Zero-Knowledge Proofs Introduction

Difficulty: Advanced

Zero-knowledge proofs (ZKPs) are cryptographic protocols that allow one party (the prover) to convince another party (the verifier) that a statement is true without revealing any information beyond the truth of the statement itself. In blockchain, ZKPs have two transformative applications: privacy (proving you have sufficient funds without revealing your balance, proving you are over 18 without revealing your age) and scalability (ZK rollups that compress thousands of transactions into a single proof verified on L1). The concept was introduced by Goldwasser, Micali, and Rackoff in 1985 and has become one of the most important technologies in the blockchain ecosystem.

A zero-knowledge proof must satisfy three properties. Completeness means that if the statement is true, an honest prover can always convince an honest verifier. Soundness means that if the statement is false, no cheating prover can convince the verifier (except with negligible probability). Zero-knowledge means the verifier learns nothing beyond the fact that the statement is true. A classic analogy is the Ali Baba cave: Alice can prove she knows the secret password to a cave door by consistently exiting from the side Bob requests, without ever revealing the password itself.

zk-SNARKs (Succinct Non-interactive Arguments of Knowledge) are the most widely used ZKP system in blockchain. 'Succinct' means proofs are small (a few hundred bytes) and fast to verify. 'Non-interactive' means the prover generates the proof without back-and-forth communication with the verifier. SNARKs require a trusted setup ceremony where initial parameters are generated; if the secret randomness from this ceremony is not properly destroyed, fake proofs could be created. Groth16 is the most efficient SNARK scheme with constant proof size (~128 bytes) and verification time, but requires a new trusted setup for each circuit. PLONK is a universal SNARK that needs only one setup for all circuits of a given size, making it more practical for production systems.

zk-STARKs (Scalable Transparent Arguments of Knowledge) are an alternative to SNARKs that eliminate the trusted setup requirement entirely. STARKs use hash-based cryptography instead of elliptic curves, making them quantum-resistant (SNARKs based on elliptic curves could theoretically be broken by quantum computers). The tradeoff is that STARK proofs are larger (tens of kilobytes vs hundreds of bytes for SNARKs) and more expensive to verify on-chain. StarkWare uses STARKs for StarkNet and StarkEx, while most other ZK rollups (zkSync, Polygon zkEVM, Scroll, Linea) use SNARK-based proof systems.

ZK rollups are the most impactful application of zero-knowledge proofs for blockchain scalability. A ZK rollup executes transactions off-chain, generates a cryptographic proof that all state transitions are valid, and posts this proof along with compressed transaction data to the L1 (Ethereum). The L1 smart contract verifies the proof, which is computationally cheap regardless of how many transactions were batched. This gives ZK rollups instant finality once the proof is verified (compared to the 7-day challenge period for optimistic rollups), though proof generation itself can take minutes. The major ZK rollups today are zkSync Era, Polygon zkEVM, Scroll, Linea, and StarkNet. Circom is a domain-specific language for writing ZK circuits that compile to R1CS constraint systems, which can then be proved using Groth16 or PLONK.

Code examples

Circom Circuit: Proving Knowledge of Factors

// === Circom Circuit: factorize.circom ===
// Prove that you know two secret factors of a public number
// without revealing the factors themselves
//
// Public input: n (the product)
// Private inputs: p, q (the factors)
// Proof: "I know p and q such that p * q = n"

pragma circom 2.1.0;

// A simple multiplier component
template Multiplier() {
    // Private inputs (known only to the prover)
    signal input a;
    signal input b;

    // Public output (visible to everyone)
    signal output c;

    // Constraint: a * b must equal c
    c <== a * b;

    // Additional constraint: factors must be > 1
    // (prevents trivial solutions like 1 * n)
    signal a_minus_1;
    signal b_minus_1;
    a_minus_1 <== a - 1;
    b_minus_1 <== b - 1;

    // Both (a-1) and (b-1) must be non-zero
    // Use inverse existence to prove non-zero
    signal a_inv;
    signal b_inv;
    a_inv <-- 1 / a_minus_1;  // Hint to the prover
    b_inv <-- 1 / b_minus_1;
    a_minus_1 * a_inv === 1;   // Constraint: a-1 has an inverse (non-zero)
    b_minus_1 * b_inv === 1;   // Constraint: b-1 has an inverse (non-zero)
}

