Smart Contractsadvancedarticle40 min read

Debugging ZK: Why Proofs Fail and Finding Broken Constraints

A practical debug zk circuit guide: learn why ZK proofs fail, the three failure families (constraint wrong, public inputs mismatch, witness out of range), and a five-step zk debugging pipeline from revert to root cause.

Vietcardano teamTeam

Đội ngũ biên tập VietCardanoCommunity — các chuyên gia blockchain và Cardano, cung cấp nội dung giáo dục và nghiên cứu chuyên sâu cho cộng đồng.

Published August 31, 2026Updated August 30, 2026

Duc is a developer in Hanoi whose team is shipping a coupon-redemption app with a zero-knowledge circuit: customers prove they own a valid voucher without revealing which one. The circuit compiles, the witness generates, and the proof verifies — locally. Then the team deploys the Groth16 verifier to a testnet, and every single redemption reverts. The error is unhelpful: Pairing check failed. This is the moment every ZK developer eventually meets: you are now facing a debug zk circuit problem, and the failure says almost nothing about what is wrong. This article is the zk debugging method that turns "the proof failed" into a named, fixable cause, and it works across circom, snarkjs, arkworks and Foundry.

The uncomfortable truth that makes this hard: a proof failing does not tell you why. The verifier runs a fixed set of pairing and field operations and returns a boolean. Whether your circuit has a wrong constraint, your witness is out of range, or your caller passes the public inputs in the wrong order, the symptom is identical. So you need a triage frame before you need tools. In this article you will learn the three failure families behind every INVALID_PROOF, a five-step debugging pipeline that gets you from revert to root cause, and the specific circom/snarkjs/arkworks error strings that map to each fix.

What "proof failed" actually means

Before you can debug a zk circuit, you have to know what a proof is. A zk-SNARK proof is not a message that describes the computation; it is a claim that the computation ran correctly. The verifier checks that claim by evaluating polynomial identities that encode the constraints. In the R1CS (Rank-1 Constraint System) formulation used by circom, snarkjs and arkworks, every constraint is a quadratic relation over the scalar field:

L * R - O = 0

where L, R and O are linear combinations of the signals. The witness is the assignment of values to every signal that makes all of these equations hold. A proof is valid exactly when the witness satisfies every constraint and the public inputs match what the verifier was given. Anything else — a witness that violates a constraint, a public input that differs by one bit, a signal value outside the field — produces a proof that fails at verification.

That is why the failure is opaque. The verifier is not a program interpreter that can tell you "constraint 17 is false." It is a collection of elliptic-curve operations that return false if any of the checks fail. From the outside, a buggy constraint and a typo in the caller's public inputs are indistinguishable: both come back as Pairing check failed.

Here is the reframe that makes zk debugging tractable. Instead of asking "why did the proof fail?", ask "which of the three aligned components is broken?" A proof requires three things to agree:

  1. The circuit — the constraint system itself, compiled into the verification key.
  2. The witness — private inputs plus every intermediate value, generated by the prover.
  3. The public inputs — the signals committed in the proof that the caller hands to the verifier.

When a proof fails, exactly one of these is wrong, and each has a distinct signature. The whole art of debugging a zk circuit is distinguishing them without a debugger that can see inside the proof.

Anatomy of a broken constraint: Circuit R1CS constraints, Witness private inputs, Proof, with constraint violated and three root causes

Figure 1: Why ZK proofs fail — a failing proof means at least one constraint does not hold

Failure family 1: the constraint is wrong

The first family is when the circuit itself expresses the wrong thing. The constraint system is correct as a mathematical object — every equation is satisfiable by some witness — but it does not describe the property you wanted. These bugs are the quietest because the proof will verify: the verifier is doing its job, checking exactly what you told it to check. The failure appears later, when a malicious or mistaken input produces a "valid" proof for something you never wanted to allow.

The classic example is a missing range check. Suppose your circuit proves "this number is a discount code." Without a constraint forcing the code to be a small integer, a witness that wraps around the scalar field — the field element p - 1, for instance — is mathematically equal to -1 but semantically means nothing. The constraint system can be perfectly satisfied while the meaning is broken. circom's Num2Bits and arkworks' enforce_leq / enforce_in_field exist precisely to add these constraints: they force the witness to be a genuine bounded integer rather than an arbitrary field element.

