UTXO System
How funds are represented, committed, and discovered within the shielded pool.
UTXO Structure
A UTXO (Unspent Transaction Output) represents funds in the shielded pool. Unlike traditional account balances, each UTXO is a discrete unit of value with cryptographic ownership.
1class Utxo {2amount: BN // Value in base units (lamports, SPL base units)3blinding: BN // Random factor hiding the amount in the commitment4pubkey: BN // BN254 public key of the owner5keypair?: Keypair // BN254 keypair (only present if we own this UTXO)6index: number // Position in the Merkle tree7mintAddress: string // Token mint (SOL = "So1111...1112")8version: 'v1'|'v2' // Key derivation version9}The mintAddress field is used in the commitment hash and for UTXO filtering, but is not serialized in the current binary v2 encrypted format. Instead, it is provided by the caller from the per-token Merkle tree context during decryption — saving 32 bytes per output on-chain.
Commitment
The commitment is what goes on-chain — a Poseidon hash that hides all values while enabling ZK verification.
commitment = Poseidon(amount, pubkey, blinding, mintAddressField)
Properties:
• Hiding: Cannot extract amount, pubkey, or blinding from the hash
• Binding: Cannot find different inputs that produce the same hash
• Verifiable: ZK circuit can prove knowledge of inputs without revealing themThe commitment uses the Poseidon hash function — specifically designed for efficient computation inside ZK circuits (SNARKs). Standard hashes like SHA-256 would be prohibitively expensive to prove in a circuit.
Nullifier
The nullifier prevents double-spending. When a UTXO is spent, its nullifier is published on-chain. If the same nullifier appears again, the transaction is rejected.
nullifier = Poseidon(commitment, index, Sign(commitment, index))
Key property: Requires the PRIVATE KEY to compute
→ Only the owner can produce a valid nullifier
→ Published on-chain when spending
→ If seen before → double-spend rejectedNullifiers are unlinkable to their source UTXO from an observer's perspective. An on-chain observer sees a nullifier hash but cannot determine which commitment it corresponds to — this is what breaks the deposit-to-withdrawal link.
Creation Modes
UTXOs are created differently depending on whether you're depositing for yourself or for a paylink recipient.
1// SELF-DEPOSIT: Depositing for yourself2const selfUtxo = new Utxo({3amount: BN(1_500_000_000), // 1.5 SOL4keypair: myBN254Keypair, // Full keypair (private + public)5});6// → Has private key → can generate nullifier → can spend78// PAYLINK DEPOSIT: Depositing for a recipient9const paylinkUtxo = new Utxo({10amount: BN(1_500_000_000), // 1.5 SOL11publicKey: recipientBN254Pubkey, // Only public key12});13// → No private key → can generate commitment → CANNOT spend14// → The recipient, with their private key, can spend later| Mode | Has Private Key | Can Compute Nullifier | Can Spend |
|---|---|---|---|
| Self-deposit | Yes | Yes | Yes |
| Paylink deposit | No (pubkey only) | No | No (only recipient can) |
UTXO Scanning
The Problem
The shielded pool contains all encrypted UTXOs from all users. To find your own, you'd need to attempt decryption on every single one. With thousands of UTXOs, this is prohibitively slow.
The Solution: Recipient ID Hash
Each encrypted note includes an 8-byte recipientIdHash — a truncated hash of the owner's public key. Before attempting expensive decryption, recipients perform a cheap byte comparison.
Implementation
1private shouldAttemptDecryption(encryptedBuffer: Buffer): boolean {2const firstByte = encryptedBuffer[0];34// Compact format (current): single-byte tag at position 05// recipientIdHash is at bytes 1-86if (firstByte === 0xC2 || firstByte === 0xC3) {7 const storedHash = encryptedBuffer.subarray(1, 9); // 8 bytes in blob8 const myHash = this.deriveRecipientIdHash(); // 8 bytes from my key9 return storedHash.equals(myHash); // Fast compare10}1112// Standard format (legacy): schema version at byte 813// recipientIdHash is at bytes 9-1714const schemaVersion = encryptedBuffer[8];15if (schemaVersion === 0x02) {16 const storedHash = encryptedBuffer.subarray(9, 17);17 const myHash = this.deriveRecipientIdHash();18 return storedHash.equals(myHash);19}2021// Schema 0x01 (legacy) → must attempt decrypt (no hash in format)22return true;23}False Positive Rate
The 8-byte hash provides 2^64 possible values. The probability of a false positive (hash collision) is approximately:
P(false positive) = 1 / 2^64 ≈ 5.4 × 10⁻²⁰
With 50,000 UTXOs in the pool:
Expected false positives = 50,000 / 2^64 ≈ 0.0000000000000027
In practice: effectively zero false positives everThe recipientIdHash does NOT compromise privacy. An observer cannot reverse the hash to identify the recipient — it's derived from the BN254 public key which is itself unlinkable to the wallet address.
Further Reading
- Cryptography — The encryption schemes that protect UTXO data
- Zero-Knowledge Proofs — How spending is verified without revealing which UTXO
- Privacy Model — How UTXOs fit into the overall privacy architecture