component main {public [c]} = Multiplier();
// "c" is public, "a" and "b" are private

// === Compilation and Setup ===
// $ circom factorize.circom --r1cs --wasm --sym --c
// $ snarkjs groth16 setup factorize.r1cs pot12_final.ptau factorize_0000.zkey
// $ snarkjs zkey contribute factorize_0000.zkey factorize_final.zkey --name="contribution"
// $ snarkjs zkey export verificationkey factorize_final.zkey verification_key.json
//
// === Generating a Proof ===
// Create input.json: { "a": "7", "b": "13" }
// $ snarkjs groth16 fullprove input.json factorize.wasm factorize_final.zkey proof.json public.json
// proof.json contains the ZK proof
// public.json contains: ["91"] (7 * 13 = 91, but 7 and 13 are hidden)
//
// === Verifying the Proof ===
// $ snarkjs groth16 verify verification_key.json public.json proof.json
// Output: "OK!" - the verifier is convinced you know factors of 91
// without learning that the factors are 7 and 13

This Circom circuit demonstrates a fundamental ZK proof: proving knowledge of two factors of a public number without revealing the factors. The circuit defines constraints (a * b === c) that must be satisfied. The prover provides private witness values (a=7, b=13) and the system generates a proof that these values satisfy all constraints. The verifier only sees the public output (91) and the proof, never the private inputs. The <== operator both assigns and constrains, while <-- is a hint that requires a separate constraint.

On-Chain ZK Proof Verification (Solidity)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/
 * @title Groth16Verifier
 * @notice On-chain verification of Groth16 ZK proofs
 * @dev Auto-generated by snarkjs, simplified for education
 *
 * In production, run:
 *   snarkjs zkey export solidityverifier factorize_final.zkey Verifier.sol
 * This generates a complete verifier contract
 */
contract Groth16Verifier {
    // Elliptic curve points for the verification key
    // These are generated during the trusted setup
    struct VerifyingKey {
        uint256[2] alpha1;    // G1 point
        uint256[2][2] beta2;  // G2 point
        uint256[2][2] gamma2; // G2 point
        uint256[2][2] delta2; // G2 point
        uint256[2][] ic;      // Input commitment points
    }

    struct Proof {
        uint256[2] a;     // G1 point
        uint256[2][2] b;  // G2 point
        uint256[2] c;     // G1 point
    }

    /
     * @notice Verify a Groth16 proof
     * @param proof The proof points (a, b, c)
     * @param publicInputs The public inputs to the circuit
     * @return True if the proof is valid
     */
    function verifyProof(
        uint256[2] memory a,
        uint256[2][2] memory b,
        uint256[2] memory c,
        uint256[] memory publicInputs
    ) public view returns (bool) {
        // Verification uses elliptic curve pairings:
        // e(A, B) == e(alpha, beta) * e(IC, gamma) * e(C, delta)
        //
        // Where:
        // - e() is the bilinear pairing function
        // - A, B, C are the proof elements
        // - alpha, beta, gamma, delta are from the verification key
        // - IC is computed from the public inputs
        //
        // This is a constant-time check regardless of circuit complexity!
        // Gas cost: ~200,000-300,000 gas (uses precompiled contracts)

        // In the auto-generated verifier, this uses:
        // - ecMul (precompile 0x07): scalar multiplication on BN254
        // - ecAdd (precompile 0x06): point addition on BN254
        // - ecPairing (precompile 0x08): bilinear pairing check

        // Simplified for education - actual implementation uses assembly
        return _verifyPairing(a, b, c, publicInputs);
    }

    function _verifyPairing(
        uint256[2] memory a,
        uint256[2][2] memory b,
        uint256[2] memory c,
        uint256[] memory publicInputs
    ) internal view returns (bool) {
        // Assembly call to ecPairing precompile (0x08)
        // Returns 1 if pairing check passes, 0 otherwise
        // This is the core of ZK proof verification on-chain
        return true; // Placeholder - real impl uses precompile
    }
}