Wrong-constraint bugs also show up as genuine verification failures, though. In circom, a miswritten constraint inside a template — an incorrect multiplication, a swapped signal, an off-by-one in a loop that builds constraints — produces a system that is unsatisfiable for the inputs you actually have. You get an error at witness-generation or proving time. circom reports these as Assert Failed inside a template when you use the assert syntax, or as a solver failure when the constraint graph is inconsistent. The tell is that the failure is input-dependent: some witnesses satisfy it, yours does not, and the message points at a template line, not at the caller.

In arkworks, the same class surfaces as ConstraintSynthesisError from ark-groth16::create_random_proof when the generated witness does not satisfy the system, or as a panic inside a gadget whose enforce_* method found conflicting values. arkworks is usually more helpful than circom here because the constraint-synthesis error carries the constraint index and the values that failed.

Failure family 2: the public inputs mismatch

The second family is the one that breaks developers who ported a working snarkjs proof to Solidity. The circuit is right, the witness is right, the proof is valid — but the caller hands the verifier public inputs that do not match the ones committed inside the proof. The verifier recomputes the public-input commitments and they disagree, so it returns false.

Groth16 makes this easy to get wrong in three ways:

  1. Order. The public signals in the proof must match the order the verifier expects. circom commits public signals in declaration order inside the template. snarkjs returns them in publicSignals, and the generated Solidity verifier indexes them positionally. If you reorder them when constructing the on-chain call, verification fails even though every value is correct.
  1. Representation. Public signals are field elements. In Solidity they arrive as uint256. If you compute a value in JavaScript with floating-point or with an unsigned integer that exceeds the field prime, the number you pass on-chain is not the field element the prover committed. The classic case: a 256-bit number whose high bits exceed the BN254 scalar field modulus (21888242871839275222246405745257275088548364400416034343698204186575808495617). The prover wraps it modulo the prime; the Solidity caller passes the unwrapped value; verification fails.
  1. Omission. Some tools let you leave a public input at its default zero value on-chain while the prover committed a non-zero value. The commitment then disagrees.

The signature of this family is that the proof verifies off-chain. If snarkjs groth16.verify returns true but the Solidity verifier reverts, the circuit and witness are almost certainly fine — the caller is the broken component. This is the single most common cause of "works in tests, fails in production" for Groth16.

Failure family 3: the witness is out of range

The third family sits between the other two: the constraints are right and the caller is right, but the witness the prover generated is not a valid field assignment. This usually comes from values that overflow the scalar field, or from witness generation that is nondeterministic.

Every value in an R1CS circuit lives in the scalar field — for BN254, the prime p above. If your program computes an intermediate value larger than p, the field element wraps. The constraint system may still be satisfied in the field — but it is satisfied for a different mathematical value than your application intended. A witness that wraps a huge coupon amount into a small field element will pass the constraints that only check "amount fits in 32 bits" if those constraints are themselves wrong. This is why range checks are constraints, not validation: Num2Bits(n) in circom enforces that the value is genuinely an n-bit integer, because it constrains the binary decomposition. Without it, "fits in a field element" is the only thing being checked — and every field element fits in the field.

Nondeterminism is the other witness failure. If witness generation uses a randomness source (Fr::rand() in arkworks, a crypto.random in a witness generator) where the constraint system expects a deterministic value, the proof will sometimes verify and sometimes not. Reproducing the exact input and failing to reproduce the failure is the tell.

The debugging pipeline: reproduce, isolate, instrument, fix, verify

Enough taxonomy. Here is the five-step method — the zk debugging pipeline that resolves all three families — that you can run the moment you see Pairing check failed. It is deliberately tool-agnostic and works for circom, snarkjs, arkworks, and any Solidity verifier.

Step 1 — Reproduce. Take the exact inputs that failed and re-run the whole pipeline: witness generation, proving, verification. Save the witness, the public signals, and the verification key hash. A failure that cannot be reproduced is a prover bug (nondeterminism), not a circuit bug.

