protocol

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.