Zero-Knowledge Proof Security: Circuits, Verifiers, and Trusted Setups
Zero-knowledge proof systems are increasingly the cryptographic backbone of Layer 2 rollups, private DeFi, identity verification, and cross-chain bridges. They promise mathematical certainty: a verifier can be convinced a computation was performed correctly without learning anything about the private inputs. But the security of a ZK system is not a single property — it is a stack of interdependent guarantees that can break at multiple layers.
Zero-knowledge proofs have transitioned from theoretical cryptography to the backbone of blockchain scalability and privacy, but this power comes with a terrifying caveat: silence. Unlike a standard software bug that crashes an app, a vulnerability in a ZK circuit often fails silently, allowing an attacker to counterfeit tokens or forge state changes without anyone noticing until it is too late.
This article addresses every major attack surface in a deployed ZK system — from the toxic waste of a trusted setup ceremony to the subtle arithmetic constraints a malicious circuit author can omit. It includes Solidity patterns for hardening the on-chain verifier contract and concludes with a production-ready audit checklist.
1. The Trusted Setup Ceremony and Its Risks
Many zk-SNARK proving systems — most notably Groth16 — require a trusted setup ceremony to generate the public parameters (called the common reference string, or CRS) used by both provers and verifiers.
The entire event — from generating random values to creating a structured reference string (parameters) and destroying them — is known as a trusted setup ceremony. Many protocols, especially in the areas of data availability sampling and zkSNARKs, depend on trusted setups to generate initial parameters known as a common reference string (CRS), which is required for non-interactive and short ZK-proofs that can be shared between the verifier and prover.
The critical security invariant is deceptively simple: if these secrets — random values — fall into the hands of a malicious entity, they can create false proofs, threatening the network’s integrity.
The Powers-of-Tau Model
Modern ceremonies use a multi-party computation approach called Powers-of-Tau. The Powers-of-Tau represents a specific type of trusted setup ceremony wherein contributors sequentially update the trusted setup using their unique secret keys. A contributor receives the preceding CRS and verifies the validity of the previous update, and if the previous update is deemed valid, the contributor computes a new CRS using their secret key.
The security of the Powers-of-Tau ceremony is predicated on the condition that at least one contributor does not disclose the secret key used in the trusted setup ceremony. This is the “1-of-N” trust model. You don’t need all participants to be honest — you only need one. This is why ceremonies like Zcash’s and Ethereum’s Hermez ceremony included hundreds of participants: the probability of all of them being compromised simultaneously approaches zero.
Circuit-Specific Phase 2 Ceremonies
A critical operational risk is the distinction between the universal (Phase 1) Powers-of-Tau ceremony and the circuit-specific Phase 2. The Phase 1 output is reusable across any circuit. Phase 2 is circuit-specific and must be repeated every time the circuit changes. Verification key governance — updating keys after circuit bug fixes — requires new ceremonies and creates upgrade governance risks.
Many teams skip Phase 2 re-ceremonies after patching bugs, instead redeploying verifier contracts with old parameters. This creates a mismatch between the on-chain verification key and the current circuit, a silent failure that may not be caught without explicit cross-referencing.
Ceremony Failure Modes
- Toxic waste retention: A participant fails to destroy their randomness contribution after computing their CRS update. An adversary who obtains even one participant’s secret can generate arbitrary valid proofs.
- Coercion or compromise: A nation-state or well-resourced attacker coerces participants before the ceremony.
- Software-level backdoors: The ceremony software itself contains a vulnerability that leaks randomness. The current zk-SNARKs used by ZK rollups rely on a trusted setup ceremony where a group of participants use secret information to generate the public parameters. However, there are security concerns surrounding the trusted setup because information can be leaked during the process and the zk-SNARKs’ security would be compromised.
Alternatives That Eliminate the Trusted Setup
PLONK and zk-STARKs avoid the trusted setup constraint, at the cost of larger proofs or different performance profiles. Researchers have been looking to develop transparent zk-SNARKs that do not use a trusted setup ceremony; instead, they rely on publicly verifiable randomness to set up the public parameters. For new protocol designs, the threat model cost of eliminating the trusted setup is almost always worth the proof-size trade-off.
2. Verification Key Management and Substitution Attacks
The verification key (VK) is the on-chain artifact that the verifier contract uses to check proofs. It is derived from the circuit’s constraint system and the trusted setup parameters. If an attacker can substitute this key for one derived from a backdoored setup, they can generate proofs for false statements that the verifier will happily accept.
The Substitution Attack Surface
This pattern of security risks primarily includes verification key substitution, along with forged proof acceptance from soundness bugs and compromised trusted setups.
A verification key substitution attack can occur through several vectors:
- Governance hijack: The VK is stored in a mutable contract variable controlled by a multisig. If governance is compromised, the VK can be swapped.
- Upgrade proxy attack: The verifier is behind a transparent or UUPS proxy. An attacker who controls the upgrade key replaces the implementation with one that uses a different VK.
- Deployment substitution: The team deploys a VK that was generated from a different ceremony than the one publicly documented.
The defense is to make verification keys immutable and to publish a deterministic derivation from a publicly auditable ceremony transcript. Circuits correctly compiling down to the verifier keys used on-chain is a critical audit concern. An auditor must independently re-derive the VK from the circuit source and the ceremony transcript, then compare it byte-for-byte with what is deployed.
Solidity: Immutable Verification Key Enforcement
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title ImmutableVerifier
/// @notice Verification key is set once at construction and cannot be changed.
/// This eliminates the governance attack surface for key substitution.
contract ImmutableVerifier {
// -----------------------------------------------------------------------
// Verification key fields (Groth16 BN254 example)
// These values MUST be derived from the public ceremony transcript
// and independently verified before deployment.
// -----------------------------------------------------------------------
uint256 internal constant VK_ALPHA_X =
0x1a4c4e3a66c5a7c0e5b2b7a9d3f8e2c1b0a9d4f7c6e3b2a1d0f9e8c7b6a5d4e3;
uint256 internal constant VK_ALPHA_Y =
0x2b3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3;
uint256 internal constant VK_BETA_X1 =
0x0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1;
uint256 internal constant VK_BETA_X2 =
0x1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2;
uint256 internal constant VK_BETA_Y1 =
0x2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3;
uint256 internal constant VK_BETA_Y2 =
0x3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4;
uint256 internal constant VK_GAMMA_X1 =
0x4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5;
// ... additional G2 points omitted for brevity
// Commitment to the full VK — published before deployment, verified by users
bytes32 public immutable VK_COMMITMENT;
constructor() {
// Commit to all VK constants at deployment time
VK_COMMITMENT = keccak256(abi.encodePacked(
VK_ALPHA_X, VK_ALPHA_Y,
VK_BETA_X1, VK_BETA_X2,
VK_BETA_Y1, VK_BETA_Y2,
VK_GAMMA_X1
// ... all other VK fields
));
}
/// @notice Publicly expose VK fields for independent off-chain verification
function getVerificationKeyCommitment() external view returns (bytes32) {
return VK_COMMITMENT;
}
}
Using Solidity constant and immutable for VK fields is the single most effective mitigation against substitution attacks. A constant cannot be modified after compilation, giving users a bytecode-level guarantee.
3. Malicious Circuit Design
The circuit is the specification of what a valid proof means. A malicious or negligent circuit author can craft constraints that appear to enforce a strong statement while actually permitting a much weaker one. This is distinct from implementation bugs — it is an intentional or systematic design-level weakness.
Backdoor Constraints
A circuit author can include a hidden bypass: a constraint branch that is satisfiable only by someone who knows a secret value (the backdoor key). From the outside, the constraint system looks normal. The output — the compiled circuit artifact — does not obviously reveal the bypass.
Mitigations include:
- Peer review of circuit constraint logic against the formal specification
- Formal verification of the constraint system using tools like Picus or ECNE
- Publishing circuit source code and requiring the build to be reproducible from source to the deployed VK
The “Assigned but Not Constrained” Pattern
A frequent issue in ZK circuit design lies in distinguishing between assignments and constraints. While constraints are mathematical equations that must be satisfied for any given witness for the proof to be valid, assignments allocate values to variables during the witness generation process.
In ZK circuits, assignments are used to allocate values to variables during the proof generation process, but unlike constraints, they do not enforce proof validity on their own. If a necessary constraint is omitted, a malicious prover could exploit this weakness by modifying the assign function to bypass or manipulate the missing constraint. These discrepancies between assignment and constraint definitions create vulnerabilities, allowing a malicious actor to fork the code and adjust the assign function to exploit the absent constraint. This manipulation can lead to the generation of invalid proofs that appear valid, undermining the security and integrity of the ZK circuit.
This pattern is especially common in Halo2 circuits, where custom gates and lookup tables separate configuration from assignment.
4. Soundness vs. Completeness Failures
ZK systems have three core properties, and their failure modes are very different in impact:
- Completeness: An honest prover with a valid witness can always convince the verifier.
- Soundness: A dishonest prover without a valid witness cannot convince the verifier except with negligible probability.
- Zero-knowledge: The proof reveals nothing about the private witness.
Understanding which property is violated tells you how severe a bug is.
Soundness Failures (Critical)
Soundness failure means: “If I am a hacker and don’t know the secret, I can still prove it” — which constitutes a critical security breach. The most frequent vulnerability in ZK circuits arises from insufficient constraints (under-constraining), and this deficiency causes the verifier to mistakenly accept invalid proofs, thus undermining the system’s soundness or completeness.
A soundness failure is existential. In a ZK rollup, it means an attacker can submit a state transition that steals funds while presenting a valid-looking proof. The verifier contract accepts the proof, the state is updated, and money is stolen — all without the attacker knowing any legitimate private inputs.
Research data shows that 95 circuit vulnerabilities led to soundness issues compared to only 4 that led to completeness issues. Soundness is where the risk is concentrated.
Completeness Failures (Availability)
Completeness failure means: “If I am honest and know the secret, I still cannot prove it” — which results in a broken or unusable system.
Although less common than under-constrained issues, circuits can be over-constrained, leading to the rejection of valid witnesses by honest provers or benign proofs from honest verifiers. This issue stems from extra constraints in the circuit where legitimate solutions cannot be proven or verified, leading to DoS issues.
A practical example: over-constraining occurs when you accidentally restrict valid inputs, breaking completeness. For instance, proving a number is a square root but arbitrarily restricting the bit-length of the input to 32 bits when the field supports 254 bits means valid users with larger numbers will fail to generate proofs, causing a denial of service on your own users.
Completeness issues can often be transient, meaning that you can fix the underlying issue without having to update the circuit and recompute the prover and/or the verifier keys. This asymmetry matters for incident response: soundness bugs require full ceremony re-runs; completeness bugs may be patchable in the witness generation layer.
The Dual Execution Path Attack (Soundness via Underconstrained Inputs)
Vulnerable code patterns include circuits that contain an underconstrained free input that can be exploited to manipulate the execution path, yet still produce a valid output even after maliciously altering the execution path. This class of bug is particularly dangerous because the proof verifies successfully — the verification logic is not wrong — but the statement proven is weaker than intended.
5. Front-Running ZK Proof Submissions
Proofs submitted to on-chain verifier contracts are not automatically safe from MEV (Maximal Extractable Value) extraction. A ZK proof is a transaction like any other, and it sits in the public mempool until it is mined.
The Proof Extraction Attack
Front-running occurs when an attacker observes an unconfirmed transaction in the mempool and submits their own transaction with a higher gas fee, ensuring priority execution.
For ZK systems specifically, the attack works as follows: Alice generates a ZK proof that she knows a valid nullifier (e.g., for a Tornado Cash–style withdrawal). She submits this proof to the verifier contract. Bob, an MEV searcher, sees Alice’s transaction in the mempool, extracts her proof, replaces the recipient address with his own, and submits it with a higher gas price. Bob’s transaction executes first, and Alice’s transaction then fails because the nullifier has already been spent.
In ZK systems like Zether, ZK proofs are generated with respect to a certain state of the contract. For example, a ZK proof in a transfer transaction needs to show that the remaining balance is positive. A user generates this proof with respect to their current account balance stored in encrypted form on the contract. However, if another user’s transaction gets processed first, the original transaction will be rejected because the proof will not be valid anymore.
Mitigation Patterns
1. Bind the recipient to the proof’s public inputs. Include the msg.sender or an explicit recipient address as a public input to the circuit, so the proof is only valid for a specific caller.
2. Commit-reveal schemes. A two-phase process has users first commit to a transaction with hash(transaction + nonce), then reveal actual parameters after the commit phase ends — preventing front-running by concealing transaction details until execution.
3. Private mempool submission. Flashbots are designed to enable private, transparent auctions for MEV opportunities and help reduce harmful front-running by keeping transactions out of the public mempool.
Here is a Solidity pattern that binds the proof’s recipient to the transaction sender, eliminating the simple proof-extraction attack:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IGroth16Verifier {
function verifyProof(
uint256[2] calldata a,
uint256[2][2] calldata b,
uint256[2] calldata c,
uint256[] calldata publicInputs
) external view returns (bool);
}
/// @title NullifierRegistry
/// @notice Prevents proof replay and front-running by binding recipient to proof
contract NullifierRegistry {
IGroth16Verifier public immutable verifier;
// Nullifier => spent flag
mapping(bytes32 => bool) public nullifierSpent;
// Public input indices (circuit-specific — must match circuit design)
uint256 constant PUBLIC_INPUT_NULLIFIER = 0;
uint256 constant PUBLIC_INPUT_RECIPIENT = 1;
uint256 constant PUBLIC_INPUT_AMOUNT = 2;
event Withdrawal(address indexed recipient, uint256 amount, bytes32 nullifier);
error NullifierAlreadySpent();
error RecipientMismatch();
error InvalidProof();
constructor(address _verifier) {
verifier = IGroth16Verifier(_verifier);
}
/// @notice Submit a withdrawal proof
/// @dev The circuit MUST include msg.sender as a public input (PUBLIC_INPUT_RECIPIENT).
/// This means the proof is only valid for the submitting address,
/// preventing front-running extraction.
function withdraw(
uint256[2] calldata a,
uint256[2][2] calldata b,
uint256[2] calldata c,
uint256[] calldata publicInputs
) external {
// 1. Extract the recipient committed to in the proof's public inputs
address committedRecipient = address(uint160(publicInputs[PUBLIC_INPUT_RECIPIENT]));
// 2. Enforce the sender matches the committed recipient
// An attacker who copies this proof cannot submit it from a different address
if (committedRecipient != msg.sender) revert RecipientMismatch();
// 3. Extract and check the nullifier
bytes32 nullifier = bytes32(publicInputs[PUBLIC_INPUT_NULLIFIER]);
if (nullifierSpent[nullifier]) revert NullifierAlreadySpent();
// 4. Verify the proof
if (!verifier.verifyProof(a, b, c, publicInputs)) revert InvalidProof();
// 5. Mark nullifier as spent BEFORE external calls (CEI pattern)
nullifierSpent[nullifier] = true;
uint256 amount = publicInputs[PUBLIC_INPUT_AMOUNT];
emit Withdrawal(msg.sender, amount, nullifier);
// 6. Execute the transfer
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "Transfer failed");
}
}
6. Risks of Proof Aggregation Systems
Proof aggregation allows multiple individual proofs to be combined into a single succinct proof that can be verified in a single on-chain transaction. This is essential for the economics of ZK rollups. But aggregation introduces new trust assumptions and a broader attack surface.
Recursive Composition and the Weakest Link
Common projects employ a recursive composition of proofs, first leveraging a SNARK with a fast prover and successively proving the verification in a SNARK with a fast verifier. For example, Polygon ZK-EVM composes a FRI-based SNARK with Groth16 to obtain succinct proof size and cheap on-chain verification, while benefiting from faster prover time. Note that in this case, the trust assumption reduces to the weakest component — Groth16 still requires a trusted setup.
A bug in one underlying prover invalidates the entire aggregated proof. Auditing the full recursive stack is combinatorially harder. Security reduces to the weakest link in a chain most users cannot see.
This has a critical implication: an aggregated proof that claims to represent a thousand valid state transitions is only as secure as the least-secure component in the recursive proof stack. If a component prover has a soundness bug, an attacker can generate a valid-looking aggregate from an invalid component proof.
Aggregator Centralization Risk
Proof aggregation promises massive scalability but introduces new trust vectors and systemic fragility. Aggregation creates a natural oligopoly, and the high capital cost of specialized hardware (ASICs, FPGAs) and the need for deep expertise will concentrate power in a few large proving services.
When a single aggregator processes proofs for multiple L2s, that aggregator becomes a single point of failure — and a single point of trust. Sequencers will vertically integrate or form exclusive partnerships to minimize proof costs and latency, creating bundled centralization; L2 decentralization becomes theoretical as the sequencer-prover duo acts as a single trusted party.
Mitigation for Aggregation Systems
- Audit every layer separately. The component provers, the recursive circuit that verifies them, and the on-chain verifier of the final aggregated proof are three distinct audit surfaces.
- Never mix trust assumptions silently. If your aggregate includes a Groth16 proof, the system requires the corresponding trusted setup to be sound.
- Use emergency pause mechanisms that can halt aggregated proof acceptance if a component is found vulnerable, without requiring the entire system to go offline.
7. Underconstrained Vulnerabilities in ZK Circuits
Underconstrained vulnerabilities are the dominant vulnerability class in deployed ZK systems. Underconstrained vulnerabilities arise from insufficient constraints and typically lead to critical soundness errors.
A critical class of bugs are soundness issues that make a circuit underconstrained, such that witnesses are not fully constrained. This situation typically violates intended functionality, and allows a malicious prover to choose a bogus witness that satisfies all the given constraints but does not correspond to a trace that the circuit can actually produce.
Common Underconstrained Patterns
Range checks omitted: A circuit computes a * b = c over a finite field but does not constrain a and b to be within expected ranges. An attacker can supply values that wrap around the field modulus to produce unexpected results while the constraint is technically satisfied.
Boolean enforcement missing: A signal intended to be 0 or 1 is used in a branch condition without constraining it to {0, 1}. An attacker sets it to an arbitrary field element. Both branches may become satisfiable.
Hash preimage unconstrained: A circuit hashes an input and outputs the hash as a public input, but the assignment of the hash value is not constrained by a corresponding R1CS constraint. The prover can assign any value to the hash output.
Passing unchecked data across the integration layer: This vulnerability manifests when implicit constraints on the public inputs expected by the circuit are not enforced by the application’s verifier. It can result in both soundness and completeness issues.
Detection Approaches
A unified framework captures both under-constrained (soundness) vulnerabilities — when an invalid trace is accepted by the circuit constraints — and over-constrained (completeness) vulnerabilities — when a valid trace is incorrectly rejected. It models vulnerabilities in ZK programs as discrepancies between all possible execution traces that a computation may produce and the set of input, intermediate, and output values permitted by the circuit constraints.
Tools like ECNE, Picus, and Circomspect perform static analysis of Circom circuits specifically targeting unconstrained signals. However, existing solutions have primarily focused on formal verification and static analysis to check the correctness of ZKP circuits, but formal verification tools often struggle with scalability when applied to complex circuits, and static analysis focuses on specific patterns while facing challenges in minimizing false positives.
8. Auditing the On-Chain Verifier Contract Independently of the Circuit
The on-chain verifier contract is a critical but often under-audited component. Most security reviews focus on the circuit. But the verifier contract has its own vulnerability surface, independent of whether the circuit is correctly designed. It is paramount to have a third party review the circuit by performing formal verification or a security audit. This will provide assurance to both the engineers as well as users of the circuit that it is indeed providing the privacy and security guarantees advertised. A good security audit will provide not only security insights but also optimizations and a precise review of the constraints within the context of the wider protocol, as the circuit is only as secure as its usage.
What to Check in the Verifier Contract
1. Pairing precompile correctness. Groth16 verifiers on EVM chains call the ecPairing precompile (address 0x08). Incorrect encoding of G1/G2 points, or failing to check that pairing inputs lie on the curve, can produce false verifications. The EVM precompile does perform subgroup checks, but the Solidity wrapper must supply correctly encoded inputs.
2. Public input validation. The verifier must validate the number and range of public inputs. An attacker who can supply more public inputs than the circuit expects, or inputs outside the field modulus, may force the pairing computation into unexpected states.
3. Proof malleability. In some SNARK constructions, valid proofs can be transformed into other valid proofs for the same statement. If the verifier accepts malleable proofs and the application logic uses the proof as a unique identifier (e.g., for replay protection), an attacker can bypass nullifier checks.
4. The verifyProof return value must always be checked. A common integration bug is calling verifyProof without checking its boolean return, or wrapping it in a try/catch that swallows a revert.
5. Upgradeability of the verifier. The verifier should not be upgradeable in production. If it must be (for emergency patches), upgrades must be time-locked and announced publicly.
Here is a hardened verifier wrapper that addresses these integration concerns:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title HardenedVerifierWrapper
/// @notice Wraps a Groth16 verifier with defense-in-depth checks:
/// - Explicit return value enforcement
/// - Public input count validation
/// - Field modulus bounds checking
/// - Nullifier-based replay protection
/// - Emergency pause controlled by time-locked governance
contract HardenedVerifierWrapper {
// BN254 scalar field modulus
uint256 internal constant FIELD_MODULUS =
21888242871839275222246405745257275088548364400416034343698204186575808495617;
// Expected number of public inputs for this specific circuit
uint256 internal constant EXPECTED_PUBLIC_INPUTS = 3;
address public immutable groth16Verifier;
// Replay protection
mapping(bytes32 => bool) private _proofUsed;
// Emergency pause
bool public paused;
address public immutable guardian;
uint256 public pauseTimestamp;
uint256 public constant MAX_PAUSE_DURATION = 7 days;
event ProofVerified(bytes32 indexed proofHash, address indexed caller);
event Paused(address indexed by);
event Unpaused(address indexed by);
error ProofAlreadyUsed();
error InvalidPublicInputCount(uint256 got, uint256 expected);
error PublicInputOutOfField(uint256 index, uint256 value);
error VerificationFailed();
error ContractPaused();
error Unauthorized();
error PauseTooLong();
modifier whenNotPaused() {
if (paused) revert ContractPaused();
_;
}
constructor(address _verifier, address _guardian) {
groth16Verifier = _verifier;
guardian = _guardian;
}
/// @notice Verify a Groth16 proof with full defensive checks
function verify(
uint256[2] calldata a,
uint256[2][2] calldata b,
uint256[2] calldata c,
uint256[] calldata publicInputs
) external whenNotPaused returns (bool) {
// --- 1. Public input count validation ---
if (publicInputs.length != EXPECTED_PUBLIC_INPUTS) {
revert InvalidPublicInputCount(publicInputs.length, EXPECTED_PUBLIC_INPUTS);
}
// --- 2. Bounds check every public input against the field modulus ---
for (uint256 i = 0; i < publicInputs.length; i++) {
if (publicInputs[i] >= FIELD_MODULUS) {
revert PublicInputOutOfField(i, publicInputs[i]);
}
}
// --- 3. Compute a unique proof fingerprint for replay protection ---
bytes32 proofHash = keccak256(abi.encodePacked(
a[0], a[1],
b[0][0], b[0][1], b[1][0], b[1][1],
c[0], c[1],
publicInputs
));
if (_proofUsed[proofHash]) revert ProofAlreadyUsed();
// --- 4. Call the underlying verifier and ENFORCE the return value ---
// Never use try/catch here — a revert from the verifier is NOT
// the same as a false return. Handle both explicitly.
(bool callSuccess, bytes memory returnData) = groth16Verifier.staticcall(
abi.enc
odeWithSignature(
"verifyProof(uint256[2],uint256[2][2],uint256[2],uint256[])",
a, b, c, publicInputs
)
);
// Case 1: the call itself reverted (verifier bug, bad ABI, etc.)
require(callSuccess, "Verifier call failed");
// Case 2: the call succeeded but proof is invalid — verifier returned false
bool proofValid = abi.decode(returnData, (bool));
require(proofValid, "Invalid proof");
}
}
ZK Proof Security Audit Checklist
Verifier contract
- The verifier contract was generated from an audited toolchain (snarkjs, gnark, etc.) and has not been manually modified
- The verifier’s
verifyProoffunction is called correctly — all inputs passed in the right order and encoding - The return value of the verifier call is explicitly checked — both the low-level call success and the returned boolean
-
try/catchis not used as the sole error handler — a revert from the verifier must be treated as a verification failure, not a successful proof
Public input integrity
- Every public input that carries a security guarantee is validated on-chain before being passed to the verifier
- Public inputs that represent chain-specific context (chain ID, contract address) are computed on-chain, not supplied by the prover
- Nullifiers or commitment hashes are checked against a replay registry before the proof is accepted
Trusted setup (SNARKs)
- The ceremony transcript is publicly available and the number of participants is documented
- The verification key used on-chain matches the output of the ceremony, verifiable by hash
- There is a documented plan for re-running the ceremony if the setup is suspected compromised
Circuit and constraint soundness
- The circuit has been reviewed by a specialist in ZK circuit auditing — not just a Solidity auditor
- Under-constrained witnesses have been explicitly ruled out for all critical signal paths
- The field size of the proof system is appropriate for the security level required
Nullifier and replay protection
- Every proof that authorizes an action includes a nullifier or unique commitment
- Nullifiers are stored and checked atomically with proof verification
- The nullifier namespace is chain-specific — cross-chain replay is impossible by construction
Upgrade and key rotation
- If the verifier or circuit is upgradeable, the upgrade path is access-controlled and timelocked
- Rotating to a new ceremony or circuit requires re-verification of all existing proofs or an explicit migration path