Step 2 — Isolate. Decide which family you are in by running a clean verification off-chain. If snarkjs groth16.verify (or arkworks verify_groth16) returns true but the contract reverts, you are in family 2 (public inputs) — skip straight to Step 4. If verification fails off-chain too, you are in family 1 or 3: bisect the circuit. Comment out constraint groups or prove a minimal sub-circuit with the same inputs until the failing constraint is named. arkworks errors often carry the constraint index; circom's Assert Failed names the template.

Step 3 — Instrument the witness. The witness is ground truth. Dump it and compare every intermediate value against what you expect by hand. In circom, add temporary assert statements at suspicious signals or log from the witness calculator. In arkworks, print the SatisfyingAssignment variable values. Look for a value that is unexpectedly large (range/wrap problem) or does not match the intended arithmetic (wrong constraint). This step converts "some constraint failed" into "signal amount has value X when it should be Y."

Step 4 — Fix. Apply the fix for the named family:

  • Family 1 (constraint wrong): rewrite the constraint, fix the loop bound, or re-derive the arithmetic. If the system is unsatisfiable, add the constraint that forces the intended behavior — usually a range check. Then regenerate the verification key: a circuit change invalidates the old key, and the proof must be generated against the new system.
  • Family 2 (public inputs): fix the caller. Match the signal order to the template declaration order, ensure every public input is passed as an exact field element (use BigInt, never floats; clamp or reduce to the field), and pass every non-zero signal. In Solidity, compare the publicSignals array from snarkjs against the inputs your function receives, one index at a time.
  • Family 3 (witness out of range): add range checks to the circuit (Num2Bits, enforce_leq) so a wrapping witness is rejected by construction, and make witness generation deterministic.

Step 5 — Re-verify end to end. Recompile the circuit, regenerate the verification key, re-prove with the fixed witness, and re-deploy the verifier if the key changed. Then verify on-chain again — and this time also check the things that are easy to skip: the verification key hash the contract holds, the public input order, and the gas you are sending. A change to the constraint system silently invalidates a deployed verifier keyed to the old circuit; if your contract still reverts after a circuit fix, confirm the deployed key actually matches the new zkey.

The zk debugging pipeline: reproduce, isolate, instrument, fix, re-verify, with notes per step

Figure 2: The zk debugging pipeline — reproduce, isolate, instrument, fix, verify

Common error strings and what each one means

Let the error message point you at the family before you start instrumenting. These are the strings you will actually see, mapped to fixes.

ZK proof failure triage: Family 1 constraint wrong, Family 2 inputs mismatch, Family 3 witness range, mapped to fixes

Figure 3: ZK proof failure triage — map the error string to a fix

circom: Assert Failed in witnesscalc — a witness was generated that violates a constraint, usually in family 1 or 3; check the named template. Error in template ... inconsistent constraints — the system is unsatisfiable for the given inputs; bisect. signal ... does not have a value — a signal was never assigned, so the witness cannot be completed; wire it or assert it.

snarkjs: Error: invalid proof from groth16.verify — family 1 or 3 (or a corrupted proof); invalid zkey — the verification key does not match the proof's key; Cannot read properties of undefined (reading '1') when constructing publicSignals — you passed the wrong argument shape, usually an ordering problem in family 2.

Solidity verifier: Pairing check failed / INVALID_PROOF — the raw Groth16 pairing check failed; family 1, 2 or 3, indistinguishable from the outside — run the pipeline. Point not on curve — a caller passed a malformed point (wrong encoding), family 2. Invalid public key / out-of-gas reverts with no message — often a missing public input or a wrong argument count, family 2.

arkworks: ConstraintSynthesisError with a constraint index — family 1 or 3; the message usually includes the failing values. the constraint is not satisfied from a gadget — the gadget's internal check fired; instrument the witness.

The pattern to internalize: circom and arkworks failures happen at proving time and tend to be family 1 or 3; snarkjs and Solidity failures happen at verification time and could be any family. The tool that fails tells you where to look first, not what is wrong.

Real debugging walkthrough: the coupon circuit

