Building ZK proofs for privacy applications

June 29, 2025 / 10 min read / - views​

Why ZK for privacy apps

Most web apps leak more data than they need. You don’t need my birthdate to know I’m over 18. You don’t need my wallet history to know I can post once per minute in a chat. Zero‑knowledge proofs let us prove a statement without revealing the inputs. In practice, that means:

  • Age / KYC properties without dumping PII
  • Membership proofs (am I in a set?) without revealing which member I am
  • Private transfers using commitments and nullifiers

We’ll build the first two end‑to‑end and sketch the transfer pattern.

Who this is for

Developers comfortable with Node.js, Solidity basics, and CLI work. If you’ve never touched ZK, it’s fine; we won’t do the algebra, we’ll focus on getting working circuits and avoiding the classic foot‑guns.


The tool choices that actually matter

  • Proving systems: Groth16, PLONK, Halo2, STARKs. On the EVM today, Groth16 on BN254 remains the pragmatic default for on‑chain verification cost and library support.

  • Languages/tooling:

    • Circom + snarkjs for fast prototyping and battle‑tested Groth16 flows.
    • Noir + Barretenberg when you want a nicer language, ACIR portability, modern PLONK variants, or you’re targeting Aztec‑style flows.

We’ll start with Circom/snarkjs because it’s the shortest path to a working demo and a Solidity Verifier.sol.


Part 1 — Prove “I’m at least 18” without revealing age

This is a tiny circuit but it forces you to learn three things that matter everywhere: range constraints, public vs private signals, and trusted setup.

Circuit

// File: circuits/AgeAtLeast.circom
pragma circom 2.1.5;

include "circomlib/circuits/comparators.circom";     // LessThan(n)

// Proves: age >= threshold. Threshold is public, age is private.
// nBits should bound the max age you care about; 8 bits covers 0..255.
template AgeAtLeast(nBits) {
    assert(nBits <= 252);

    signal input age;            // private
    signal input threshold;      // public
    signal output ok;            // 1 if age >= threshold

    // age >= threshold  <=>  threshold < age
    component lt = LessThan(nBits);
    lt.in[0] <== threshold;     // a
    lt.in[1] <== age;           // b

    ok <== lt.out;              // 1 when a < b
}

// Expose threshold as public input of main.
component main { public [threshold] } = AgeAtLeast(8);

Build and prove (Groth16)

# 0) Prepare folders
mkdir -p build && mkdir -p build/age && cd build/age
 
# 1) Compile the circuit
circom ../../circuits/AgeAtLeast.circom --r1cs --wasm -o .
 
# 2) Create input
cat > input.json <<'JSON'
{ "age": 23, "threshold": 18 }
JSON
 
# 3) Compute witness
node AgeAtLeast_js/generate_witness.js AgeAtLeast_js/AgeAtLeast.wasm input.json witness.wtns
 
# 4) Groth16 trusted setup (Powers of Tau -> circuit key)
# Use a prebuilt ptau file in real projects; shown here end-to-end for clarity.
snarkjs powersoftau new bn128 14 pot14_0000.ptau
snarkjs powersoftau contribute pot14_0000.ptau pot14_0001.ptau --name="contrib1" -e="random"
snarkjs powersoftau prepare phase2 pot14_0001.ptau pot14_final.ptau
 
snarkjs groth16 setup AgeAtLeast.r1cs pot14_final.ptau AgeAtLeast_0000.zkey
snarkjs zkey contribute AgeAtLeast_0000.zkey AgeAtLeast_final.zkey --name="key1" -e="random"
snarkjs zkey export verificationkey AgeAtLeast_final.zkey verification_key.json
 
# 5) Generate proof
snarkjs groth16 prove AgeAtLeast_final.zkey witness.wtns proof.json public.json
 
# 6) Verify locally
snarkjs groth16 verify verification_key.json public.json proof.json

On‑chain verifier

# Generate Solidity verifier
snarkjs zkey export solidityverifier AgeAtLeast_final.zkey ../../contracts/Verifier_Age.sol
 
# Optional: generate calldata for verifyProof()
snarkjs generatecall

Minimal integration contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import "./Verifier_Age.sol";
 
contract AgeGate {
    Verifier public immutable verifier;
    uint256 public immutable threshold; // must match the public input
 
    constructor(address _verifier, uint256 _threshold) {
        verifier = Verifier(_verifier);
        threshold = _threshold;
    }
 
    function enter(
        uint[2] memory a,
        uint[2][2] memory b,
        uint[2] memory c,
        uint[1] memory pubSignals // [threshold]
    ) external view returns (bool) {
        require(pubSignals[0] == threshold, "bad threshold");
        return verifier.verifyProof(a, b, c, pubSignals);
    }
}