/
 * @title ZKVoting
 * @notice Private voting using ZK proofs
 * @dev Voters prove they are eligible without revealing identity
 */
contract ZKVoting {
    // In production, use the auto-generated Groth16Verifier
    Groth16Verifier public immutable verifier;

    // Merkle root of eligible voter commitments
    bytes32 public voterMerkleRoot;

    // Track nullifiers to prevent double-voting
    mapping(bytes32 => bool) public nullifierUsed;

    // Vote tallies
    mapping(uint256 => uint256) public voteCounts;

    event VoteCast(bytes32 indexed nullifier, uint256 indexed choice);

    constructor(address _verifier, bytes32 _merkleRoot) {
        verifier = Groth16Verifier(_verifier);
        voterMerkleRoot = _merkleRoot;
    }

    /
     * @notice Cast a private vote
     * @param proof ZK proof that voter is eligible
     * @param nullifier Unique identifier to prevent double-voting
     * @param choice The voting choice (e.g., 0 = No, 1 = Yes)
     * @param merkleRoot The Merkle root (must match stored root)
     *
     * The ZK circuit proves:
     * 1. The voter knows a secret that hashes to a leaf in the Merkle tree
     * 2. The Merkle proof is valid (leaf is in the tree with the given root)
     * 3. The nullifier is derived from the secret (deterministic, prevents double-vote)
     * Without revealing: which leaf/identity the voter is
     */
    function vote(
        uint256[2] calldata proofA,
        uint256[2][2] calldata proofB,
        uint256[2] calldata proofC,
        bytes32 nullifier,
        uint256 choice,
        bytes32 merkleRoot
    ) external {
        require(merkleRoot == voterMerkleRoot, "Invalid Merkle root");
        require(!nullifierUsed[nullifier], "Already voted");
        require(choice < 10, "Invalid choice");

        // Build public inputs array for the verifier
        uint256[] memory publicInputs = new uint256[](3);
        publicInputs[0] = uint256(merkleRoot);
        publicInputs[1] = uint256(nullifier);
        publicInputs[2] = choice;

        // Verify the ZK proof
        require(
            verifier.verifyProof(proofA, proofB, proofC, publicInputs),
            "Invalid ZK proof"
        );

        // Record the vote
        nullifierUsed[nullifier] = true;
        voteCounts[choice]++;

        emit VoteCast(nullifier, choice);
    }
}

// This pattern (Merkle tree + nullifier + ZK proof) is the same
// used by Tornado Cash for private transfers and by Semaphore
// for anonymous signaling. The key insight: the nullifier is
// deterministically derived from the voter's secret, so each
// voter can only generate one valid nullifier, preventing
// double-voting while preserving anonymity.

The Groth16Verifier shows how ZK proofs are verified on-chain using elliptic curve pairings via Ethereum precompiled contracts. Verification is constant-time (~200-300k gas) regardless of circuit complexity. The ZKVoting contract demonstrates a practical application: voters prove Merkle tree membership (eligibility) without revealing which leaf they are. The nullifier pattern prevents double-voting while preserving anonymity. This is the same core pattern used by Tornado Cash and Semaphore.

Complete ZK Proof Workflow with snarkjs

// === Complete ZK Proof Workflow ===
// From circuit definition to on-chain verification

// Step 1: Install tools
// $ npm install -g circom snarkjs

// Step 2: Write a circuit (ageCheck.circom)
// Prove: "I am over 18" without revealing actual age
/*
pragma circom 2.1.0;

include "circomlib/circuits/comparators.circom";

template AgeCheck() {
    signal input age;          // Private: actual age
    signal input minAge;       // Public: minimum required age (18)
    signal output isEligible;  // Public: 1 if eligible, 0 if not

    // Use GreaterEqThan comparator from circomlib
    // Proves age >= minAge without revealing age
    component geq = GreaterEqThan(8); // 8-bit numbers (0-255)
    geq.in[0] <== age;
    geq.in[1] <== minAge;

    isEligible <== geq.out;

    // Ensure the result is 1 (eligible)
    isEligible === 1;
}

component main {public [minAge]} = AgeCheck();
*/

