DEV Community

ohmygod
ohmygod

Posted on

The ZK Verifier Audit Checklist: 8 Cryptographic Invariants Every Protocol Must Verify Before Deploying Groth16

On March 2, 2026, FoomCash — a ZK-SNARK privacy protocol on Ethereum and Base — lost $2.3 million because two elliptic curve constants were set to the same value. No flash loans. No reentrancy. No complex DeFi composability attack. Just a single cryptographic misconfiguration that had been sitting in plain sight since deployment.

The root cause was almost embarrassingly simple: the Groth16 verifier's gamma and delta parameters were identical. This collapsed the entire soundness guarantee of the zero-knowledge proof system, letting anyone forge proofs and drain funds without ever making a deposit.

Worse: the exact same bug had been exploited on Veil Cash days earlier. Someone read the post-mortem, found a bigger target, and scaled up.

This article isn't another exploit post-mortem. It's a practical audit checklist for anyone deploying, auditing, or integrating ZK proof systems — because the FoomCash disaster revealed that the DeFi industry has a systematic blind spot around cryptographic parameter validation.

Why ZK Verifier Bugs Are Different

Smart contract bugs typically live in business logic: access control, reentrancy, oracle manipulation. Auditors know where to look. ZK verifier bugs are different because:

  1. They're invisible to Solidity-level analysis. Slither, Aderyn, and Mythril won't flag a misconfigured elliptic curve constant. The Solidity code is syntactically correct.

  2. They break mathematical guarantees, not code paths. The verifier contract works perfectly — it just accepts forged proofs as valid.

  3. They're catastrophic. A reentrancy bug might drain one pool. A broken verifier lets an attacker mint unlimited withdrawals across every pool on every chain.

  4. They're copyable. Once one protocol is hit, every fork with the same verifier config is vulnerable. Veil Cash → FoomCash happened in days.

The Groth16 Pairing Equation (30-Second Version)

Groth16 verification checks this pairing equation:

e(A, B) = e(alpha, beta) · e(vk_x, gamma) · e(C, delta)
Enter fullscreen mode Exit fullscreen mode

Where:

  • A, B, C = proof elements (prover-supplied)
  • alpha, beta = fixed verification key constants
  • vk_x = linear combination of public inputs weighted by IC points
  • gamma = binds the proof to public inputs
  • delta = binds the proof to the private witness

The security depends on gamma ≠ delta. When they're equal, the public-input term and witness term collapse, and an attacker can algebraically cancel them by setting C = -vk_x. No private witness needed. Proof forged.

The 8-Point ZK Verifier Audit Checklist

1. Verify gamma ≠ delta (The FoomCash Check)

What to check: Compare the gamma (γ) and delta (δ) G2 points in the deployed verifier contract. They must be distinct.

How to check:

// Extract from deployed verifier — look for vk.gamma and vk.delta
// In Groth16Verifier.sol, these are typically hardcoded as:
//   vk.gamma2 = Pairing.G2Point(...)
//   vk.delta2 = Pairing.G2Point(...)

// Foundry one-liner to compare:
// cast call <verifier> "gamma2()(uint256[4])" --rpc-url <rpc>
// cast call <verifier> "delta2()(uint256[4])" --rpc-url <rpc>
Enter fullscreen mode Exit fullscreen mode

If all four coordinates match, the verifier is broken. Full stop.

Why it matters: This single check would have prevented both the Veil Cash and FoomCash exploits. It's the cryptographic equivalent of checking require(msg.sender != address(0)).

2. Validate the Trusted Setup Ceremony Output

What to check: The verification key should be derived from a legitimate trusted setup ceremony (Powers of Tau + circuit-specific phase 2). Verify the ceremony transcript hash chain.

How to check:

  • Confirm the ptau file hash matches a known ceremony (Hermez, Zcash Sapling, etc.)
  • Verify the phase 2 contribution hashes are published and independently verifiable
  • Check that the final verification key was generated by snarkjs zkey export verificationkey from a valid zkey

Red flag: No ceremony documentation, no contribution list, or a "we generated it internally" handwave.

3. Cross-Reference On-Chain Parameters Against Ceremony Output

What to check: The constants hardcoded in the deployed Solidity verifier must exactly match the verification key JSON exported from the ceremony.

How to check:

# Export vk from zkey
snarkjs zkey export verificationkey circuit.zkey verification_key.json

# Compare each field against the deployed contract:
# - vk_alpha_1 (G1)
# - vk_beta_2 (G2)  
# - vk_gamma_2 (G2)
# - vk_delta_2 (G2)
# - IC[] array (G1 points, one per public input + 1)
Enter fullscreen mode Exit fullscreen mode

Why it matters: Even if the ceremony was perfect, a copy-paste error during contract deployment can silently break soundness. FoomCash's gamma/delta collision may have been a deployment transcription error rather than a ceremony failure.

4. Verify IC Array Length Matches Circuit Public Inputs

What to check: The IC (input coefficients) array in the verifier must have exactly n + 1 elements, where n is the number of public inputs in the circuit.

How to check:

// IC[0] is the base point
// IC[1..n] correspond to each public input
// If the circuit has 7 public inputs (root, nullifierHash, recipient, relayer, fee, refund, chainId),
// IC must have exactly 8 elements
Enter fullscreen mode Exit fullscreen mode