Why Groth16 here? Proofs are tiny and verify quickly with EVM precompiles on BN254, which keeps gas sane for L1/L2. For app‑level gates like this, that matters.


Part 2 — Anonymous membership with a nullifier (Semaphore‑style)

This is the workhorse pattern for “I am in the group but don’t link me” with one‑use or rate‑limited actions. We’ll verify a Merkle inclusion against a public root and compute a nullifier hash to prevent double‑use under an external context.

Circuit

// File: circuits/SemaphoreLite.circom
pragma circom 2.1.5;

include "circomlib/circuits/poseidon.circom"; // Poseidon(n)

// A simple Poseidon Merkle path verifier + nullifier hash.
template MerklePath(depth) {
    signal input leaf;
    signal input pathElements[depth];
    signal input pathIndex[depth];   // 0 if leaf is left child at this level, 1 if right
    signal output root;

    signal cur[depth+1];
    cur[0] <== leaf;

    component H[depth];
    signal left[depth];
    signal right[depth];

    for (var i = 0; i < depth; i++) {
        H[i] = Poseidon(2);
        // Select ordering based on pathIndex
        left[i]  <== (1 - pathIndex[i]) * cur[i] + pathIndex[i] * pathElements[i];
        right[i] <== pathIndex[i] * cur[i] + (1 - pathIndex[i]) * pathElements[i];

        H[i].inputs[0] <== left[i];
        H[i].inputs[1] <== right[i];
        cur[i+1] <== H[i].out;
    }
    root <== cur[depth];
}

// Computes a nullifier hash from a private identity secret and a public external nullifier.
template SemaphoreLite(depth) {
    signal input leaf;                       // private: identity commitment leaf
    signal input pathElements[depth];        // private
    signal input pathIndex[depth];           // private

    signal input merkleRoot;                 // public
    signal input extNullifier;               // public context (e.g., room id, epoch)
    signal input identityNullifier;          // private secret

    signal output nullifierHash;             // public output

    component mp = MerklePath(depth);
    mp.leaf <== leaf;
    for (var i=0; i<depth; i++) {
        mp.pathElements[i] <== pathElements[i];
        mp.pathIndex[i]   <== pathIndex[i];
    }

    // Must match the public Merkle root
    merkleRoot === mp.root;

    // Nullifier hash = Poseidon(identityNullifier, extNullifier)
    component N = Poseidon(2);
    N.inputs[0] <== identityNullifier;
    N.inputs[1] <== extNullifier;

    nullifierHash <== N.out;
}

// Expose merkleRoot, extNullifier, nullifierHash as public
component main { public [merkleRoot, extNullifier, nullifierHash] } = SemaphoreLite(20);

Usage pattern

  • Keep the Merkle root on‑chain (or signed off‑chain) as the public set anchor.
  • A user proves inclusion with a private path and outputs nullifierHash for the specific extNullifier (for example, the chat room id and epoch).
  • Your contract stores used nullifierHash values for that context to prevent double‑use.

Solidity sketch:

mapping(bytes32 => bool) public used; // keyed by nullifierHash
 
function post(
    uint[2] memory a,
    uint[2][2] memory b,
    uint[2] memory c,
    uint[3] memory pub // [merkleRoot, extNullifier, nullifierHash]
) external {
    require(pub[0] == currentRoot, "stale root");
    bytes32 nf = bytes32(pub[2]);
    require(!used[nf], "double-use");
    require(verifier.verifyProof(a,b,c,pub), "bad proof");
    used[nf] = true;
    // proceed (emit, write, etc.)
}

Tips that save days:

  • Fix your hash domain separation. If you ever hash different tuples into the same Poseidon state, add domain tags (constants) so leaves and nullifiers can’t collide.
  • Be consistent on left/right ordering between your off‑chain Merkle tree and the circuit. A single flipped bit in pathIndex breaks everything.
  • Poseidon params must match between JS/TS code and circuits (arity, round constants, field).

Part 3 — Private transfers with commitments and nullifiers (the pattern)

Production privacy coins and privacy layers model balances as notes: Pedersen‑ or Poseidon‑based commitments to value and recipient. Spending a note reveals a nullifier derived from the note and a secret key. Verifiers check the nullifier hasn’t appeared before. Crucially, the nullifier can’t be linked back to the original commitment.

An implementable sketch for an ERC‑20‑like private pool:

  1. Mint notes: C_i = Commit(value_i, recipient_pk, rho_i)
  2. Spend: Prove knowledge of a note in the Merkle tree, that you own it, and that the sum of inputs equals sum of outputs (plus fees). Publish the per‑input nullifiers.
  3. Verify: Check inclusion, balance, and that none of the nullifiers were seen. Update Merkle root with new output notes.

