ZK Circuits

Technical specification of the Groth16 circuits used for privacy-preserving withdrawals.

Withdraw Circuit

The withdraw.circom circuit proves knowledge of a valid deposit secret without revealing it. It uses Poseidon hashing for ZK-friendly cryptographic operations.

pragma circom 2.1.5;

include "../node_modules/circomlib/circuits/poseidon.circom";

template Withdraw() {
    // Private inputs (only prover knows)
    signal input secret;
    signal input nullifier;
    
    // Public inputs (on-chain verifiable)
    signal input commitment;    // Poseidon(secret, nullifier)
    signal input nullifierHash; // Hash of nullifier (prevents reuse)
    signal input recipient;     // Withdrawal recipient address
    signal input amount;        // Amount to withdraw
    
    // Compute the commitment from secret and nullifier
    component poseidon = Poseidon(2);
    poseidon.inputs[0] <== secret;
    poseidon.inputs[1] <== nullifier;
    
    // Verify computed commitment matches public commitment
    commitment === poseidon.out;
    
    // Compute nullifier hash
    component nullifierHasher = Poseidon(1);
    nullifierHasher.inputs[0] <== nullifier;
    
    // Verify nullifier hash matches
    nullifierHash === nullifierHasher.out;
}

component main {public [commitment, nullifierHash, recipient, amount]} = Withdraw();

Circuit Metrics

Metric
Constraints
Value
~1,600
Description
Total R1CS constraints
Metric
Private Inputs
Value
2
Description
secret, nullifier
Metric
Public Inputs
Value
4
Description
commitment, nullifierHash, recipient, amount
Metric
Gas Cost
Value
~200,000
Description
On-chain verification cost
Metric
Proof Time
Value
~5 seconds
Description
Browser generation time

Poseidon Hash Function

Poseidon is a ZK-friendly hash function designed for efficient circuit implementation.

Why Poseidon?

  • ~8x fewer constraints than SHA256 in ZK circuits
  • Native field arithmetic (BN254 curve)
  • Security level: 128-bit
  • Standardized in circomlib

Parameters

  • Curve: BN254
  • State width (t): 3 (for 2 inputs)
  • Rounds: 8 full + 57 partial
  • S-box: x^5

Trusted Setup

Groth16 requires a trusted setup ceremony. The keys are generated in two phases:

Compile

Circuit to R1CS

Powers of Tau

Universal setup

Phase 2

Circuit-specific

Export

Verification key

# 1. Compile circuit to R1CS
circom withdraw.circom --r1cs --wasm --sym -o build/

# 2. Powers of Tau (universal setup)
snarkjs powersoftau new bn128 14 pot14_0000.ptau
snarkjs powersoftau contribute pot14_0000.ptau pot14_0001.ptau

# 3. Phase 2 (circuit-specific)
snarkjs groth16 setup withdraw.r1cs pot14_final.ptau withdraw_0000.zkey
snarkjs zkey contribute withdraw_0000.zkey withdraw_final.zkey

# 4. Export verification key
snarkjs zkey export verificationkey withdraw_final.zkey verification_key.json

# 5. Generate Solidity verifier
snarkjs zkey export solidityverifier withdraw_final.zkey Groth16Verifier.sol
⚠️
Production Setup
For production, a multi-party computation (MPC) ceremony should be conducted to eliminate the trusted setup risk. The testnet uses development keys generated by a single party.

File Locations

The circuit files are organized in the following structure:

public/circuits/
├── withdraw_js/
│   └── withdraw.wasm      # WASM for proof generation (browser)
├── keys/
│   ├── withdraw_final.zkey # Proving key (3.5 MB)
│   └── verification_key.json # Verification key
└── withdraw.r1cs           # Circuit constraints

contracts/src/
├── Groth16Verifier.sol    # On-chain verifier (auto-generated)
└── Groth16VerifierAdapter.sol # Interface adapter

Proof Generation Flow

Proofs are generated client-side in the browser using snarkjs:

Load Circuit

WASM + zkey

Compute Inputs

Hash nullifier

Generate Proof

~5 seconds

Format

Solidity calldata

import * as snarkjs from 'snarkjs'

async function generateWithdrawProof(
  secret: bigint,
  nullifier: bigint,
  commitment: bigint,
  recipient: string,
  amount: bigint
) {
  // Load circuit files
  const wasmPath = '/circuits/withdraw_js/withdraw.wasm'
  const zkeyPath = '/circuits/keys/withdraw_final.zkey'
  
  // Compute nullifier hash
  const nullifierHash = poseidon([nullifier])
  
  // Generate proof
  const { proof, publicSignals } = await snarkjs.groth16.fullProve(
    {
      secret,
      nullifier,
      commitment,
      nullifierHash,
      recipient: BigInt(recipient),
      amount
    },
    wasmPath,
    zkeyPath
  )
  
  return { proof, publicSignals }
}

On-Chain Verification

The Groth16Verifier contract uses BN254 elliptic curve pairings to verify proofs:

Component
Proof Points
Description
A (G1), B (G2), C (G1)
Gas Cost
~10,000
Component
Verification Key
Description
Embedded in contract
Gas Cost
0 (storage)
Component
Pairing Check
Description
e(A,B) = e(α,β) * e(vk,γ) * e(C,δ)
Gas Cost
~180,000
Component
Public Signals
Description
Validate 4 public inputs
Gas Cost
~10,000
ℹ️
Verification Cost
The total gas cost of ~200,000 is dominated by the pairing check, which uses the precompiled contract at address 0x08. On L2s, this costs approximately $0.05-0.50.