Skip to Content
ProtocolTransaction Model

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:

  1. Demonstrate knowledge of (amount, blinding, privKey) such that commitment = Poseidon2(amount, pubKey, blinding, 0x01)
  2. Prove the commitment exists in the Merkle tree at leafIndex with a valid path to the current root
  3. Compute a nullifier: nullifier = Poseidon2(commitment, pathIndices, privKey, 0x02)
  4. 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"
PropertyValue
Depth10
Max leaves1,024
Hash functionPoseidon2 (no domain separator for internal nodes)
Root storageRing 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.

Last updated on