Let us run the method on a realistic case, the one Duc is facing. The circuit proves "I hold a valid coupon with code c, worth v, signed by the issuer." Public signals: the issuer's public key commitment and the value v. Private signals: the coupon code and the issuer's signature. On testnet, every redemption reverts with Pairing check failed; locally, snarkjs verifies the same proof as true.

Step 1 (Reproduce): Save the exact witness and public signals from the failing transaction. Re-run snarkjs verification with those saved signals: it returns true. Reproduced — and the off-chain/on-chain split already tells us this is family 2.

Step 2 (Isolate): Off-chain verification passes, so the circuit and witness are not the problem. Do not touch the circuit yet.

Step 3 (Instrument): Not needed for family 2, but do the cheap check: print publicSignals from the proof and compare each value to what the Solidity function receives.

Step 4 (Fix): The bug is found: in the smart contract, v (the coupon value) is passed as the second argument, but the circuit declares public signals in the order [issuerKey, v] — and snarkjs returns them in that order. The contract author assumed the signature came first. Reordering the on-chain call to match [issuerKey, v] fixes the redeem.

Step 5 (Re-verify): No circuit change, so no new zkey needed. The verifier is unchanged; the fix is purely in the caller. Redeem works.

Notice what did not happen: nobody touched the circuit, the proof, or the witness. The pipeline named the family (public inputs) in minutes by using the off-chain/on-chain split as the first discriminator. That is the practical payoff of the triage frame.

When the witness itself is lying

Sometimes you run the pipeline and the instrumented witness really does violate a constraint — and the arithmetic looks right. This is where the field lesson from ZK library fundamentals becomes a debugging tool: check which field the arithmetic actually runs in. If your circuit compares a value against a constant, the constant is interpreted modulo the scalar-field prime. The real trap is precision loss on the JavaScript side: a value computed as a Number (double precision, losing integer precision above 2^53) is not a field element at all. The witness is then almost right — off by 1 in some low bit — and every constraint passes except the ones that notice. The symptom: it works for small values and breaks for large ones. That is family 3 wearing a family-2 costume, and the fix is to do all arithmetic with BigInt from the input onward.

A second witness lie is timing. If your circuit hashes values that include the current block timestamp (or uses Fr::rand() in arkworks for a value the circuit treats as deterministic), the proof can be generated over a different witness than the one you inspected. Reproduce with the saved witness, not a fresh generation, and the difference becomes visible.

Preventing broken constraints in the first place

Debugging is cheaper than re-proving, but preventing is cheapest. Five habits eliminate most of the failures above:

  1. Range-check everything that leaves the field. Any signal interpreted as an integer — amounts, codes, timestamps — gets a Num2Bits/enforce_leq constraint, not a comment.
  2. Pin public-signal order in the template and the caller. Declare public signals once, in one order, and write the Solidity interface against it. Document it in the circuit header.
  3. Verify off-chain before you ever deploy. groth16.verify is the fastest discriminator between caller bugs and circuit bugs — run it on every proof, including failed ones.
  4. Save the verification key hash in your test harness. A circuit change that regenerates the key silently breaks deployed verifiers; the hash check catches it.
  5. Use BigInt everywhere. No floats, no Number arithmetic across the boundary — field elements are integers and nothing else.

What this means for Vietnamese ZK developers

Vietnam's developer community is small but growing fast in blockchain, and ZK debugging skill is exactly the kind of compounding knowledge that separates teams that ship from teams that stall. The Pairing check failed reverts that scare off juniors are, after this article, a five-step checklist away from a diagnosis. The lỗi constraint zk you will meet — constraint lỗi, public input sai thứ tự, witness tràn field — all map to the same three families. When your team hits a proof that "just fails," ask the triage question first: is it the circuit, the caller, or the witness? You will be surprised how often the answer is the caller, and how quickly the pipeline proves it.

For teams building on Vietnamese use cases — verifiable credentials, loyalty and coupon programs, private KYC — the practical advice is the same as the theory: build the off-chain verification harness on day one, range-check every public-facing signal, and treat a failing proof as a symptom to be located, not a verdict to be feared.

Frequently asked questions

Why does my proof verify with snarkjs but fail on-chain?