// Step 3: Compile the circuit
// $ circom ageCheck.circom --r1cs --wasm --sym
// Outputs: ageCheck.r1cs, ageCheck_js/ageCheck.wasm

// Step 4: Powers of Tau ceremony (trusted setup phase 1)
// $ snarkjs powersoftau new bn128 12 pot12_0000.ptau
// $ snarkjs powersoftau contribute pot12_0000.ptau pot12_final.ptau
// $ snarkjs powersoftau prepare phase2 pot12_final.ptau pot12_final.ptau

// Step 5: Circuit-specific setup (trusted setup phase 2)
// $ snarkjs groth16 setup ageCheck.r1cs pot12_final.ptau ageCheck_0000.zkey
// $ snarkjs zkey contribute ageCheck_0000.zkey ageCheck_final.zkey
// $ snarkjs zkey export verificationkey ageCheck_final.zkey vkey.json

// Step 6: Generate proof (JavaScript)
import * as snarkjs from 'snarkjs';
import * as fs from 'fs';

async function generateAgeProof(actualAge: number) {
  // Create witness input
  const input = {
    age: actualAge,    // Private: won't be revealed
    minAge: 18,        // Public: part of the statement
  };

  // Generate the proof
  const { proof, publicSignals } = await snarkjs.groth16.fullProve(
    input,
    'ageCheck_js/ageCheck.wasm',
    'ageCheck_final.zkey'
  );

  console.log('Proof:', JSON.stringify(proof, null, 2));
  console.log('Public signals:', publicSignals);
  // publicSignals = ["1", "18"]
  // "1" = isEligible (output), "18" = minAge (public input)
  // The actual age (e.g., 25) is NOT in publicSignals!

  // Verify locally before submitting on-chain
  const vkey = JSON.parse(fs.readFileSync('vkey.json', 'utf8'));
  const isValid = await snarkjs.groth16.verify(vkey, publicSignals, proof);
  console.log('Valid proof:', isValid); // true

  return { proof, publicSignals };
}

// Step 7: Format proof for on-chain verification
async function formatForSolidity(proof: any, publicSignals: string[]) {
  // snarkjs can generate Solidity calldata directly
  const calldata = await snarkjs.groth16.exportSolidityCallData(
    proof,
    publicSignals
  );

  // calldata is a string like:
  // ["0x...", "0x..."], [["0x...", "0x..."], ["0x...", "0x..."]], ["0x...", "0x..."], ["0x...", "0x..."]
  // These map to: proofA, proofB, proofC, publicInputs
  console.log('Solidity calldata:', calldata);

  return calldata;
}

// Step 8: Generate the Solidity verifier contract
// $ snarkjs zkey export solidityverifier ageCheck_final.zkey AgeVerifier.sol
// This generates a production-ready Verifier.sol that you deploy on-chain

// Step 9: Deploy and verify on-chain
// const verifier = await AgeVerifier.deploy();
// const result = await verifier.verifyProof(proofA, proofB, proofC, publicInputs);
// result === true: person is over 18, without knowing their actual age

generateAgeProof(25).catch(console.error);

This end-to-end workflow shows every step of creating and verifying a ZK proof. The circuit (ageCheck.circom) defines the computation. The trusted setup generates cryptographic parameters. The prover generates a proof from private inputs (age=25) that only reveals public signals (minAge=18, isEligible=1). snarkjs handles proof generation, local verification, and Solidity calldata formatting. The exported Verifier.sol contract enables on-chain verification. The actual age is never revealed at any point in the process.

Key points

Concepts covered

Zero-Knowledge Proofs, zk-SNARKs, zk-STARKs, ZK Rollups, Circom