7 Common Mistakes in ZK Circuit Development and How to Avoid Them

7 Common Mistakes in ZK Circuit Development and How to Avoid Them

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.

1. Under-Constrained Circuits

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.

2. Missing Range Checks

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.

3. Trusting the Witness Generator

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.

4. Reusing Gadgets Without Reading Their Assumptions

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.

5. Testing Only the Happy Path

The happy path passes trivially, because you wrote it. Bugs live at the edges. A working test suite for a circuit includes:

  • Mutation tests. Take a valid witness, change one value, and confirm the constraint system rejects it. A mutation that still satisfies every constraint is a finding, not a passing test.
  • Boundary values. 0, 1, 2^n - 1, 2^n, and p - 1 for anything with a declared width.
  • Generator failures. Inputs that force division by zero or an out-of-bounds index in the witness code.
  • Static analysis. Tools such as Ecne, CIVER, and Picus exist specifically to hunt for under-constrained signals, and they catch things no unit test will.

6. Ignoring the Circuit-Protocol Boundary

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

Related Posts