The withdraw circuit is the cryptographic heart of SolMask. It proves five things in one proof, all without revealing which deposit you are spending. This page is a reference; if you want the math intuition first, read How ZK proofs work.
The circuit at a glance
| Property | Value |
|---|---|
| Proving system | Groth16 |
| Curve | BN254 (alt_bn128) |
| Hash | Poseidon (circomlib) |
| Merkle tree depth | 20 |
| Per-pool capacity | 2^20 = 1,048,576 deposits |
| Public inputs | 8 (each a 32-byte BE field element) |
The choice of Groth16 over more modern systems (PLONK, Halo2, Nova) is pragmatic: Groth16 produces ~200-byte proofs that verify on-chain in single-digit milliseconds against Solana's alt_bn128 syscalls. The cost is a circuit-specific phase-2 trusted setup, which we cover in Trusted setup.
Public inputs
The public-input vector is defined twice — once in the circuit and once in the on-chain verifier — and the orderings must match exactly:
root
nullifier_hash
recipient
fee_recipient
current_slot
mint
amount_to_recipient
change_commitment
Each input's role:
root— the Merkle root the proof commits against. The on-chain verifier checks thatrootis in the recent-root ring buffer before accepting the proof. This window lets in-flight proofs survive new deposits that change the root.nullifier_hash— the spend tag, derived in-circuit fromnullifier_secret. The program initializes a per-note nullifier record; double-spend is mechanically impossible because the second initialization fails.recipient— bound into the proof. A relayer cannot redirect funds without invalidating the proof.fee_recipient— the token account that receives the releasedamount_to_recipient. Typically a relayer-controlled escrow account that subsequent instructions in the same tx route to the final recipient (possibly via a Jupiter swap).current_slot— the slot the proof was built against. The on-chain verifier does not demand an exact match with the runtime slot — an async relayer (proof-gen → queue → build → submit) makes that unachievable. Instead it enforces a bounded backward window:current_slot ≤ clock.slotandclock.slot − current_slot ≤ 150(~60s at 400ms/slot). A future-dated slot is rejected outright, so the timelock can never be escaped; a stale proof is rejected once it drifts past the window. Combined with the in-circuitunlock_slot ≤ current_slotcheck, this is how the timing-delay privacy primitive is enforced. Anything outside the window returnsSlotMismatch.mint— must equal the pool's mint. Prevents a pool-A nullifier being applied to pool B.amount_to_recipient— how much to release from the pool's vault. Must satisfyamount_to_recipient ≤ amount_total.change_commitment— a fresh leaf inserted into the tree foramount_total - amount_to_recipient. Zero if the entire note is being spent.
Private witnesses
The private inputs never leave your browser. They are:
secret— 254-bit random scalarnullifier_secret— 254-bit random scalaramount_total— the note's total value (u64)unlock_slot— the slot at which the note becomes spendable (u64)merkle_path[20],merkle_path_indices[20]— the inclusion proofchange_secret,change_nullifier_secret,change_unlock_slot— fresh secrets for the change note (unused ifchange_amount == 0)
A deposit's commitment is Poseidon(secret, nullifier_secret, amount, unlock_slot). The commitment is the only thing that lands on-chain as the leaf. The four pre-images are derived deterministically from your wallet: connecting and signing SolMask's fixed key-derivation message yields a per-wallet seed (sha256 of the signature), and each note's secrets are HKDF-expanded from that seed. They never leave your browser, and because the derivation is deterministic the same wallet reproduces them on any device — there is no passphrase and no note file to lose. See Architecture for how the encrypted recovery blob is published on-chain so a fresh device can re-discover its notes.
The five constraints
The circuit proves all five simultaneously:
-
Leaf binding. The hashes of the private inputs reconstruct the leaf:
commitment = Poseidon(secret, nullifier_secret, amount_total, unlock_slot) nullifier_hash = Poseidon(nullifier_secret)The output
nullifier_hashis constrained equal to the public input of the same name. You cannot announce a different nullifier than the one yournullifier_secretderives — that would be a Poseidon collision, ≥254-bit work. -
Merkle membership. A depth-20 path-checker walks from your leaf up to the claimed
root. The path elements and indices are private; only the root is public. -
Timing check. A range comparator enforces
unlock_slot ≤ current_slot. Early-spend is impossible at the circuit level. -
Amount conservation.
amount_to_recipient ≤ amount_total (range check) change_amount = amount_total - amount_to_recipientThe subtraction is in the field; the range check above ensures it doesn't underflow.
-
Change-note construction. The change commitment is
Poseidon(change_secret, change_nullifier_secret, change_amount, change_unlock_slot), multiplied by(1 - isZeroChange)— so ifchange_amount == 0, the expected change commitment is the field zero. The publicchange_commitmentmust match this expected value, which means a partial withdraw cannot output a change leaf that doesn't actually match a note you control.
There is also a binding constraint that forces recipient, fee_recipient, and mint into the R1CS by squaring them. This is a circom idiom: if a public input is not otherwise constrained by an arithmetic operation, the prover could in principle ignore it. Squaring is the cheapest way to introduce a constraint without altering the semantics. The proof is non-malleable against changes to these three fields.
The Merkle tree
The tree is a binary Poseidon Merkle tree of depth 20, giving a per-pool capacity of 2^20 = 1,048,576 deposits. Empty subtrees use precomputed zero-roots. On deposit, a leaf is appended in O(log n) using a cached frontier — only the right-most filled node at each level. After insertion, the new root is pushed to the recent-root history.
The recent-root history is a ring buffer; withdraw proofs are accepted against any root in this window. The window matters because between the moment your browser builds the proof and the moment the relayer submits it, other deposits can land and change the latest root. The tolerance means many intervening deposits before your proof is stale.
If the history rolls over before your proof lands, you get an UnknownRoot error. The fix is to rebuild the proof against the new tree state — your note secrets are unchanged, only the inclusion path changes.
Nullifier derivation
nullifier_hash = Poseidon(nullifier_secret) — single-input Poseidon. Using one input domain-separates the nullifier from the leaf hash, which is four-input Poseidon. There is no preimage relationship between a leaf and its nullifier observable on-chain — the chain sees a 32-byte commitment at deposit, an unrelated 32-byte nullifier_hash at withdraw, and a nullifier_secret that is never seen.
The on-chain verifier
The on-chain verifier consumes (proof_a, proof_b, proof_c, public_inputs) and dispatches to Solana's alt_bn128_pairing syscall. The verifying key is generated deterministically from the published verification_key.json; if you want to verify the binding, re-run the generation against the published JSON and diff against the deployed verifier.
Things to verify yourself
If you want to audit the cryptography without taking our word for it:
- Re-derive a commitment from your own note using
Poseidon(4)and confirm it appears in the on-chain Merkle tree's leaf set (the relayer's indexer endpoint exposes the full leaf list per asset). - Run
snarkjs zkey verifyagainst the published phase-1 ptau and the publishedwithdraw_final.zkey. See Trusted setup. - Read the verifying key into a separate Groth16 verifier (e.g. the snarkjs CLI) and verify a real on-chain withdraw's proof bytes, which are emitted in the withdraw transaction logs.