Zero-Knowledge Proofs

Proving you know a secret without ever revealing it — the cryptographic primitive powering zkRollups, zkBridges, and private identity on blockchains.

Prereq: basic cryptography, modular arithmetic Time to read: ~35 min Interactive figures: 1

1. What is a zero-knowledge proof?

A zero-knowledge proof (ZKP) is an interactive protocol between two parties — a Prover P and a Verifier V — in which:

That sounds paradoxical. How can you convince someone of something while revealing nothing? The key is that "proof" in cryptography is probabilistic, and "nothing" is made precise by a formal simulation argument.

The Ali Baba cave — the canonical example

Peggy knows the secret magic word that opens a door connecting the two halves of a circular cave. Victor wants to be convinced she knows the word — but Peggy doesn't want to reveal it. The protocol:

  1. Victor waits outside. Peggy enters the cave and randomly goes left (path A) or right (path B) — Victor doesn't see which.
  2. Victor walks to the fork and shouts: "Come out from path A!" (or B — chosen randomly).
  3. Peggy uses the magic word if needed and exits from the called side.

If Peggy is bluffing, she has a $\tfrac{1}{2}$ chance of being on the right side already. After $n$ rounds of this the probability of a successful bluff is $(1/2)^n$. After 20 rounds:

$$\Pr[\text{successful bluff after 20 rounds}] = \left(\frac{1}{2}\right)^{20} \approx 10^{-6}$$

Victor is now essentially certain Peggy knows the word — yet he has learned nothing about the word itself. The transcript (which side she entered, which side was called, which side she exited) could have been manufactured by anyone, even without knowing the word.

THE THREE ZK PROPERTIES
  1. Completeness: if the statement is true and both parties follow the protocol, the verifier accepts.
  2. Soundness: if the statement is false, no cheating prover can convince the verifier except with negligible probability.
  3. Zero-knowledge: the verifier learns nothing beyond the truth of the statement. Formally: the proof transcript can be simulated without the witness, so the transcript contains no information about the secret.

The simulation argument is crucial. Zero-knowledge does not mean the verifier is confused or can't reason — it means that whatever the verifier sees during the proof, they could have generated themselves without interacting with the prover. If the transcript is simulatable, it carries zero information about the witness.

ZKP vocabulary decoded

Statement
A mathematical assertion: "this circuit output is 0", "this ciphertext decrypts to a value > 18", "I know a preimage of this hash."
Witness
The secret information that makes the statement true — the private key, the age, the hash preimage. The prover knows it; the verifier must not learn it.
Transcript
The sequence of messages exchanged between prover and verifier. Zero-knowledge requires that this transcript is computationally indistinguishable from a simulated one generated without the witness.
Negligible probability
A function $\varepsilon(n)$ that shrinks faster than any inverse polynomial. In practice: $\varepsilon < 2^{-128}$ for 128-bit security.
Knowledge soundness
Stronger than soundness: not only can't a cheating prover convince the verifier of a false statement, but if the prover can convince the verifier, a hypothetical "extractor" could pull the witness out of the prover. This is the "of Knowledge" in zk-SNARK.

Analogy Think of a ZK proof as a magic mirror: you can show Victor your reflection (the proof) without revealing the room you're standing in (the witness). The reflection looks real — he can verify it's a genuine reflection — but it tells him nothing about what's behind the mirror.

2. Interactive proofs and the ZK properties

Sigma protocols

A sigma protocol ($\Sigma$-protocol) is a 3-move interactive proof:

  1. Commitment: Prover sends a random commitment $R$.
  2. Challenge: Verifier sends a random challenge $c$.
  3. Response: Prover sends a response $s$ that ties $R$, $c$, and the witness together.

The canonical example is the Schnorr identification protocol for proving knowledge of a discrete logarithm.

SCHNORR PROTOCOL — DISCRETE LOG

Statement: "I know $x$ such that $Y = g^x \pmod{p}$" (without revealing $x$)

$$\begin{aligned} &\textbf{Commitment:} \quad \text{Prover picks random } r \in \mathbb{Z}_q, \text{ sends } R = g^r \pmod{p} \\ &\textbf{Challenge:} \quad \text{Verifier sends random } c \in \mathbb{Z}_q \\ &\textbf{Response:} \quad \text{Prover sends } s = r + c \cdot x \pmod{q} \\ &\textbf{Verification:} \quad \text{Check } g^s \equiv R \cdot Y^c \pmod{p} \end{aligned}$$

Why does verification work? Because $g^s = g^{r + cx} = g^r \cdot (g^x)^c = R \cdot Y^c$. An honest prover always passes. Why is this zero-knowledge? The transcript $(R, c, s)$ can be simulated by picking $s$ and $c$ uniformly at random and computing $R = g^s \cdot Y^{-c}$. This fake transcript has exactly the same distribution as a real one — indistinguishable — so it carries zero information about $x$.

Soundness via the extractor