I won’t dump a full mixer here, but the circuit ingredients are the same as in Part 2 plus conservation constraints and range checks on values.


When to use Noir instead

Noir gives you a nicer language, an ACIR intermediate representation, and a first‑class TypeScript story. If you’re building a web app with client‑side proving, the noirjs + Barretenberg stack is a joy compared to shelling out to snarkjs. The trade‑off is different verifier contracts and usually PLONK‑style proofs rather than Groth16.

Minimal Noir age example (conceptual):

// File: noir/age/src/main.nr
fn main(age: u8, threshold: u8) -> pub bool {
    constrain(threshold < age);
    return true;
}

Then:

nargo new age && cd age
nargo prove pkey.toml # or use the Barretenberg backend via noirjs in a web app

If you need EVM verification, generate the matching verifier from the backend you pick (Barretenberg provides Solidity verifiers for its proofs). For Aztec or app‑specific rollups, you’ll likely verify off‑chain or via the network’s native verifier.


Choosing a proving system (pragmatic cheat‑sheet)

  • Groth16: smallest proofs, fastest verification on EVM (BN254 precompiles). Downsides: trusted setup per circuit. Great for app gates, Semaphore‑style flows, mixers.
  • PLONK/FFLONK: universal or updatable setups, slightly larger proofs, simpler dev ergonomics. Good middle ground when you don’t want per‑circuit ceremonies.
  • Halo2: recursion‑friendly, no trusted setup for some constructions; great for complex circuits, but on‑chain EVM verification is not as cheap as Groth16 today.
  • STARKs: transparent setup, huge proofs, excellent for rollups and audits, usually verified off‑chain or on specialized L2s.

If you’re verifying on EVM L1/L2, Groth16 on BN254 is still hard to beat for raw gas. If you verify off‑chain, pick whatever gives you the best dev experience.


Debugging and security checklist

  • Under‑constrained circuits: if you don’t constrain it, provers will “solve” it. Add assertions liberally and test failing cases. Static analyzers like Circomspect help.
  • Range checks and field size: remember BN254’s field is < 254 bits. Use Num2Bits_strict or comparators for safe range proofs instead of naive swaps to bits.
  • Merkle path alignment: document endianness and left/right conventions. Write a test that reconstructs the root in JS and in the circuit from the same path.
  • Poseidon versioning: keep the same parameters across off‑chain code, circuit, and any on‑chain Poseidon precompiles you might use.
  • Trusted setup hygiene: never ship with a single‑party ceremony. Use a publicly verifiable Powers of Tau and keep transcripts.
  • Version bumps: track circuit library releases. Bugs in common templates do get found and fixed; bump and re‑prove when needed.

Performance tips

  • Reduce tree depth by using higher‑arity Poseidon trees if your library supports them; fewer hash rounds per inclusion proof.
  • Batch proofs off‑chain where possible; verify one aggregate on‑chain.
  • Cache constants in circuits and avoid variable‑length loops; Circom unrolls loops at compile‑time.
  • Keep public inputs minimal; they land on‑chain and can bloat calldata.

Quick commands you’ll reuse a lot

# Compile Circom
circom circuit.circom --r1cs --wasm -o build
 
# Witness
node circuit_js/generate_witness.js circuit_js/circuit.wasm input.json witness.wtns
 
# Groth16 setup and proof
snarkjs powersoftau new bn128 17 pot.ptau
snarkjs powersoftau contribute pot.ptau pot1.ptau -e="rand"
snarkjs powersoftau prepare phase2 pot1.ptau pot_final.ptau
snarkjs groth16 setup circuit.r1cs pot_final.ptau circuit_0000.zkey
snarkjs zkey contribute circuit_0000.zkey circuit_final.zkey -e="rand"
snarkjs zkey export verificationkey circuit_final.zkey verification_key.json
snarkjs groth16 prove circuit_final.zkey witness.wtns proof.json public.json
snarkjs groth16 verify verification_key.json public.json proof.json
snarkjs zkey export solidityverifier circuit_final.zkey contracts/Verifier.sol
snarkjs generatecall # calldata args for verifyProof

What we built

  • AgeAtLeast.circom with a working Groth16 flow and Solidity verifier
  • SemaphoreLite.circom for anonymous membership with nullifiers
  • A mental model and code patterns for private transfers using commitments/nullifiers

If you hit weirdness, it’s usually a range constraint missing, a left/right Merkle mismatch, or Poseidon params out of sync. Lock those down early.