A circuit that compiles is not a circuit that proves what you think it proves. Most ZK bugs are not clever cryptographic breaks. They are quiet gaps: a signal nobody constrained, a range check someone forgot, a witness generator trusted more than it deserves. The proof verifies, the tests are green, and the protocol is still broken. Here are the mistakes that surface again and again in review, and the habits that keep them out of your code.
An under-constrained circuit accepts more than one valid witness for the same public inputs. An honest prover picks the intended one. A dishonest prover picks whichever is cheapest. The classic cause is confusing assignment with assignment-plus-constraint: in Circom, <-- computes a witness value and adds nothing to the constraint system, while <== computes and constrains. Write <-- where you meant <==, and the attacker gets a free variable.
The subtler version is a signal that is assigned correctly but never appears in any constraint that pins it. Intermediate variables and values passed around inside templates are common offenders. Compilers help if you let them: Circom's --inspect flag reports signals that are not constrained, and those warnings are worth more than most style linters.
For every signal, answer one question: which constraints determine this value? If the answer is "the witness generator," you have a bug.
Field elements are not integers. In a 254-bit prime field, subtraction wraps, and every value has a large negative twin: minus one is p - 1. A constraint such as a + b === total therefore proves addition modulo p, not addition over the integers. Write a balance check that way, and a prover can set a to p - 2, b to 3, and total to 1. Every constraint is satisfied. The transfer is nonsense.
Range checks close the hole. Constrain every value that stands for a quantity — amounts, counters, indices, timestamps — to a known bit width using Num2Bits or a comparison gadget. Then read what those gadgets actually promise. LessThan(n) proves the relation only if its inputs already fit in n bits. It does not check that for you.
The witness generator lives in JavaScript, Rust, or Go, and it is not part of the proof. Whatever it computes is a convenience. If the generator derives a commitment and the circuit merely asserts that a hash matches, the circuit proves nothing about how the commitment was built — only that someone knew a preimage.
Two consequences follow. If a witness value matters to the statement, some constraint must pin it. And generator code is denial-of-service surface: an unguarded division by zero or a bad array index can make honest provers crash. Treat it like a parser — untrusted input, explicit checks.
Gadget libraries ship templates with assumptions baked in. A Merkle verifier may assume the leaf is a field element below the modulus. A range gadget may assume its input has already been decomposed. Reuse is good; reuse without reading the doc comment is how assumptions propagate silently into your circuit.
Pin dependency versions and note the constraint count of each gadget you use. A library bump that changes a template changes your R1CS, which changes your proving key and invalidates your setup. That should be a deliberate decision, not a surprise the morning of a release.
The happy path passes trivially, because you wrote it. Bugs live at the edges. A working test suite for a circuit includes:
A proof is valid for the statement it was made about, and nothing else. If a recipient address, a chain ID, or a nullifier sits outside the circuit, the same proof can be reused in a different context. Put identity and intent inside the constraints where they belong, or pass them as public inputs and compare them in the verifier.
Two boundary mistakes deserve their
Photo: Elchinator / Pixabay