Suppose a cheating prover can produce valid responses to two different challenges $c \neq c'$ for the same commitment $R$. Then they have $s = r + cx$ and $s' = r + c'x$, so $s - s' = (c - c')x$, giving $x = (s-s')(c-c')^{-1} \pmod{q}$. The extractor can rewind the prover and extract $x$. This special soundness property means that a prover who can convince the verifier must know $x$.

The Fiat-Shamir heuristic — making proofs non-interactive

Interactive proofs require back-and-forth. For blockchains, we need proofs that can be verified by anyone, offline. The Fiat-Shamir transform removes the verifier from the picture by replacing their random challenge with a hash:

$$c = H(\text{statement} \,\|\, R)$$

The hash function acts as a random oracle — its output is unpredictable, so the prover can't choose $R$ to make $c$ favorable. The resulting proof $(R, s)$ is non-interactive and publicly verifiable. This is the foundation of signature schemes (Schnorr signatures are exactly this), and more generally of all practical SNARKs.

WHY BLOCKCHAINS NEED NON-INTERACTIVE ZKPs

An Ethereum smart contract can't play 20 rounds of the Ali Baba cave protocol — it can only read calldata. Non-interactive proofs (NIZKs) let a prover generate a proof off-chain that the contract verifies in a single transaction. This is what makes zkRollups possible.

3. SNARKs vs STARKs

Modern ZK proof systems fall into two families. Both prove the same thing: "I know a witness $w$ such that $C(w) = 0$ for circuit $C$." They differ in setup, size, and cryptographic assumptions.

Property zk-SNARK zk-STARK
Full name Succinct Non-interactive ARgument of Knowledge Scalable Transparent ARgument of Knowledge
Proof size $O(1)$ — ~200 bytes (Groth16: 3 group elements) $O(\log^2 n)$ — tens of kilobytes
Verification time $O(1)$ — ~3 ms, 3 pairings $O(\log^2 n)$ — ~10–50 ms
Trusted setup Yes — circuit-specific (Groth16) or universal (Plonk, Marlin) No — fully transparent (public randomness)
Cryptographic assumption Elliptic curve pairings (BN254, BLS12-381) — not quantum-safe Collision-resistant hash functions only — quantum-safe
Prover time $O(n \log n)$ with FFTs $O(n \log^2 n)$ — somewhat slower
Toxic waste Yes — setup parameters must be destroyed No — anyone can verify the setup
Used by Groth16: zkSync, Scroll, Zcash; Plonk: Aztec, Linea StarkNet, Polygon Miden, Risc0

The trusted setup and "toxic waste"

Groth16 requires a Structured Reference String (SRS): a set of elliptic curve points computed from secret random values $(\alpha, \beta, \gamma, \delta, \tau)$. These values must never be known to anyone — if they are, an attacker can create proofs for false statements. Hence the name toxic waste.

To mitigate this, the ZK community runs powers-of-tau ceremonies: many parties contribute randomness, and the SRS is secure as long as at least one party destroys their randomness. Zcash's ceremony involved hundreds of contributors. Ethereum's KZG ceremony had over 140,000 participants.

UNIVERSAL VS CIRCUIT-SPECIFIC SETUP

Groth16 requires a fresh setup per circuit — expensive for many applications. Universal SNARKs (Plonk, Marlin, Sonic) use a single "universal" SRS for any circuit up to a certain size. This doesn't eliminate the trusted setup, but amortizes it. STARKs need no setup at all — their "randomness" comes from the verifier's challenges (or a hash in the non-interactive setting), which is publicly auditable.

4. Arithmetic circuits and R1CS

Before you can generate a ZK proof, you must express your computation as an arithmetic circuit over a prime field $\mathbb{F}_p$. The prover's claim is "I know an assignment of wires that satisfies all the gate constraints."

Arithmetic circuits

An arithmetic circuit over $\mathbb{F}_p$ has:

The key insight: multiplication is expensive, addition is free. This is why circuit complexity is measured in multiplication gates (also called "constraints" or "R1CS rows").

Rank-1 Constraint System (R1CS)

An R1CS is a system of equations over $\mathbb{F}_p$. Each equation encodes one multiplication gate:

$$(\mathbf{a}_i \cdot \mathbf{s}) \times (\mathbf{b}_i \cdot \mathbf{s}) = (\mathbf{c}_i \cdot \mathbf{s})$$

where $\mathbf{s}$ is the witness vector (all wire values: public inputs, private inputs, and intermediate values), and $\mathbf{a}_i, \mathbf{b}_i, \mathbf{c}_i$ are selector vectors picking out the relevant wires for gate $i$.

R1CS symbols decoded

$\mathbf{s}$
The witness vector. Always starts with $1$ (a constant wire), followed by public inputs, then private inputs, then intermediate values. E.g. for $x^3 + x + 5 = 35$: $\mathbf{s} = (1, \text{out}, x, v_1, v_2) = (1, 35, 3, 9, 27)$.
$\mathbf{a}_i, \mathbf{b}_i, \mathbf{c}_i$
Sparse selector vectors — mostly zero. $\mathbf{a}_i \cdot \mathbf{s}$ picks out the left input to gate $i$; $\mathbf{b}_i \cdot \mathbf{s}$ picks the right input; $\mathbf{c}_i \cdot \mathbf{s}$ picks the output.
$n$ constraints
One per multiplication gate. Addition is encoded by adding entries in the selector vectors (linear combinations are "free").
$m$ witness variables
Total wires. Proof size and verification time are $O(1)$ in $m$; only prover time scales with $m$.

Worked example: $x^3 + x + 5 = 35$

Claim: I know $x = 3$ such that $x^3 + x + 5 = 35$, without revealing $x$.

Introduce intermediate variables to flatten to degree-2:

The R1CS constraints (each row is one multiplication gate):

$$\begin{array}{rcll} x &\cdot& x &= v_1 \\ v_1 &\cdot& x &= v_2 \\ (v_2 + x + 5) &\cdot& 1 &= \text{out} \end{array}$$

Witness vector: $\mathbf{s} = (1,\ \text{out},\ x,\ v_1,\ v_2) = (1,\ 35,\ 3,\ 9,\ 27)$

Just 3 constraints for a cubic computation. If we had $x^{1000}$, we'd need only $O(\log 1000)$ squarings — circuits are very efficient for repeated squaring.

x =3 3 3 × x·x x²=9 × v₁·x x=3 x³=27 + v₂+x x=3 =30 + +5 out =35 × mul gate + add gate val wire value Arithmetic circuit for x³+x+5=35, x=3

From R1CS to QAP

A Quadratic Arithmetic Program (QAP) encodes the R1CS as polynomial equations. For each wire $i$, define polynomials $u_i(x), v_i(x), w_i(x)$ that interpolate the $i$-th column of $A, B, C$ at roots of unity. The R1CS constraint $As \odot Bs = Cs$ becomes:

$$\left(\sum_i a_i u_i(\tau)\right) \cdot \left(\sum_i a_i v_i(\tau)\right) - \sum_i a_i w_i(\tau) = h(\tau) \cdot t(\tau)$$

where $t(\tau) = \prod_k (\tau - \omega^k)$ is the "target polynomial" vanishing at all the roots, and $h$ is the quotient. The Groth16 prover evaluates this at a secret point $\tau$ using the proving key (a list of encrypted powers of $\tau$), and the verifier checks it via pairings without ever knowing $\tau$.

5. The Groth16 proof system

Groth16 (Jens Groth, 2016) is currently the most-deployed SNARK in production, notable for its minimal proof size: exactly 3 elliptic curve group elements (~192 bytes for BN254). Verification requires exactly 3 pairing operations, taking roughly 3 ms regardless of circuit complexity.

Setup phase

The setup generates a proving key $\mathsf{pk}$ and verification key $\mathsf{vk}$ from the circuit and secret values $(\alpha, \beta, \gamma, \delta, \tau) \in \mathbb{F}_p^5$ (the "toxic waste"). These values must be destroyed after the ceremony. The keys encode encrypted evaluations of the QAP polynomials at $\tau$, using elliptic curve points $[x]_1 = x \cdot G_1$ and $[x]_2 = x \cdot G_2$.

Proving

Given a witness vector $\mathbf{a}$ satisfying the R1CS, the prover picks random blinding factors $r, s \in \mathbb{F}_p$ and computes three group elements:

$$\begin{aligned} [A]_1 &= [\alpha]_1 + \sum_i a_i [\sigma_i^A]_1 + r[\delta]_1 \\ [B]_2 &= [\beta]_2 + \sum_i a_i [\sigma_i^B]_2 + s[\delta]_2 \\ [C]_1 &= \frac{\sum_{i \in \text{priv}} a_i[\sigma_i^C]_1 + [h(\tau) \cdot t(\tau)]_1 - r[A]_1 - s[B]_1 + rs[\delta]_1}{1} \end{aligned}$$

The proof is $\pi = ([A]_1, [B]_2, [C]_1)$ — just those three points. The blinding terms $r, s$ ensure zero-knowledge: without them, a verifier who saw two proofs for the same witness could extract it.

Verification

Given public inputs $\mathbf{x}$, the verifier checks a single pairing equation:

$$e(A,\, B) \;=\; e([\alpha]_1, [\beta]_2) \;\cdot\; e\!\left(\sum_{i \in \text{pub}} x_i \left[\frac{\beta u_i(\tau) + \alpha v_i(\tau) + w_i(\tau)}{\gamma}\right]_1,\; [\gamma]_2\right) \;\cdot\; e(C,\; [\delta]_2)$$

This is one equation with 3 pairings and $|\text{pub}|$ group additions. The verification time is dominated by the pairings, which cost ~1 ms each on modern hardware — so ~3 ms total, independent of circuit size.

WHY PAIRINGS WORK FOR THIS

A bilinear pairing $e: G_1 \times G_2 \to G_T$ satisfies $e(aP, bQ) = e(P, Q)^{ab}$. This lets the verifier "multiply in the exponent" — comparing encrypted values without decrypting them. The security rests on the hardness of the discrete log in $G_T$ (the "pairing DH assumption"). Elliptic curves BN254 and BLS12-381 are carefully engineered to support efficient pairings.

Trusted Setup generates CRS (pk, vk) pk vk Prover has witness w runs: π = Prove(pk, w) π = (A, B, C) ∈ G₁×G₂×G₁ proof π = (A,B,C) Verifier checks pairing eq: e(A,B) = e(α,β)· e(Σaᵢuᵢ,γ)·e(C,δ) ✓ Accept ✗ Reject Groth16 proof flow: Setup → Prove → Verify

The Plonk improvement

Plonk (Gabizon, Williamson, Ciobotaru, 2019) achieves a universal trusted setup: one ceremony works for all circuits up to a maximum size. The tradeoff: proof size is ~400 bytes (larger than Groth16) but eliminating per-circuit ceremonies dramatically reduces operational complexity. Plonk and its variants (TurboPlonk, UltraPlonk, HyperPlonk) are now the dominant SNARK in new zkEVM deployments.

6. ZK-STARKs and FRI

STARKs take a completely different approach: instead of elliptic curves and pairings, they use only collision-resistant hash functions. This makes them quantum-safe, transparent (no trusted setup), and highly parallelizable — at the cost of larger proofs.

Arithmetization: from computation to polynomials

STARKs use an Algebraic Intermediate Representation (AIR). The computation trace is laid out as a table: rows are time steps, columns are register values. Transition constraints are low-degree polynomial equations $P(T_i, T_{i+1}) = 0$ that must hold for every step $i$. Boundary constraints specify initial and final states.

The prover encodes the trace as polynomials interpolated over a low-degree extension (LDE) domain — a set of roots of unity in a large field. The verifier needs to check that these polynomials have low degree and satisfy the AIR constraints.

FRI: Fast Reed-Solomon IOP of Proximity

FRI is a protocol for proving that a function $f: D \to \mathbb{F}$ is close to a polynomial of degree $< d$, using only Merkle-hash commitments (no elliptic curves). The core idea is a folding protocol:

  1. Prover commits to $f$ by Merkle-hashing its evaluations over a domain $D$ of size $N$.
  2. Verifier sends a random $\beta \in \mathbb{F}$.
  3. Prover "folds" $f$ into $f'$ of degree $d/2$ over domain $D' = D^2$ of size $N/2$:
    $$f'(x^2) = \frac{f(x) + f(-x)}{2} + \beta \cdot \frac{f(x) - f(-x)}{2x}$$
  4. Repeat $O(\log d)$ times until the polynomial is constant (degree 0 = trivially verifiable).

Each fold halves the degree and domain size. The final proof is the sequence of Merkle roots and decommitment paths for queried positions. Total proof size: $O(\log^2 n)$ hash values. Verification: $O(\log^2 n)$ hash checks.

STARK/FRI vocabulary

AIR
Algebraic Intermediate Representation. The computation expressed as polynomial transition constraints over a trace table. Each row is one step of the program; constraints must hold for every consecutive pair of rows.
LDE (Low-Degree Extension)
Evaluating the trace polynomials over a domain much larger than the original trace. Errors in the trace polynomial become "spread out" in the LDE, making them easier for the FRI verifier to detect via random queries.
Blowup factor
The ratio of LDE domain size to trace length. Larger blowup = smaller soundness error per query = fewer queries needed = smaller proof, at higher prover cost.
DEEP-FRI
An optimization that binds the AIR constraint check and the FRI low-degree test together via out-of-domain sampling, improving soundness without increasing proof size.
Cairo VM
StarkWare's provable CPU architecture. Cairo programs compile to AIR constraints over a STARK-friendly field ($\mathbb{F}_{2^{251} + \ldots}$). StarkNet uses Cairo for its zkEVM-equivalent.

Analogy FRI is like a binary search for polynomial degree. You start with a huge polynomial (the full trace), and each folding round says: "I'll bet this is low-degree — here's a compressed version half the size." The verifier spot-checks each compressed version. If the polynomial were high-degree, the folding would break down, and the verifier would catch it in a random query with high probability.

Round 0 — degree d fold β₁ 0 1 f(x) deg d d/2 d/4 const

STARK proof size in practice

For a computation with $n = 10^6$ steps, a STARK proof is typically 40–200 KB (depending on hash function, blowup factor, and security level), versus ~200 bytes for Groth16. This is a 100–1000× size difference. However, STARKs are:

7. Applications in blockchain

zkRollups

A zkRollup is a Layer 2 scaling solution: batch thousands of transactions off-chain, generate a ZK proof that all state transitions are valid, and post only the proof and compressed transaction data on-chain.

HOW A ZKROLLUP WORKS
  1. Users submit transactions to a sequencer (the L2 node).
  2. The sequencer executes the transactions, updating the L2 state tree.
  3. A ZK prover generates a proof that: (a) each transaction has a valid signature, (b) each sender had sufficient balance, (c) the state root transitioned correctly.
  4. The proof (~200 bytes for Groth16) and a compressed transaction batch (calldata) are submitted to an Ethereum smart contract.
  5. The verifier contract checks the proof in ~500,000 gas — the same cost regardless of whether the batch contained 10 or 10,000 transactions.

Result: 100–1000× throughput increase with full Ethereum security guarantees. Unlike optimistic rollups, zkRollups have instant finality — no 7-day challenge period, because the proof is a mathematical guarantee, not an economic incentive.

Layer 2 (off-chain) Layer 1 (Ethereum) L2 Users sign & submit transactions txs Sequencer orders txs, executes state transitions batch + new state root ZK Prover generates validity proof π for the batch calldata + proof π L1 Verifier Contract verifies proof on-chain (~500k gas) proof valid ✓ State Root Updated on L1 finality achieved — no challenge period zkRollup architecture: L2 execution → ZK proof → L1 settlement
Protocol Proof system EVM-compatible Status
zkSync EraPlonk (Boojum)Full EVM (zkEVM)Mainnet
ScrollPlonkFull EVM (zkEVM)Mainnet
LineaPlonk (gnark)Full EVMMainnet
Polygon zkEVMPlonk + STARK (Plonky2)Full EVMMainnet
StarkNetSTARK (Cairo)Cairo VM (not EVM)Mainnet
ZcashGroth16N/A (payments only)Mainnet

zkBridges

Traditional cross-chain bridges use a multisig of "validators" who sign off on messages — a trusted committee. If the committee is compromised (as happened with Ronin, Wormhole, Nomad), funds are stolen.

A zkBridge replaces the trusted committee with a ZK proof. The prover generates a proof that: "the source chain's light client state at block $N$ has hash $H$, and this hash was signed by a supermajority of validators according to the consensus rules." The destination chain verifies the proof onchain — no trust assumptions beyond the cryptography.

Projects in this space: Succinct Labs (SP1-based bridge proofs), Polyhedra (zkBridge with BLS signature aggregation proofs), Axiom (historical Ethereum state proofs), Lagrange (cross-chain ZK coprocessor).

zkIdentity and zkVMs

zkIdentity lets you prove properties about yourself without revealing the underlying data:

Semaphore is an Ethereum protocol for anonymous group membership: prove you're in a group without revealing which member you are. Used for anonymous voting, whistleblowing, and private airdrops. World ID (Worldcoin) uses iris biometrics to generate a unique nullifier — proving personhood without linking transactions to identity.

zkVMs prove the correct execution of arbitrary programs inside a ZK proof:

The vision: any program can be proven correct off-chain and verified on-chain for a flat fee. This would enable fully trustless bridges, provable rollup sequencers, and ZK-proven AI inference.

8. Interactive: ZK Ali Baba cave

Run rounds of the Ali Baba protocol below. Each round: Peggy enters a random side, Victor calls out a side, Peggy exits correctly. Watch the cheating probability drop toward zero.

DOOR ENTRANCE A B Peggy Victor Press "Run Round" to start "Come out from A!" ✓ Correct!
Round: 0 / 20
Cheating probability: 1.000000
 

9. Source code

Implementation examples across languages — from a Schnorr protocol simulation to full Groth16 proof generation using gnark, snarkjs, and arkworks.

Source — Zero-Knowledge Proof Implementations
# ─── Schnorr Identification Protocol ───────────────────────────
# Public: p (prime), q (prime, q | p-1), g (generator of order q)
# Public: Y = g^x mod p  (the statement)
# Secret: x  (the witness / private key)

function schnorr_prove(x, Y, g, p, q):
    r  = random(1, q-1)            # random blinding factor
    R  = pow(g, r, p)              # commitment
    c  = H(Y || R)                 # Fiat-Shamir challenge (non-interactive)
    s  = (r + c * x) mod q         # response
    return (R, s)                  # the proof

function schnorr_verify(Y, R, s, c, g, p, q):
    # recompute challenge
    c  = H(Y || R)
    lhs = pow(g, s, p)             # g^s mod p
    rhs = (R * pow(Y, c, p)) mod p # R * Y^c mod p
    return lhs == rhs


# ─── R1CS Constraint System (for x^3 + x + 5 = 35) ────────────
# Witness: s = [1, out, x, v1, v2]
#          s = [1, 35,  3,  9, 27]

constraints:
  # gate 1: x * x = v1
  A[0] = [0, 0, 1, 0, 0]   # picks x
  B[0] = [0, 0, 1, 0, 0]   # picks x
  C[0] = [0, 0, 0, 1, 0]   # picks v1

  # gate 2: v1 * x = v2
  A[1] = [0, 0, 0, 1, 0]   # picks v1
  B[1] = [0, 0, 1, 0, 0]   # picks x
  C[1] = [0, 0, 0, 0, 1]   # picks v2

  # gate 3: (v2 + x + 5) * 1 = out
  A[2] = [5, 0, 1, 0, 1]   # 5*1 + x + v2
  B[2] = [1, 0, 0, 0, 0]   # 1 (constant)
  C[2] = [0, 1, 0, 0, 0]   # picks out

function check_r1cs(A, B, C, s):
    for i in range(len(A)):
        left  = dot(A[i], s)
        right = dot(B[i], s)
        out   = dot(C[i], s)
        assert left * right == out, f"Constraint {i} failed"
    return True


# ─── Groth16 Verification (simplified) ─────────────────────────
# vk: verification key (alpha_g1, beta_g2, gamma_g2, delta_g2, IC)
# proof: (A_g1, B_g2, C_g1)
# public_inputs: [x1, x2, ...]

function groth16_verify(vk, proof, public_inputs):
    # Compute linear combination of IC points
    vk_x = vk.IC[0]
    for i, xi in enumerate(public_inputs):
        vk_x += xi * vk.IC[i+1]   # scalar multiplication on G1

    # Check pairing equation:
    # e(A, B) == e(alpha, beta) * e(vk_x, gamma) * e(C, delta)
    lhs = pairing(proof.A, proof.B)
    rhs = ( pairing(vk.alpha_g1, vk.beta_g2)
          * pairing(vk_x, vk.gamma_g2)
          * pairing(proof.C, vk.delta_g2) )
    return lhs == rhs
# ─── Requirements: pip install py_ecc ──────────────────────────
# py_ecc provides BN128 pairing operations

from py_ecc.bn128 import (
    G1, G2, pairing, multiply, add, neg, field_modulus, curve_order
)
import hashlib, random

# ─── Schnorr protocol simulation ───────────────────────────────
def schnorr_sim(p=0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141,
                g=2):
    """Simulate Schnorr over a toy multiplicative group."""
    q = p  # prime order group for simplicity
    x = random.randrange(1, q)          # private key
    Y = pow(g, x, q)                    # public key

    # Prove knowledge of x
    r = random.randrange(1, q)
    R = pow(g, r, q)
    c = int(hashlib.sha256(f"{Y}{R}".encode()).hexdigest(), 16) % q
    s = (r + c * x) % q

    # Verify
    lhs = pow(g, s, q)
    rhs = (R * pow(Y, c, q)) % q
    print(f"Valid proof: {lhs == rhs}")
    return lhs == rhs

schnorr_sim()


# ─── R1CS satisfiability check ─────────────────────────────────
def check_r1cs(A, B, C, s):
    for i, (a, b, c) in enumerate(zip(A, B, C)):
        left  = sum(ai * si for ai, si in zip(a, s))
        right = sum(bi * si for bi, si in zip(b, s))
        out   = sum(ci * si for ci, si in zip(c, s))
        if left * right != out:
            raise ValueError(f"Constraint {i} failed: {left}*{right} != {out}")
    print("All R1CS constraints satisfied!")

# x^3 + x + 5 = 35, with x=3
s = [1, 35, 3, 9, 27]  # [1, out, x, v1, v2]
A = [[0,0,1,0,0], [0,0,0,1,0], [5,0,1,0,1]]
B = [[0,0,1,0,0], [0,0,1,0,0], [1,0,0,0,0]]
C = [[0,0,0,1,0], [0,0,0,0,1], [0,1,0,0,0]]
check_r1cs(A, B, C, s)


# ─── BN128 pairing check (Groth16-style) ───────────────────────
def demo_pairing():
    # e(aG1, bG2) == e(G1, G2)^(ab)
    a, b = 5, 7
    aG1 = multiply(G1, a)
    bG2 = multiply(G2, b)
    abG1 = multiply(G1, a * b % curve_order)

    lhs = pairing(bG2, aG1)
    rhs = pairing(G2, abG1)
    print(f"Pairing bilinearity: {lhs == rhs}")

demo_pairing()


# ─── circom witness generation example ─────────────────────────
# Requires: npm install -g circom snarkjs
# Circuit: multiplier2.circom
#   pragma circom 2.0.0;
#   template Multiplier2() {
#       signal input a, b;
#       signal output c;
#       c <== a * b;
#   }
#   component main = Multiplier2();

import subprocess, json, os

def generate_proof(circom_circuit, input_data, output_dir="."):
    """Full circom -> witness -> proof pipeline."""
    # 1. Write input
    with open(f"{output_dir}/input.json", "w") as f:
        json.dump(input_data, f)

    # 2. Generate witness
    subprocess.run(["node", f"{circom_circuit}_js/generate_witness.js",
                    f"{circom_circuit}_js/{os.path.basename(circom_circuit)}.wasm",
                    f"{output_dir}/input.json",
                    f"{output_dir}/witness.wtns"], check=True)

    # 3. Generate proof (requires trusted setup zkey)
    subprocess.run(["snarkjs", "groth16", "prove",
                    f"{circom_circuit}.zkey",
                    f"{output_dir}/witness.wtns",
                    f"{output_dir}/proof.json",
                    f"{output_dir}/public.json"], check=True)

    # 4. Verify
    result = subprocess.run(["snarkjs", "groth16", "verify",
                              f"{circom_circuit}_vk.json",
                              f"{output_dir}/public.json",
                              f"{output_dir}/proof.json"],
                             capture_output=True, text=True)
    return "OK" in result.stdout

# generate_proof("multiplier2", {"a": 3, "b": 7})  # proves 3*7=21
// ─── Requirements: npm install snarkjs circomlibjs ─────────────

const snarkjs = require("snarkjs");
const { buildPoseidon } = require("circomlibjs");

// ─── Generate and verify a Groth16 proof with snarkjs ──────────
async function proveAndVerify() {
    // Input to the circuit (must match the circuit's public/private inputs)
    const input = { a: 3, b: 11 };  // proving a*b = 33

    // Generate proof
    const { proof, publicSignals } = await snarkjs.groth16.fullProve(
        input,
        "multiplier.wasm",   // compiled circuit (circom -> wasm)
        "multiplier.zkey"    // proving key (from trusted setup)
    );

    console.log("Proof:", JSON.stringify(proof, null, 2));
    console.log("Public signals:", publicSignals);

    // Verify proof using verification key
    const vkey = JSON.parse(require("fs").readFileSync("verification_key.json"));
    const isValid = await snarkjs.groth16.verify(vkey, publicSignals, proof);
    console.log("Proof valid:", isValid);

    return { proof, publicSignals, isValid };
}

// ─── Export Solidity verifier contract ─────────────────────────
async function exportSolidityVerifier() {
    const templates = { groth16: snarkjs.groth16 };
    const solidityCode = await snarkjs.zKey.exportSolidityVerifier(
        "multiplier.zkey",
        templates
    );
    require("fs").writeFileSync("Verifier.sol", solidityCode);
    console.log("Verifier.sol written — deploy this to Ethereum!");
}


// ─── Circom compilation workflow ───────────────────────────────
// Install: npm install -g circom
// 1. Write circuit in multiplier.circom:
//    pragma circom 2.0.0;
//    template Multiplier() {
//        signal input a, b;
//        signal output c;
//        c <== a * b;
//    }
//    component main { public [c] } = Multiplier();
//
// 2. Compile: circom multiplier.circom --r1cs --wasm --sym
// 3. Trusted setup:
//    snarkjs powersoftau new bn128 12 pot12_0000.ptau
//    snarkjs powersoftau contribute pot12_0000.ptau pot12_0001.ptau
//    snarkjs powersoftau prepare phase2 pot12_0001.ptau pot12_final.ptau
//    snarkjs groth16 setup multiplier.r1cs pot12_final.ptau multiplier.zkey
//    snarkjs zkey export verificationkey multiplier.zkey verification_key.json


// ─── Poseidon hash (ZK-friendly hash function) ─────────────────
async function poseidonDemo() {
    const poseidon = await buildPoseidon();
    // Poseidon is a ZK-friendly hash — many times cheaper in circuits
    // than SHA256 (SHA256 needs ~20k R1CS constraints; Poseidon needs ~200)
    const input = [BigInt(1), BigInt(2), BigInt(3)];
    const hash = poseidon(input);
    const hashHex = poseidon.F.toString(hash, 16);
    console.log("Poseidon hash:", hashHex);

    // Proving knowledge of a Poseidon preimage (for Semaphore/Merkle trees)
    // Circuit: poseidon(secret, nullifier) == commitment (public)
    // Proves: I know the secret behind a public commitment
}

proveAndVerify().catch(console.error);
/* ── Requirements: libff (https://github.com/scipr-lab/libff) ──
   Compile: g++ -O2 -o zkp_demo zkp_demo.c -lff -lgmp -lssl -lcrypto
   libff provides elliptic curve arithmetic and pairing operations.    */

#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <stdlib.h>

/* ── Conceptual BN128 group element (opaque in real libff) ──── */
typedef struct { uint64_t x[4], y[4], z[4]; } G1;
typedef struct { uint64_t x[8], y[8], z[8]; } G2;
typedef struct { uint64_t c[12]; }             GT;

/* ── Stub declarations — real libff uses C++ ────────────────── */
extern G1  g1_generator(void);
extern G2  g2_generator(void);
extern G1  g1_scalar_mul(G1 P, uint64_t s[4]);
extern G1  g1_add(G1 P, G1 Q);
extern GT  bn128_pairing(G2 Q, G1 P);   /* e: G2 x G1 -> GT */
extern int gt_equal(GT a, GT b);
extern void g1_print(const char *name, G1 P);

/* ── Check bilinearity: e(aG1, bG2) == e(G1, G2)^(ab) ─────── */
int check_pairing_bilinearity(uint64_t a, uint64_t b) {
    G1 G1gen = g1_generator();
    G2 G2gen = g2_generator();

    /* a*G1, b*G2, (a*b)*G1 */
    uint64_t a4[4] = {a, 0, 0, 0};
    uint64_t b4[4] = {b, 0, 0, 0};
    uint64_t ab4[4] = {a * b, 0, 0, 0};  /* simplified, ignores overflow */

    G1 aG1   = g1_scalar_mul(G1gen, a4);
    G2 bG2   = g2_scalar_mul(G2gen, b4);
    G1 abG1  = g1_scalar_mul(G1gen, ab4);

    /* e(aG1, bG2) should equal e(abG1, G2) by bilinearity */
    GT lhs = bn128_pairing(bG2, aG1);
    GT rhs = bn128_pairing(G2gen, abG1);

    return gt_equal(lhs, rhs);
}

/* ── Simplified Groth16 verification (structure only) ────────
   In practice: use libsnark or arkworks-rs for production code. */
typedef struct {
    G1 alpha_g1;
    G2 beta_g2, gamma_g2, delta_g2;
    G1 *IC;           /* size: n_public + 1 */
    int n_public;
} VerificationKey;

typedef struct { G1 A; G2 B; G1 C; } Proof;

int groth16_verify(const VerificationKey *vk, const Proof *pi,
                   const uint64_t *pub_inputs, int n_pub)
{
    /* Compute vk_x = IC[0] + sum_i( pub_inputs[i] * IC[i+1] ) */
    G1 vk_x = vk->IC[0];
    for (int i = 0; i < n_pub; i++) {
        uint64_t s4[4] = {pub_inputs[i], 0, 0, 0};
        G1 term = g1_scalar_mul(vk->IC[i + 1], s4);
        vk_x = g1_add(vk_x, term);
    }

    /* Check: e(A, B) == e(alpha, beta) * e(vk_x, gamma) * e(C, delta) */
    GT lhs = bn128_pairing(pi->B, pi->A);

    GT r1 = bn128_pairing(vk->beta_g2, vk->alpha_g1);
    GT r2 = bn128_pairing(vk->gamma_g2, vk_x);
    GT r3 = bn128_pairing(vk->delta_g2, pi->C);

    /* GT multiplication */
    GT rhs = gt_mul(gt_mul(r1, r2), r3);

    return gt_equal(lhs, rhs);
}

int main(void) {
    printf("BN128 pairing bilinearity (3, 7): %s\n",
           check_pairing_bilinearity(3, 7) ? "PASS" : "FAIL");
    /* For full ZKP: use libsnark C++ interface or arkworks-rs */
    return 0;
}
// ─── Requirements: libsnark ──────────────────────────────────
// git clone https://github.com/scipr-lab/libsnark && cd libsnark
// mkdir build && cd build && cmake .. && make
// g++ -O2 -o zkp_demo zkp_demo.cpp -lsnark -lff -lgmp -lprocps

#include <libsnark/gadgetlib1/gadgets/basic_gadgets.hpp>
#include <libsnark/gadgetlib1/gadget.hpp>
#include <libsnark/gadgetlib1/pb_variable.hpp>
#include <libsnark/zk_proof_systems/ppzksnark/r1cs_gg_ppzksnark/r1cs_gg_ppzksnark.hpp>
#include <libff/algebra/curves/public_params.hpp>

using namespace libsnark;
typedef libff::default_ec_pp ppT;
typedef libff::Fr<ppT>       FieldT;

// ─── Circuit: prove knowledge of x s.t. x^3 + x + 5 == 35 ───
class CubicCircuit : public gadget<FieldT> {
public:
    pb_variable<FieldT> x;    // private input
    pb_variable<FieldT> v1;   // x*x
    pb_variable<FieldT> v2;   // x*x*x
    pb_variable<FieldT> out;  // public output

    CubicCircuit(protoboard<FieldT> &pb, const std::string &name)
        : gadget<FieldT>(pb, name)
    {
        out.allocate(pb, "out");   // public first
        x.allocate(pb,   "x");
        v1.allocate(pb,  "v1");
        v2.allocate(pb,  "v2");
        pb.set_input_sizes(1);     // out is the single public input
    }

    void generate_r1cs_constraints() {
        // x * x = v1
        pb.add_r1cs_constraint(
            r1cs_constraint<FieldT>(x, x, v1), "x*x=v1");
        // v1 * x = v2
        pb.add_r1cs_constraint(
            r1cs_constraint<FieldT>(v1, x, v2), "v1*x=v2");
        // (v2 + x + 5) * 1 = out
        pb.add_r1cs_constraint(
            r1cs_constraint<FieldT>(v2 + x + FieldT(5), FieldT::one(), out),
            "v2+x+5=out");
    }

    void generate_r1cs_witness(const FieldT &x_val) {
        pb.val(x)   = x_val;
        pb.val(v1)  = x_val * x_val;
        pb.val(v2)  = pb.val(v1) * x_val;
        pb.val(out) = pb.val(v2) + x_val + FieldT(5);
    }
};

int main() {
    libff::default_ec_pp::init_public_params();
    protoboard<FieldT> pb;

    CubicCircuit circuit(pb, "cubic");
    circuit.generate_r1cs_constraints();

    // ── Trusted setup ────────────────────────────────────────
    const r1cs_constraint_system<FieldT> cs = pb.get_constraint_system();
    auto keypair = r1cs_gg_ppzksnark_generator<ppT>(cs);

    // ── Prove: x = 3 ────────────────────────────────────────
    circuit.generate_r1cs_witness(FieldT(3));
    assert(pb.is_satisfied());

    const r1cs_gg_ppzksnark_proof<ppT> proof =
        r1cs_gg_ppzksnark_prover<ppT>(
            keypair.pk,
            pb.primary_input(),    // public: out=35
            pb.auxiliary_input()); // private: x=3, v1=9, v2=27

    // ── Verify ──────────────────────────────────────────────
    bool verified = r1cs_gg_ppzksnark_verifier_strong_IC<ppT>(
        keypair.vk,
        pb.primary_input(),
        proof);

    std::cout << "Proof verified: " << verified << "\n";
    // Proof size: 3 group elements (~192 bytes for BN254)
    return 0;
}
import java.math.BigInteger;
import java.security.SecureRandom;
import java.security.MessageDigest;
import org.web3j.protocol.Web3j;
import org.web3j.tx.gas.DefaultGasProvider;

/**
 * Zero-Knowledge Proof demonstration in Java.
 * Part 1: Schnorr protocol simulation (educational, over toy group)
 * Part 2: web3j interaction with an on-chain Groth16 verifier
 */
public class ZKPDemo {

    // ── Schnorr simulation (educational - uses BigInteger, not EC) ──
    static final BigInteger P = new BigInteger(
        "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F", 16);
    static final BigInteger Q = P.subtract(BigInteger.ONE).divide(BigInteger.TWO);
    static final BigInteger G = BigInteger.valueOf(2);

    public static BigInteger[] schnorrProve(BigInteger x) throws Exception {
        SecureRandom rng = new SecureRandom();
        BigInteger r = new BigInteger(Q.bitLength(), rng).mod(Q);
        BigInteger Y = G.modPow(x, P);
        BigInteger R = G.modPow(r, P);

        // Fiat-Shamir challenge: c = H(Y || R)
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        md.update(Y.toByteArray());
        md.update(R.toByteArray());
        BigInteger c = new BigInteger(1, md.digest()).mod(Q);

        BigInteger s = r.add(c.multiply(x)).mod(Q);
        return new BigInteger[]{ R, c, s };
    }

    public static boolean schnorrVerify(BigInteger Y, BigInteger[] proof) throws Exception {
        BigInteger R = proof[0], c = proof[1], s = proof[2];

        // Recompute challenge
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        md.update(Y.toByteArray());
        md.update(R.toByteArray());
        BigInteger cCheck = new BigInteger(1, md.digest()).mod(Q);
        if (!cCheck.equals(c)) return false;

        // Check g^s == R * Y^c mod P
        BigInteger lhs = G.modPow(s, P);
        BigInteger rhs = R.multiply(Y.modPow(c, P)).mod(P);
        return lhs.equals(rhs);
    }

    // ── web3j: call deployed Groth16 verifier contract ──────────────
    // After running `snarkjs groth16 exportsolidityverifier circuit.zkey`
    // you get a Verifier.sol. Deploy it, then call verifyProof().
    public static boolean verifyOnChain(Web3j web3, String verifierAddress,
                                         BigInteger[] a,       // [2]    G1 point
                                         BigInteger[][] b,     // [2][2] G2 point
                                         BigInteger[] c2,      // [2]    G1 point
                                         BigInteger[] inputs)  // public signals
            throws Exception
    {
        // Load the auto-generated Java wrapper (web3j generate ...)
        Verifier verifier = Verifier.load(
            verifierAddress, web3,
            /* credentials */ null,
            new DefaultGasProvider()
        );

        // The snarkjs-generated Solidity verifier exposes:
        // function verifyProof(uint[2] a, uint[2][2] b, uint[2] c, uint[] input)
        //     external view returns (bool)
        return verifier.verifyProof(
            new org.web3j.abi.datatypes.generated.Uint256[]{
                new org.web3j.abi.datatypes.generated.Uint256(a[0]),
                new org.web3j.abi.datatypes.generated.Uint256(a[1])
            },
            new org.web3j.abi.datatypes.generated.Uint256[][]{
                { new org.web3j.abi.datatypes.generated.Uint256(b[0][0]),
                  new org.web3j.abi.datatypes.generated.Uint256(b[0][1]) },
                { new org.web3j.abi.datatypes.generated.Uint256(b[1][0]),
                  new org.web3j.abi.datatypes.generated.Uint256(b[1][1]) }
            },
            // ... c, inputs
        ).send();
    }

    public static void main(String[] args) throws Exception {
        // Simulate Schnorr proof
        BigInteger x = new BigInteger("12345678901234567890");
        BigInteger Y = G.modPow(x, P);
        BigInteger[] proof = schnorrProve(x);

        boolean valid = schnorrVerify(Y, proof);
        System.out.println("Schnorr proof valid: " + valid);

        // x = 3, Y = 2^3 mod P
        BigInteger x2 = BigInteger.valueOf(3);
        BigInteger Y2 = G.modPow(x2, P);
        BigInteger[] proof2 = schnorrProve(x2);
        System.out.println("Schnorr proof for x=3 valid: " + schnorrVerify(Y2, proof2));
    }
}
// ─── Requirements: go get github.com/consensys/gnark@latest ────
//     go get github.com/consensys/gnark-crypto@latest

package main

import (
	"fmt"
	"log"

	"github.com/consensys/gnark-crypto/ecc"
	"github.com/consensys/gnark/backend/groth16"
	"github.com/consensys/gnark/frontend"
	"github.com/consensys/gnark/frontend/cs/r1cs"
	"github.com/consensys/gnark-crypto/ecc/bn254/fr/poseidon"
)

// ─── Circuit: prove x^3 + x + 5 == out ─────────────────────────
type CubicCircuit struct {
	X   frontend.Variable `gnark:",secret"`  // private witness
	Out frontend.Variable `gnark:",public"`  // public output
}

// Define adds R1CS constraints. Called once during setup.
func (c *CubicCircuit) Define(api frontend.API) error {
	// v1 = x * x
	v1 := api.Mul(c.X, c.X)
	// v2 = v1 * x  (= x^3)
	v2 := api.Mul(v1, c.X)
	// out == v2 + x + 5
	api.AssertIsEqual(c.Out, api.Add(v2, c.X, 5))
	return nil
}

func groth16Demo() {
	var circuit CubicCircuit

	// ── Compile to R1CS ────────────────────────────────────────
	ccs, err := frontend.Compile(ecc.BN254.ScalarField(), r1cs.NewBuilder, &circuit)
	if err != nil {
		log.Fatal("compile:", err)
	}
	fmt.Printf("Constraints: %d\n", ccs.GetNbConstraints()) // should be 3

	// ── Trusted setup (Groth16) ────────────────────────────────
	pk, vk, err := groth16.Setup(ccs)
	if err != nil {
		log.Fatal("setup:", err)
	}
	fmt.Println("Setup complete. pk size:", pk.NbG1(), "G1 elements")

	// ── Prove: x = 3, out = 35 ────────────────────────────────
	witness, err := frontend.NewWitness(&CubicCircuit{X: 3, Out: 35}, ecc.BN254.ScalarField())
	if err != nil {
		log.Fatal("witness:", err)
	}

	proof, err := groth16.Prove(ccs, pk, witness)
	if err != nil {
		log.Fatal("prove:", err)
	}
	fmt.Println("Proof generated:", proof)

	// ── Verify ────────────────────────────────────────────────
	publicWitness, _ := witness.Public()
	err = groth16.Verify(proof, vk, publicWitness)
	if err != nil {
		fmt.Println("Verification FAILED:", err)
	} else {
		fmt.Println("Proof VERIFIED ✓")
	}
}

// ─── Poseidon hash (gnark-crypto) ──────────────────────────────
func poseidonDemo() {
	// Poseidon is ZK-friendly: ~200 constraints vs ~20k for SHA256
	h, err := poseidon.New()
	if err != nil {
		log.Fatal(err)
	}
	h.Write([]byte("secret preimage"))
	digest := h.Sum(nil)
	fmt.Printf("Poseidon hash: %x\n", digest)

	// In a circuit you'd use: mimc.NewMiMC() or poseidon.NewPoseidon()
	// and call api.Commit(inputs...) to get a ZK-native commitment
}

// ─── zkVM-style: prove a general Go program ────────────────────
// SP1 (Succinct) and Risc0 support proving arbitrary Go programs
// compiled to RISC-V. The pattern:
//
//   sp1-sdk: https://github.com/succinctlabs/sp1
//   1. Write your Go/Rust program as normal
//   2. Compile to RISC-V ELF
//   3. sp1_prover.prove(elf_bytes, stdin_bytes) -> proof
//   4. Deploy SP1Verifier.sol, call verify(proof, public_values)
//
// Cost: ~1M gas onchain verification for any program up to 10^8 cycles

func main() {
	groth16Demo()
	poseidonDemo()
}

10. Summary

ONE-PARAGRAPH SUMMARY

A zero-knowledge proof lets a prover convince a verifier of a statement's truth while revealing nothing beyond that truth. The three properties — completeness, soundness, and zero-knowledge — are formalized via a simulator argument. Modern non-interactive ZKPs (SNARKs and STARKs) encode computation as arithmetic circuits or AIR constraints over finite fields, reducing any program to a polynomial satisfiability problem. SNARKs (Groth16, Plonk) use elliptic curve pairings to achieve 200-byte proofs with 3ms verification, requiring a trusted setup. STARKs use only hash functions (FRI protocol) for quantum-safe, transparent proofs that are larger but faster to generate and parallelizable. On blockchains, ZKPs enable zkRollups (1000× throughput increase with full security), zkBridges (trustless cross-chain messaging), and zkIdentity (prove properties without revealing data). zkVMs (RISC Zero, SP1) generalize this to prove any computation off-chain and verify it on-chain cheaply — the cryptographic foundation of the next generation of blockchain infrastructure.

Key equations at a glance

$$\text{Soundness error after } n \text{ rounds}: \quad \varepsilon = \left(\frac{1}{2}\right)^n$$
$$\text{Schnorr response}: \quad s = r + c \cdot x \pmod{q}$$
$$\text{R1CS constraint}: \quad (\mathbf{a}_i \cdot \mathbf{s}) \cdot (\mathbf{b}_i \cdot \mathbf{s}) = (\mathbf{c}_i \cdot \mathbf{s})$$
$$\text{Groth16 verify}: \quad e(A,B) = e([\alpha]_1, [\beta]_2) \cdot e(\text{vk}_x, [\gamma]_2) \cdot e(C, [\delta]_2)$$

Where to go next