ZK-SNARK Circuit
Deep dive into the zero-knowledge circuit design
ZK-SNARK Circuit
The ZKMix circuit is the cryptographic engine that enables private withdrawals. It is a Groth16 ZK-SNARK circuit written in Circom that proves the following compound statement without revealing any private information: the prover knows a secret and nullifier whose hash exists as a leaf in a Merkle tree with a publicly known root, and that the nullifier hashes to a publicly known nullifier hash. This section describes the circuit's design, inputs, constraints, and performance characteristics in detail.
What the Circuit Proves
The ZKMix circuit encodes a proof of membership with nullification. Formally, the prover demonstrates knowledge of witnesses (secret, nullifier, pathElements[0..19], pathIndices[0..19]) such that:
- Commitment reconstruction:
commitment = Poseidon(nullifier, secret)-- the prover knows the preimage of a valid commitment. - Merkle membership: The commitment exists as a leaf in the incremental Merkle tree with root
root, as verified by the authentication path defined bypathElementsandpathIndices. - Nullifier binding:
nullifierHash = Poseidon(nullifier)-- the revealed nullifier hash is correctly derived from the private nullifier.
The proof is zero-knowledge: the verifier learns nothing about which leaf the prover knows, what the secret or nullifier values are, or which authentication path was used. The verifier only learns that some valid leaf exists in the tree and that the prover knows its preimage.
The recipient address, relayer address, and fee are included as public inputs but are not directly constrained by the circuit's arithmetic. They are hashed into the proof's public input vector, which binds the proof to these specific values. Any attempt to alter the recipient, relayer, or fee after proof generation will cause the on-chain verification to fail because the public input hash will not match.
Circuit Inputs
Public Inputs
Public inputs are values known to both the prover and verifier. They are submitted alongside the proof to the on-chain verifier program.
| Input | Type | Description |
|---|---|---|
root | Field (BN128) | The Merkle tree root at the time of proof generation. The on-chain program checks this against a set of recently stored roots to account for deposits that occur between proof generation and on-chain submission. |
nullifierHash | Field (BN128) | The Poseidon hash of the nullifier: Poseidon(nullifier). This value is stored on-chain after a successful withdrawal to prevent the same deposit from being withdrawn twice. |
recipient | Field (BN128) | The Solana address (public key) of the withdrawal recipient, encoded as a 256-bit integer reduced modulo the BN128 scalar field order. Binding this to the proof prevents front-running attacks where a relayer substitutes their own address. |
relayer | Field (BN128) | The Solana address of the relayer submitting the withdrawal transaction. Set to zero if the user submits directly. Binding this value prevents relayer substitution. |
fee | Field (BN128) | The fee (in lamports or token base units) paid to the relayer. Set to zero for direct submissions. This value is bound to the proof to prevent relayers from inflating fees after the user generates the proof. |
refund | Field (BN128) | An optional SOL refund amount sent to the recipient for gas costs when withdrawing SPL tokens. Bound to the proof for the same anti-tampering reasons. |
Private Inputs (Witnesses)
Private inputs are known only to the prover. They are never transmitted to the verifier or stored on-chain.
| Input | Type | Description |
|---|---|---|
secret | Field (BN128) | A 248-bit random value generated at deposit time. Combined with the nullifier to form the commitment. Must be stored securely by the user (typically encoded in the deposit note). |
nullifier | Field (BN128) | A 248-bit random value generated at deposit time. Its hash serves as the public nullifier hash. Must be stored securely by the user. The nullifier is distinct from the secret to allow the nullifier hash to be revealed without leaking information about the commitment's full preimage. |
pathElements[20] | Field[20] | The 20 sibling hashes along the Merkle authentication path from the commitment leaf to the root. Each element is the hash of the sibling node at that tree level. |
pathIndices[20] | Bit[20] | A 20-bit vector indicating whether the commitment is the left child (0) or right child (1) at each level of the tree. This determines the ordering of inputs to each Poseidon hash in the path computation. |
Groth16 Proving System
ZKMix uses the Groth16 proving system, which is currently the most efficient SNARK scheme for on-chain verification in terms of proof size and verification cost.
Why Groth16
Constant proof size: Every Groth16 proof consists of exactly three elliptic curve elements: two points on G1 and one point on G2 of the BN128 curve. This totals 256 bytes regardless of circuit complexity, making storage and transmission costs fixed and predictable.
Constant verification time: Verification requires exactly one pairing equation check involving three pairing computations, making the on-chain gas/compute cost constant and independent of the number of constraints in the circuit.
Mature tooling: The Circom compiler and snarkjs library provide a battle-tested pipeline for circuit development, trusted setup, proving, and verification. The same toolchain has been audited and deployed in production by Tornado Cash, Semaphore, and numerous other protocols.
Trade-offs
Trusted setup: Groth16 requires a circuit-specific trusted setup ceremony (Phase 2 of the Powers of Tau ceremony). The security assumption is that at least one participant in the ceremony honestly destroyed their secret randomness. If all participants collude or are compromised, it becomes possible to forge proofs. ZKMix mitigates this through a large-scale, publicly verifiable ceremony.
No universal setup: Unlike PLONK or Halo2, Groth16's setup is circuit-specific. Any change to the circuit (e.g., changing tree depth or hash function) requires a new trusted setup ceremony. This makes the circuit design a high-stakes, infrequently changed artifact.
Proof Structure
A Groth16 proof consists of:
proof = {
pi_a: G1Point (64 bytes) // First G1 element
pi_b: G2Point (128 bytes) // G2 element (two field elements per coordinate)
pi_c: G1Point (64 bytes) // Second G1 element
}Total: 256 bytes on the wire, serialized as uncompressed curve points for on-chain compatibility with Solana's alt_bn128 precompiles.
Circuit Constraints
The ZKMix circuit consists of approximately 28,000 R1CS constraints for a Merkle tree depth of 20. These constraints decompose into three functional blocks:
1. Poseidon Hash for Commitment Verification
commitment = Poseidon(nullifier, secret)The circuit computes the Poseidon hash of the nullifier and secret, then asserts that the result equals the leaf value used in the Merkle path verification. This uses the Poseidon permutation with the following parameters:
- Field: BN128 scalar field (p = 21888242871839275222246405745257275088548364400416034343698204186575808495617)
- Width: t = 3 (2 inputs + 1 capacity element)
- Full rounds: 8 (4 at the start, 4 at the end)
- Partial rounds: 57
- S-box: x^5 (fifth power, chosen for its low multiplicative depth and resistance to algebraic attacks)
Each full round applies the S-box to all t state elements, while each partial round applies it to only one element. The round constants and MDS matrix are derived deterministically from a seed using the Grain LFSR, following the Poseidon specification.
This single Poseidon invocation (2-to-1 hash) accounts for approximately 700 constraints.
2. Merkle Path Verification
The circuit verifies a 20-level Merkle authentication path. At each level i, it computes:
if pathIndices[i] == 0:
currentHash = Poseidon(currentHash, pathElements[i])
else:
currentHash = Poseidon(pathElements[i], currentHash)This requires:
- 20 Poseidon hash invocations (2-to-1), each consuming ~700 constraints
- 20 conditional swap operations using
pathIndices[i]as the selector, each consuming ~4 constraints (two multiplications for the conditional selection) - A final equality check:
currentHash === root
Total for Merkle path verification: approximately 14,080 constraints (20 x 704).
The conditional swap is implemented without branching (which is not possible in arithmetic circuits) using the algebraic identity:
left = currentHash + pathIndices[i] * (pathElements[i] - currentHash)
right = pathElements[i] + pathIndices[i] * (currentHash - pathElements[i])
nextHash = Poseidon(left, right)This ensures that when pathIndices[i] = 0, the current hash is on the left, and when pathIndices[i] = 1, it is on the right.
3. Nullifier Hash Computation
nullifierHash = Poseidon(nullifier)The circuit computes the Poseidon hash of the nullifier alone (1-to-1 hash, though internally Poseidon still uses width t = 2 with one input and one capacity element) and constrains it to equal the public input nullifierHash.
This accounts for approximately 600 constraints (slightly fewer than the 2-input variant due to the reduced width).
4. Input Validation Constraints
Additional constraints ensure:
pathIndices[i]is binary (0 or 1) for each level: 20 constraints of the formpathIndices[i] * (1 - pathIndices[i]) === 0.secretandnullifierare within valid range (less than the field modulus): enforced implicitly by the field arithmetic, but range checks may add ~500 constraints for bit decomposition if the circuit enforces 248-bit inputs.- Public inputs (
recipient,relayer,fee,refund) are included in the public input vector: these are "free" constraints as they are simply wired into the verification equation.
Constraint Summary
| Component | Approximate Constraints |
|---|---|
| Commitment hash (Poseidon 2-to-1) | ~700 |
| Merkle path (20 levels x Poseidon + swap) | ~14,080 |
| Nullifier hash (Poseidon 1-to-1) | ~600 |
| Path index binary checks | ~20 |
| Input range checks | ~500 |
| Miscellaneous wiring | ~100 |
| Total | ~16,000 |
Note: The exact constraint count depends on the specific Poseidon implementation and Circom compiler optimizations. Production circuits may vary between 15,000 and 30,000 constraints depending on parameterization and whether additional safety checks are included.
Proof Generation Time
Proof generation is the most computationally expensive client-side operation. Benchmarks vary significantly by platform:
| Platform | Prover | Time | Notes |
|---|---|---|---|
| Desktop (x86_64, 8 cores) | rapidsnark (native) | ~2 seconds | Optimal for CLI and server-side use |
| Desktop (x86_64) | snarkjs (Node.js) | ~8 seconds | Pure JavaScript, no native dependencies |
| Browser (modern desktop) | snarkjs (WASM) | ~10-15 seconds | Runs in Web Worker to avoid UI blocking |
| Mobile browser (flagship) | snarkjs (WASM) | ~20-40 seconds | Varies widely by device; memory pressure on low-end devices |
The proving key for the depth-20 circuit is approximately 40 MB. This file must be downloaded once and can be cached in the browser's IndexedDB or on disk for subsequent proofs. The initial download is a significant UX cost, but subsequent proof generations only require the computation time listed above.
Memory requirements during proof generation are approximately 256 MB for snarkjs and 128 MB for rapidsnark. Browser-based proving may fail on devices with limited available memory.
Verification Cost on Solana
On-chain verification of a Groth16 proof on Solana leverages the alt_bn128 precompiled instructions introduced in Solana v1.14:
sol_alt_bn128_g1_add: Elliptic curve point addition on G1 (used for combining public input points)sol_alt_bn128_g1_multiply: Scalar multiplication on G1 (used for computing the public input linear combination)sol_alt_bn128_pairing: Bilinear pairing check (the core verification equation)
Compute Unit Breakdown
| Operation | Count | CU per Op | Total CU |
|---|---|---|---|
| G1 scalar multiplication | 6 (public inputs) | ~12,000 | ~72,000 |
| G1 addition | 6 | ~1,000 | ~6,000 |
| Pairing check (3 pairs) | 1 | ~120,000 | ~120,000 |
| Instruction deserialization | - | - | ~5,000 |
| State reads/writes | - | - | ~10,000 |
| Total | ~213,000 |
Withdrawal transactions request 400,000 compute units to provide margin for state operations (nullifier hash storage, root lookup, token transfers) beyond the raw proof verification cost. At Solana's priority fee market rates, a typical withdrawal transaction costs between 5,000 and 25,000 lamports in compute fees, plus the standard 5,000 lamport base fee per signature.
Verification Equation
The on-chain verifier checks the Groth16 pairing equation:
e(pi_a, pi_b) = e(alpha, beta) * e(sum_i(pub_i * IC_i), gamma) * e(pi_c, delta)Where:
e()is the bilinear pairing on BN128alpha,beta,gamma,deltaare verification key elements (hardcoded in the verifier program)IC_iare the verification key's public input commitment pointspub_iare the public input valuespi_a,pi_b,pi_care the proof elements
This equation is checked by computing the left-hand side and right-hand side and verifying their equality, or equivalently, by checking that a combined product of pairings equals the identity element. The precompiled alt_bn128_pairing instruction accepts multiple pairs and checks that their combined pairing product equals one, which is how the verification is implemented in practice.
Circuit Compilation and Artifacts
The circuit compilation pipeline produces the following artifacts:
- circuit.r1cs: The Rank-1 Constraint System file encoding all constraints. Used during the trusted setup ceremony and for generating the proving/verification keys.
- circuit.wasm: A WebAssembly binary that computes the witness (all intermediate wire values) given the inputs. This is loaded by the prover at proof generation time.
- circuit.sym: Symbol table mapping wire indices to named signals. Used for debugging but not required in production.
- proving_key.zkey: The Groth16 proving key, generated by the trusted setup. Contains the encrypted evaluation of the circuit's polynomials at the toxic waste point. Approximately 40 MB for the depth-20 circuit.
- verification_key.json: The verification key, containing the curve points needed for the pairing check. This is embedded in the on-chain verifier program at deployment time. Approximately 2 KB.
The proving key and WASM file are distributed to clients via a CDN or bundled with the application. The verification key is immutably stored in the on-chain verifier program's data account or hardcoded in its instruction logic.