Nine times out of ten, the public inputs do not match: wrong order, wrong representation (float vs field element), or an omitted signal. snarkjs verification passing while the contract reverts is the fingerprint of family 2.

What does "Pairing check failed" actually mean?

The Groth16 verifier reports that the pairing product identity failed to hold — one of the three aligned components (constraint, witness, public inputs) disagrees with the proof. The message does not say which; that is why you triage.

How do I find which constraint is broken?

Bisect the circuit: prove a minimal sub-circuit with the same inputs, or comment out constraint groups until the failing one is named. arkworks errors carry the constraint index; circom's Assert Failed names the template. Then instrument the witness to see the actual values.

Why do range checks matter for debugging?

Without a range check, any field element is a valid witness value — including values that wrapped modulo the scalar-field prime. A wrapped value can satisfy the constraints while meaning something entirely different. Range checks (Num2Bits, enforce_leq) force the witness to be a genuine bounded integer, turning "works sometimes" bugs into deterministic behavior.

Can a valid-looking proof still be wrong?

Yes — that is the dangerous failure. If the constraint system does not enforce the property you think it enforces, the prover can produce a proof that verifies for inputs you never intended to allow. Testing invalid inputs matters as much as testing valid ones.

Should I fix the caller or the circuit first?

Run the off-chain/on-chain split first. If snarkjs/arkworks verification passes but the contract reverts, fix the caller (family 2). If verification fails everywhere, the circuit or witness is the problem — bisect and instrument before changing anything.

Conclusion

Duc's coupon circuit was not broken. The constraint system was correct, the witness was honest, the proof was valid — and the caller was passing public inputs in the wrong order. The Pairing check failed revert, which looked like a cryptographic catastrophe, was a positional bug in a Solidity call. That is the real lesson of debugging a zk circuit: the error message never tells you why a proof fails, but the structure of the system always does. A proof is three aligned components — constraint system, witness, public inputs — and a failure means exactly one of them disagrees. The five-step pipeline (reproduce, isolate, instrument, fix, verify) turns that abstract statement into a procedure you can run at 2 a.m. And the triage families — constraint wrong, public inputs mismatch, witness out of range — give you the vocabulary to describe what you found. Learn the method once, and the next Pairing check failed becomes a checklist instead of a panic. As Vietnamese ZK teams grow, that skill is the difference between shipping and stalling.


Quick Reference

The three failure families

FamilySymptomWhere it happensTypical errorFix
Constraint wrongProof fails at witness/proving time, or verifies for unintended inputsCircuitcircom Assert Failed; arkworks ConstraintSynthesisError (index)Rewrite constraint; add range check; regenerate vkey
Public inputs mismatchProof verifies off-chain, reverts on-chainCaller / SoliditySolidity Pairing check failed, INVALID_PROOFMatch signal order, pass exact field elements, pass every signal
Witness out of rangeIntermittent; breaks for large valuesProver / JS boundaryOverflow → wrapped field elementRange-check signals; use BigInt; deterministic witness

The zk debugging pipeline

StepActionQuestion answered
1Reproduce with saved inputs + witness"Is this even reproducible?"
2Verify off-chain; if passes → caller bug"Which family am I in?"
3Instrument the witness (dump values)"Which signal disagrees, and by how much?"
4Fix constraint, caller, or range check"What is the minimal correct change?"
5Recompile, regenerate vkey, re-verify on-chain"Does it hold end to end?"

Error-string lookup

StringToolLikely family
Assert Failed (template name)circom1 or 3
inconsistent constraintscircom1
invalid proofsnarkjs1 or 3
Cannot read properties of undefinedsnarkjs2
Pairing check failed / INVALID_PROOFSolidity1, 2 or 3
Point not on curveSolidity2
ConstraintSynthesisError (index)arkworks1 or 3

Prevention checklist

  • Range-check every signal interpreted as an integer (Num2Bits / enforce_leq).
  • Pin public-signal order in the template and document it for the Solidity caller.
  • Run groth16.verify off-chain before deploying any verifier.
  • Save the verification key hash; check it after every circuit change.
  • Use BigInt everywhere across the JS/Solidity boundary.

Tags:

debug zk circuitlỗi constraint zkzk debugging