Red flag: IC array too short (missing inputs aren't validated) or too long (extra elements are ignored but signal a mismatch between circuit and verifier).

5. Test Proof Rejection With Known-Bad Inputs

What to check: Submit deliberately invalid proofs and confirm the verifier rejects them.

Test cases:

✗ Random proof values with valid public inputs → must revert
✗ Valid proof with modified nullifierHash → must revert  
✗ Valid proof with modified root → must revert
✗ Proof of (0,0,0,0,0,0,0,0) → must revert
✗ Proof where C = -vk_x (the FoomCash forgery) → must revert
Enter fullscreen mode Exit fullscreen mode

How to check in Foundry:

function test_rejectForgedProof() public {
    // Compute vk_x from public inputs
    // Set C = negation of vk_x
    // This should FAIL if gamma ≠ delta
    bool result = verifier.verifyProof(a, b, c, publicInputs);
    assertFalse(result, "Forged proof accepted — verifier is broken");
}
Enter fullscreen mode Exit fullscreen mode

Why it matters: Positive tests (valid proofs pass) are necessary but insufficient. You need negative tests that specifically target the algebraic cancellation attack.

6. Audit Nullifier Tracking for Double-Spend Prevention

What to check: Even with a correct verifier, the protocol must track spent nullifiers to prevent replay.

// The collect/withdraw function MUST:
mapping(bytes32 => bool) public nullifierHashes;

function withdraw(bytes calldata _proof, bytes32 _nullifierHash, ...) external {
    require(!nullifierHashes[_nullifierHash], "Already spent");
    require(verifier.verifyProof(...), "Invalid proof");
    nullifierHashes[_nullifierHash] = true;  // Mark AFTER verification
    // ... transfer funds
}
Enter fullscreen mode Exit fullscreen mode

Red flags:

  • Nullifier checked after transfer (reentrancy window)
  • Nullifier stored in a way that can be cleared (upgradeable storage without proper migration)
  • No nullifier check at all (relies purely on proof validity)

7. Validate Merkle Root Against Commitment History

What to check: The Merkle root submitted with a withdrawal proof must exist in the protocol's on-chain commitment history.

mapping(bytes32 => bool) public roots;

function withdraw(..., bytes32 _root, ...) external {
    require(isKnownRoot(_root), "Unknown root");
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Red flag: If the root isn't validated, an attacker can submit a proof against a fabricated Merkle tree containing only their own fake commitments.

8. Check for Verifier Upgradeability and Admin Keys

What to check: Can the verification key constants be changed post-deployment? Is there an admin function that can swap the verifier contract?

Red flags:

  • Proxy pattern on the verifier contract (allows swapping to a malicious verifier)
  • setVerifier() or updateVK() admin functions
  • Owner key that can modify IC points, gamma, or delta
  • No timelock on verifier upgrades

Best practice: The verifier should be immutable. If upgradeability is required, enforce a multi-sig + timelock + public audit period before any parameter change takes effect.

Beyond the Checklist: Systemic Lessons

Fork-and-Forget Is the Real Vulnerability

FoomCash was a Tornado Cash fork. Veil Cash was a Tornado Cash fork. The original Tornado Cash verifier was correctly configured. Somewhere in the forking process, parameters got corrupted.

Lesson: When forking a ZK protocol, the trusted setup and verification key are NOT transferable. Each deployment needs its own ceremony or explicit verification that the parameters were correctly transcribed.

The Copycat Window Is Shrinking

Veil Cash was exploited. The technique was published. FoomCash was hit days later. In 2026, the gap between "vulnerability disclosed" and "every vulnerable fork drained" is measured in hours, not weeks.

Lesson: If your protocol uses Groth16 (or any ZK scheme), subscribe to security feeds (BlockSec Phalcon, CertiK alerts, rekt.news) and have a circuit breaker ready. When a similar protocol gets hit, you need to verify your own parameters within hours.

ZK Security Is a Specialization

Standard smart contract auditors — even excellent ones — may not catch cryptographic parameter misconfigurations. The FoomCash verifier passed Solidity compilation, had no reentrancy bugs, and had correct access control.

Lesson: ZK protocols need both a smart contract audit AND a cryptographic audit from a team that understands pairing-based cryptography. Firms like ZKSecurity, Veridise, and Zellic's ZK practice exist specifically for this.

Quick-Reference: Tools for ZK Verifier Auditing

Tool What It Does Catches FoomCash Bug?
snarkjs verify Checks proof against VK No (checks proof, not VK config)
Manual parameter diff Compare deployed vs ceremony VK Yes
Foundry negative tests Submit forged proofs Yes
Circomspect Static analysis for Circom circuits Partially (circuit-level, not deployment)
Ecne Checks R1CS constraint soundness No (checks constraints, not verifier deployment)
Custom Groth16 invariant checker Script to verify γ≠δ, IC length, etc. Yes

Conclusion

The FoomCash exploit wasn't sophisticated. It was a $2.3 million reminder that cryptographic systems have invariants that live outside the code — in the mathematical relationships between parameters. No amount of Solidity auditing will catch gamma == delta.

The 8-point checklist above takes less than a day to run against any Groth16 deployment. It would have caught FoomCash. It would have caught Veil Cash. And it will catch the next fork that copies a verifier without understanding what it copied.

If your protocol uses ZK proofs, run this checklist before your next deployment. If you're auditing one, add these checks to your methodology. The door FoomCash left open has been public knowledge for a month. The question is whether the next protocol will read this before someone else reads their verifier.


This article is part of the DeFi Security Research series. Follow for weekly deep-dives into smart contract vulnerabilities, audit techniques, and defense patterns across Solana and EVM ecosystems.

Top comments (0)