Module 8 · Ethereum, the EVM & on-chain zero-knowledge
On-chain cryptography and zero-knowledge verification
A zero-knowledge proof is only useful on-chain if a contract can check it cheaply and correctly. The EVM is a deliberately limited machine, so heavy cryptography is exposed through a small set of precompiled contracts — fixed addresses that run native code for hashing, signature recovery and elliptic-curve pairings. On top of these, a verifier contract can check a succinct proof like Groth16 — roughly 128 bytes, verified by a handful of pairing operations in constant time regardless of how large the underlying computation was. This lesson explains the primitives, walks through what a verifier actually computes, explains why Groth16 needs a trusted setup, and catalogues the on-chain-specific ways verification goes wrong. It bridges directly to the two Module 8 case studies, which depend on exactly these checks being right.
Learning objective
Understand the cryptographic primitives the EVM exposes — keccak256, ecrecover over secp256k1, and the BN254 elliptic-curve precompiles 0x06-0x08 — and how a succinct proof such as Groth16 (128 bytes, constant-time verification) is checked on-chain by a verifier contract; the role of the trusted setup (powers-of-tau); and the failure modes specific to on-chain ZK: proof malleability, nullifier design, and missing domain separation.
- Track
- Engineering
- Module
- 8 · Ethereum, the EVM & on-chain zero-knowledge
- Prerequisites
- What is a zero-knowledge proof · The EVM execution model
Conceptual introduction
In an ordinary smart contract, the code is the law: the EVM re-executes every step and the network agrees on the result. Zero-knowledge changes the economics. Instead of re-running an expensive computation, a contract checks a small proof that the computation was done correctly — and in a privacy system, done over data the contract never sees. The chain verifies a statement without witnessing the work or the secret behind it.
For that to be practical, verification must be cheap, because every node runs it and pays gas for it. The EVM cannot afford to compute an elliptic-curve pairing in plain bytecode, so Ethereum ships a few operations as precompiles: special addresses that, when called, run fast native code instead of interpreted EVM instructions. On-chain cryptography is, in large part, the art of assembling these few primitives into a correct verifier.
In a ZK system the mathematics is the law in a way the EVM's re-execution is not: a soundness bug in a circuit or a verifier does not crash — it silently accepts a false statement. The failure is invisible until it is exploited, which is why verifier correctness deserves the same scrutiny as the money it guards.
The primitives the EVM exposes
The EVM gives contracts a small, fixed menu of cryptographic building blocks. Two are everyday tools; the rest are the machinery that makes succinct proofs verifiable on-chain. It is worth being precise about which are opcodes and which are precompiled contracts at reserved addresses.
- keccak256 (opcode)
- The EVM's native hash function (the KECCAK256 / SHA3 opcode, address none — it is an instruction, not a precompile). It underlies storage slot derivation, Merkle trees, commitments and the Fiat-Shamir challenges that make interactive proofs non-interactive. Fast and ubiquitous.
- ecrecover (precompile 0x01)
- Recovers the signer address from an ECDSA signature over the secp256k1 curve — the same curve that secures ordinary Ethereum accounts. It is how contracts authenticate signed messages. Note it returns address(0) on malformed input rather than reverting, and ECDSA signatures are themselves malleable, both of which are frequent bug sources.
- sha256 and ripemd160 (precompiles 0x02, 0x03)
- Standard hashes exposed for interoperability with systems (notably Bitcoin and many off-chain protocols) that use them; more expensive than keccak256 on the EVM.
- modexp (precompile 0x05)
- Big-integer modular exponentiation, used by RSA-style checks and various cryptographic constructions that need arithmetic over large moduli.
- BN254 curve operations (precompiles 0x06, 0x07, 0x08)
- The pairing-friendly curve alt_bn128 / BN254: point addition (0x06), scalar multiplication (0x07) and the pairing check (0x08). These three are what a Groth16 verifier actually calls. They were added by EIP-196 and EIP-197 and repriced cheaper by EIP-1108 specifically to make on-chain SNARK verification affordable.
The distinction matters in audits: keccak256 is an opcode, ecrecover and the BN254 operations are precompiled contracts at fixed addresses. A verifier that "does a pairing" is really issuing a staticcall to address 0x08 and interpreting the single returned word as true/false.
What makes a proof verifiable on-chain: Groth16
The reason ZK is practical on Ethereum is succinctness: a good SNARK produces a tiny proof that verifies in time independent of how large the proven computation was. Groth16, introduced in Jens Groth's 2016 paper "On the Size of Pairing-based Non-interactive Arguments", is the classic example and still the workhorse for on-chain verification. Its proof is about 128 bytes — three elliptic-curve points — and verification costs a fixed, small number of pairing operations no matter whether the circuit encoded a thousand constraints or a billion.
- Circuit
- An arithmetic encoding of the statement to be proven, as a system of constraints the witness must satisfy. The circuit is where soundness lives: a missing constraint lets a false witness produce a valid proof.
- Witness
- The prover's private inputs that satisfy the circuit. The proof demonstrates a satisfying witness exists without revealing it.
- Public inputs
- The parts of the statement both sides can see — for a privacy pool, things like a Merkle root and a nullifier. The verifier binds the proof to these; if a public input is not properly constrained, the proof says nothing about it.
- Verification key
- A set of curve points produced by the trusted setup for this specific circuit, baked into the verifier contract. The verifier combines it with the public inputs and the proof in the pairing equation.
Crucially, "succinct and constant-time" means the verifier does not re-run the computation; it checks an algebraic relation. That is the source of ZK's scaling power and also of its danger: nothing about the proof tells the verifier whether the circuit faithfully captured the intended statement. A valid proof proves the circuit was satisfied — not that the circuit was the right circuit.
Mechanism: inside a Groth16 verifier contract
A Groth16 proof is three points, conventionally called A, B and C. Verification recombines the verification key with the public inputs to form a point (often written vk_x), then checks one pairing equation of the form e(A, B) = e(alpha, beta) · e(vk_x, gamma) · e(C, delta). If the equation holds, the proof is accepted. The pairing itself is the call to precompile 0x08; the point arithmetic that builds vk_x uses 0x06 and 0x07.
synthetic — schematic Groth16 verify (illustrative, not production code)contract Groth16Verifier { // Verification-key points, fixed at deploy time from the trusted setup: // G1 alpha; G2 beta, gamma, delta; G1[] IC (one per public input + 1) function verify(uint256[] memory publicInputs, Proof memory proof) public view returns (bool) { // 1. Fold the public inputs into a single G1 point using IC: // vk_x = IC[0] + sum_i publicInputs[i] * IC[i+1] (uses 0x06, 0x07) G1Point memory vk_x = IC[0]; for (uint i = 0; i < publicInputs.length; i++) vk_x = ecAdd(vk_x, ecMul(IC[i + 1], publicInputs[i])); // 2. REQUIRED sanity checks on attacker-supplied points: require(onCurve(proof.A) && onCurve(proof.B) && onCurve(proof.C)); require(inSubgroup(proof.A) && inSubgroup(proof.B) && inSubgroup(proof.C)); // 3. The pairing check (staticcall to precompile 0x08): // e(A, B) == e(alpha, beta) * e(vk_x, gamma) * e(C, delta) return pairingCheck(proof.A, proof.B, negate(alpha), beta, negate(vk_x), gamma, negate(proof.C), delta); } }
- 1Fold the public inputs into one curve point (vk_x) using the verification key, via BN254 add and scalar-multiply precompiles.
- 2Validate every attacker-supplied point: confirm it lies on the curve and in the correct prime-order subgroup — skipping this is a real verifier vulnerability.
- 3Issue the pairing check to precompile 0x08, which returns a single boolean word.
- 4Return that word as accept/reject. The whole check is constant-time: the loop length depends only on the fixed number of public inputs, never on the size of the proven computation.
Forgetting the on-curve or subgroup checks is not theoretical. If a verifier accepts points outside the correct subgroup, a small-subgroup attack can make the pairing equation pass for proofs that correspond to no valid witness — a soundness break that lets an attacker "prove" false statements.
The trusted setup and powers-of-tau
Groth16's small proofs and cheap verification come at a price: the verification key must be produced by a trusted setup. The setup samples secret randomness and uses it to generate the public parameters; that secret is called toxic waste and must be destroyed. Anyone who keeps it can forge valid-looking proofs for false statements, compromising the entire system.
The practical defence is a multi-party ceremony, typically structured as a powers-of-tau phase. Each participant contributes their own randomness on top of the previous contribution, and the result is secure as long as at least one participant honestly destroyed their share. No single party ever holds the whole secret, so trust is distributed rather than placed in one entity.
synthetic — the shape of a powers-of-tau contribution// Each participant raises the running parameters to the power of their own secret, // then MUST destroy that secret. One honest deletion keeps the whole ceremony safe. struct SetupParams { G1Point[] powersG1; G2Point[] powersG2; } // [tau^0, tau^1, ...] function contribute(SetupParams memory prev, uint256 mySecret /* TOXIC WASTE */) returns (SetupParams memory next) { for (uint i = 0; i < prev.powersG1.length; i++) next.powersG1[i] = scalarMul(prev.powersG1[i], pow(mySecret, i)); // destroy mySecret here; if it leaks, forged proofs become possible. }
Different proving systems make different trade-offs here. Groth16 needs a fresh setup per circuit; some systems use one universal setup; others (STARK-style and certain other constructions) need no trusted setup at all, trading it for larger proofs or heavier verification. "It is a SNARK" does not tell you its setup assumptions — see the proving-systems comparison.
On-chain-specific failure modes
Beyond circuit bugs (under-constrained circuits, missing range checks) covered elsewhere, on-chain verification has its own recurring failures. These are the ones that bite at the contract boundary.
- Proof malleability
- Groth16 proofs are malleable by design: given a valid proof (A, B, C), one can derive a different valid proof (for example by negating points) for the same statement without knowing the witness. The proof verifies either way. This is fine for pure verification but breaks any logic that treats a proof as a unique identifier.
- Nullifier
- A one-time value derived from a secret that a privacy system records to prevent double-spending — "this note has been spent". The nullifier, not the proof, must be the uniqueness key. A correctly designed nullifier is deterministic from the secret and cannot be re-derived for a second spend.
- Domain separation
- Binding a hash or challenge to its specific context (which contract, which chain, which operation) so a value valid in one context cannot be replayed in another. Missing domain separation lets proofs or signatures cross contexts they were never meant for.
- Weak Fiat-Shamir
- When a non-interactive proof derives its challenge by hashing an incomplete transcript — omitting a commitment or a public input — the prover can steer the challenge and forge proofs. A 2022 class of such vulnerabilities (publicly disclosed as "Frozen Heart") affected several widely used proving libraries.
The malleability-and-nullifier interaction is the canonical on-chain trap. If a system uses the proof bytes themselves to detect replays, an attacker mutates a valid proof into a different-but-valid one and replays the underlying action — the double-spend the system thought it prevented. The fix is architectural: never key uniqueness on the proof; key it on a nullifier the circuit binds to the secret, and bind every public input (nullifier, Merkle root, recipient, chain id) into the statement with proper domain separation.
synthetic — key replay protection on the nullifier, never on the proof// WRONG: proofs are malleable, so this detects nothing. // require(!usedProofs[keccak256(proof)], "replayed"); // RIGHT: the circuit binds a deterministic nullifier to the spender's secret; // the contract records nullifiers, and the proof's job is only to attest validity. function spend(bytes calldata proof, uint256 nullifier, uint256 root) external { require(!spentNullifiers[nullifier], "already spent"); // uniqueness lives here require(verifier.verify([nullifier, root], proof), "invalid proof"); spentNullifiers[nullifier] = true; // mark spent // ... effect ... }
Bridge to the Module 8 case studies
Everything above is the substrate the two Module 8 case studies stand on. The coercion-resistant-privacy case study builds a privacy pool on exactly these pieces: proofs verified on-chain by a Groth16-style verifier, nullifiers for double-spend prevention, Merkle roots as public inputs, and an EIP-2535 Diamond (from the delegatecall lesson) to hold it all at production scale. Every failure mode here — a missing subgroup check, a proof treated as an identifier, an unbound public input — is a way that system could leak or be drained.
The autonomous-AML-adversary case study comes at the same machinery from the opposite side: it studies how an adversary reasons about privacy systems. Understanding what a verifier does and does not guarantee is what keeps that analysis honest. A valid proof demonstrates that a statement holds under its circuit; it does not establish that the transaction is legitimate, that the sender is who policy assumes, or that an action is safe to take. Reading proofs as attestations of math, not of intent, is the discipline both case studies require.
Across both case studies the honest framing is the same one from the first ZK lesson: a verified proof proves a precise mathematical statement and nothing more. Treating it as proof that an action is authorized, correct, or safe is the error that turns a sound cryptosystem into an unsound decision.
Exercise
A privacy pool verifies a Groth16 proof on withdrawal and, to stop a user withdrawing twice, stores keccak256 of the submitted proof bytes and rejects any proof it has seen before. The circuit and verifier are otherwise correct. Describe the double-spend an attacker can still perform, name the property that enables it, and give the correct replay-prevention design.
Expected direction: Groth16 proofs are malleable: from one valid proof (A, B, C) an attacker can derive a different byte-string that is still a valid proof for the same statement (for example by negating points) without knowing the witness. Since the contract keys replay protection on keccak256 of the proof bytes, the mutated proof hashes differently, passes the "unseen" check, verifies, and lets the attacker withdraw a second time — a double-spend. The enabling property is proof malleability. The correct design keys uniqueness not on the proof but on a nullifier: the circuit derives a deterministic nullifier from the spender's secret and exposes it as a public input; the contract records spent nullifiers and rejects a repeat. The proof's role is only to attest that the statement (including the nullifier and Merkle root) holds, with all public inputs bound into the circuit and proper domain separation.
Check your understanding
- Which precompiles does a Groth16 verifier rely on, and for what?
- The BN254 curve precompiles: 0x06 for point addition and 0x07 for scalar multiplication (to fold public inputs into vk_x), and 0x08 for the pairing check that decides accept/reject. keccak256 is an opcode, and ecrecover (0x01) is a separate signature primitive.
- What does "succinct, constant-time verification" give you, and what does it crucially NOT give you?
- It gives verification cost independent of the size of the proven computation — a ~128-byte Groth16 proof checked with a fixed number of pairings. It does not give any assurance that the circuit correctly encodes the intended statement; a valid proof proves the circuit was satisfied, not that it was the right circuit.
- Why does Groth16 need a trusted setup, and what makes a ceremony safe?
- The setup generates the verification key from secret randomness (toxic waste); anyone holding that secret can forge valid proofs for false statements. A multi-party powers-of-tau ceremony is safe as long as at least one participant honestly destroyed their contribution, so no single party ever holds the whole secret.
- Why must a privacy system detect double-spends via a nullifier rather than via the proof itself?
- Because Groth16 proofs are malleable: an attacker can produce a different valid proof for the same statement without the witness, defeating any replay check keyed on the proof bytes. A nullifier is derived deterministically from the secret and bound into the circuit, so recording spent nullifiers reliably prevents a second spend.
References
- [1]Groth — On the Size of Pairing-based Non-interactive Arguments (EUROCRYPT 2016)
- [2]Goldwasser, Micali & Rackoff — The Knowledge Complexity of Interactive Proof Systems (1985)
- [3]EIP-196 & EIP-197 — Precompiles for BN254 addition, scalar multiplication and pairing checks
- [4]EIP-1108 — Reduced gas cost for the BN254 (alt_bn128) precompiles
- [5]Antonopoulos & Wood — Mastering Ethereum (O'Reilly, 2018)
References point to public, well-known technical sources for further study.
Related articles
