ZK Proofs
Groth16 proving system and circuit design
Overview#
Mini Veil uses Groth16 zero-knowledge proofs over the BN254 curve (alt_bn128). The circuit proves Merkle membership without revealing which leaf belongs to the spender. Proofs are generated client-side via snarkjs and verified on-chain via Solana's alt_bn128 syscalls.
Circuit: mixer_withdraw.circom#
Public Inputs (4 signals)#
| Index | Name | Description |
|---|---|---|
| 0 | merkle_root | Current on-chain Merkle root |
| 1 | nullifier_hash | Poseidon(nullifier) — prevents double-spending |
| 2 | recipient | Stealth address as BN254 field element |
| 3 | denomination | Pool denomination as BN254 field element |
Private Inputs (5 + 2×depth)#
| Name | Description |
|---|---|
| spending_key | User's mixer spending key (field element) |
| counter | Note counter (field element) |
| secret | Random secret (field element) |
| pathElements[20] | Merkle sibling path |
| pathIndices[20] | Binary path indices (0=left, 1=right) |
Circuit Constraints#
1. nullifier = Poseidon(spending_key, DOMAIN_SEPARATOR, counter)
2. nullifierHash = Poseidon(nullifier) ← public output
3. leaf = Poseidon(nullifier, secret)
4. MerkleTreeChecker(depth=20).verify(root, leaf, pathElements, pathIndices)
5. recipientSquare = recipient * recipient ← tamper protection
6. denomSquare = denomination * denomination ← tamper protection
Circuit Line 67#
Line 67 of mixer_withdraw.circom uses Poseidon with 1 input:
nullifierHash = Poseidon(1)(nullifier)
This corresponds to poseidon1([nullifier]) in JavaScript (from poseidon-lite).
Proving System#
| Parameter | Value |
|---|---|
| Curve | BN254 (alt_bn128) |
| Protocol | Groth16 |
| Proving key size | ~50 MB (.zkey) |
| Verification key | ~1 KB (embedded in program) |
| Proof size | 256 bytes (G1 + G2 + G1) |
| Proving time | ~2-5 seconds (browser, snarkjs) |
Client-side Proving#
const { proof, publicSignals } = await snarkjs.groth16.fullProve(
{
merkle_root,
nullifier_hash,
recipient,
denomination,
spending_key,
counter,
secret,
pathElements,
pathIndices,
},
"public/circuits/mixer_withdraw.wasm",
"public/circuits/mixer_withdraw_final.zkey",
);
Proof Packing#
The proof must be packed into Solana-compatible byte arrays:
// packG1: string[] → number[] (64 bytes)
// Input: ["x", "y"] as decimal strings
// Output: [x_hi, x_lo, y_hi, y_lo] — big-endian 32-byte halves
// packG2: string[][] → number[] (128 bytes)
// Input: [[x_im, x_re], [y_im, y_re]] as decimal strings
// Output: [x_re, x_im, y_re, y_im] — note: snarkjs returns [im, re]
// The packG2 function swaps to [re, im] for Solana compatibility
// packPublicSignals: string[] → number[][]
// Each signal packed as 32-byte big-endian
On-chain Verification#
fn verify_groth16_proof(
proof: &Proof,
vk: &Groth16Vk,
public_inputs: &[[u8; 32]],
) -> Result<()> {
// 1. Check public input count matches VK
require!(public_inputs.len() + 1 == vk.ic.len());
// 2. Linear combination: vk_x = IC[0] + Σ(pub_i * IC[i+1])
let vk_x = alt_bn128_multi_exponentiation(&vk.ic, public_inputs);
// 3. Negate proof A (negate Y coordinate)
let neg_a = negate_g1(&proof.a);
// 4. Pairing check
let pairing = alt_bn128_pairing(
[neg_a, proof.b],
[vk.alpha_g1, vk.beta_g2],
[vk_x, vk.gamma_g2],
[proof.c, vk.delta_g2],
);
// 5. Verify result
require!(pairing[31] == 1); // big-endian uint256
}
Verifier Errors#
| Error | Cause |
|---|---|
| InvalidPublicInputs | Wrong number of public inputs |
| MultiexpFailed | G1 multiexponentiation failure |
| AdditionFailed | G1 addition failure |
| PairingFailed | Pairing check returned non-1 |
| ProofVerificationFailed | Generic verification failure |
Two VK Files#
The project has two verification_key.json files:
| Location | Used For |
|---|---|
| circuits/build/verification_key.json | Embedded in program (vk.rs) |
| circuits/build/mixer_withdraw/verification_key.json | Frontend reference only |
Important: These two VK files have different vk_delta_2 values. When rebuilding circuits, vk.rs must be regenerated from the correct verification_key.json and the program redeployed.