Transaction Model
Note Structure
A note represents an unspent output in the pool:
Note {
amount: bigint — USDC amount in stroops (1 USDC = 10,000,000 stroops)
blinding: bigint — Random blinding factor (256-bit)
pubKeyLE: bytes[32] — Owner's BN254 public key (little-endian)
leafIndex: uint32 — Position in the Merkle tree
}The note is hidden inside its commitment:
commitment = Poseidon2(amount, pubKey, blinding, 0x01)Only the note holder (who knows amount, blinding, and privKey) can spend it.
Spending a Note
To spend a note, the prover must:
- Demonstrate knowledge of
(amount, blinding, privKey)such thatcommitment = Poseidon2(amount, pubKey, blinding, 0x01) - Prove the commitment exists in the Merkle tree at
leafIndexwith a valid path to the current root - Compute a nullifier:
nullifier = Poseidon2(commitment, pathIndices, privKey, 0x02) - Sign the transaction:
signature = Poseidon2(commitment, privKey, 0x04)
The nullifier is published on-chain. Because it’s derived from the commitment AND the private key, it’s deterministic (same note always produces the same nullifier) but unlinkable (cannot derive the commitment from the nullifier).
Merkle Tree
10-level binary Merkle tree with Poseidon2 internal hashing:
root
/ \
h01 h23
/ \ / \
h0 h1 h2 h3
| | | |
c0 c1 c2 c3 ... c1023
Internal node: Poseidon2(left_child, right_child)
Leaf: commitment (Poseidon2 hash)
Empty leaf: Poseidon2(0) — the "zero leaf"| Property | Value |
|---|---|
| Depth | 10 |
| Max leaves | 1,024 |
| Hash function | Poseidon2 (no domain separator for internal nodes) |
| Root storage | Ring buffer of 90 recent roots |
The ring buffer allows proofs to be generated against slightly stale roots (useful when multiple transactions are in-flight).
Nullifier Set
Nullifiers are stored on-chain as a persistent set. The contract checks !nullifiers.contains(n) before accepting a transaction. After acceptance, the nullifier is added: nullifiers.insert(n).
This provides on-chain double-spend protection that cannot be circumvented by the proxy operator.
ExtData and Front-Running Protection
The extDataHash public input binds the proof to specific external data:
extDataHash = Poseidon2(
recipient,
ext_amount,
encrypted_output0,
encrypted_output1
)This prevents front-running: a malicious miner cannot change the recipient or amount without invalidating the proof.