ROOTVIELROOTVIEL
← Academy

Module 12 · Applied Zero-Knowledge: commitments, proving systems & on-chain privacy

ZK circuit security and auditing: under-constrained circuits and soundness

This is the most security-critical lesson in the module. A ZK proof is only as meaningful as the circuit behind it, and a circuit bug does not crash — it silently accepts a false statement, exploitable with no on-chain trace. The dominant failure is the under-constrained circuit: a signal that the prover can set freely because the constraint that should pin it is missing. We separate completeness (honest inputs always prove) from soundness (only true statements can be proven) and show that testing checks completeness while attackers exploit soundness. We catalogue the recurring bug classes with synthetic examples, show why formal verification and adversarial-witness testing are needed where unit tests are not enough, and close on the hard discipline: a valid proof attests that a witness satisfied THIS circuit under THESE assumptions — not that the circuit was correct, the setup honest, or the decision safe.

Learning objective

Learn why a passing zero-knowledge proof proves a specific statement under its assumptions and nothing more, and how that statement silently diverges from the intended one: under-constrained circuits, completeness-versus-soundness bugs, assignment-versus-constraint confusion, missing range checks, weak Fiat–Shamir, trusted-setup compromise and nullifier reuse — plus the auditing posture (formal tools, adversarial witnesses, named assumptions) that catches them.

Track
Engineering
Module
12 · Applied Zero-Knowledge: commitments, proving systems & on-chain privacy
Prerequisites
Arithmetic circuits and R1CS · Polynomial IOPs and modern proving systems · Nullifiers, Merkle membership and the privacy-pool pattern

Conceptual introduction

In an ordinary program a bug usually announces itself: a crash, a wrong output, a failing test. In a zero-knowledge circuit the most dangerous bugs are silent. A circuit is a system of constraints a witness must satisfy; if a constraint that should exist is missing, the circuit accepts witnesses it should reject, and the proof system faithfully produces a valid proof of a false statement. Nothing reverts. The failure is invisible until an attacker uses it — and because the witness is private, it can be used without leaving a trace of wrongdoing on-chain.

This inverts where scrutiny belongs. In a transparent contract the state is visible and errors can sometimes be observed after the fact; in a ZK system the circuit is the law, and a soundness bug lets an attacker mint value, prove transactions that never happened, or bypass the entire system. Auditing ZK is therefore less about the prover and verifier libraries (usually well-studied) and more about the circuit: does it actually constrain everything the statement claims?

Note

The governing sentence for the whole lesson: a verifier checks that a witness satisfies the circuit it was given, under the proving system's assumptions. It cannot check that the circuit encodes the statement you meant. The gap between "the circuit the prover satisfied" and "the statement you think was proven" is where almost every ZK exploit lives.

Completeness vs soundness — and why testing misses one

Two correctness properties of a circuit pull in opposite directions, and conflating them is the root cause of under-tested circuits.

Completeness
Every valid input has a witness that satisfies the circuit: an honest user can always prove a true statement. Informally, ∀ valid x, ∃ w: C(x, w) = 1.
Soundness
Only valid inputs have satisfying witnesses: nobody can prove a false statement. Informally, ∀ x, w: C(x, w) = 1 ⇒ x is valid. This is the property attackers break.
Under-constrained
The circuit admits witnesses it should not — a soundness failure. A signal can take multiple values for the same public inputs, only some of which are legitimate.
Over-constrained
The circuit rejects some valid witnesses — a completeness failure (annoying, denial-of-service-ish) but not a theft vector.

Here is the trap. A unit test runs the circuit on an honest witness and checks it proves — that tests completeness. But an attacker never supplies an honest witness; they search for a dishonest one the circuit wrongly accepts. Soundness is a statement about witnesses you did not write, so a green test suite can sit on top of a circuit that is wide open. "Testing proves that for these inputs the circuit works; a soundness bug is about the inputs you did not test." The only ways to gain confidence about soundness are adversarial-witness construction and formal verification.

Note

A passing test suite is evidence of completeness, not soundness. If an audit report cites test coverage as its soundness argument, that is a red flag. Soundness needs adversarial reasoning or a tool that quantifies over all witnesses.

The recurring bug classes (with synthetic examples)

These classes recur across audits and real incidents. The examples use Circom-shaped syntax, where <== both assigns a signal and adds a constraint, while <-- only assigns (the witness generator computes it) and adds NO constraint. The gap between them is the single most productive bug source in the ecosystem.

synthetic — assignment vs constraint, the classic under-constraint (illustrative)
// BUG: y is computed but NEVER constrained. template BadSquare() { signal input x; signal output y; y <-- x * x; // assignment only: prover can put ANY value in y } // a malicious prover sets y to anything it likes // FIX: constrain as well as assign. template GoodSquare() { signal input x; signal output y; y <== x * x; // assigns AND enforces y = x*x for every proof }
synthetic — a bit that was never forced to be a bit (illustrative)
// BUG: 'bit' is used as if boolean but never constrained to {0,1}. signal input bit; out <== x + bit * y; // prover can set bit = 37, breaking the logic // FIX: force bit in {0,1} before using it. signal input bit; bit * (1 - bit) === 0; // the only solutions are 0 and 1 out <== x + bit * y;
Missing range checks / field aliasing
Circuit arithmetic is modulo a large prime. A value expected to be, say, 64 bits that is never range-checked can be a huge field element that wraps around — the mechanism behind confidential-amount inflation. Every bounded input must be explicitly range-constrained.
Non-deterministic / unconstrained outputs
Outputs or intermediate signals left to the witness generator (<--) without a matching constraint let the prover choose them freely. Determinism — exactly one valid output per public input — must be enforced, not assumed.
Weak Fiat–Shamir
When the challenge is derived by hashing an incomplete transcript (omitting a commitment or a public input), the prover can steer the challenge and forge proofs. The 2022 "Frozen Heart" class of disclosures affected several widely used proving libraries for exactly this reason.
Nullifier reuse / mis-binding
If the nullifier is not deterministically bound to the spending secret in-circuit, or the contract keys uniqueness on the proof instead of the nullifier, the double-spend protection fails even with an otherwise-sound circuit.
Trusted-setup compromise
For systems with a trusted setup (e.g. Groth16, KZG-backed), surviving toxic waste lets an attacker forge valid proofs of false statements. The ceremony is part of the circuit's security perimeter, not a deployment afterthought.
Public-input mismatch
If a value the on-chain effect depends on (recipient, amount, chain id) is not among the circuit's public inputs and bound into the statement, the proof says nothing about it, enabling replay or redirection.

A sobering historical note recorded in the reference material: documented soundness issues have touched well-known systems — a potential-inflation bug in an early shielded-pool circuit, an under-constraint enabling double-spend in a mixer, and multiple under-constraints found in a widely used circuit library. None of these were failures of the underlying mathematics; they were circuits that proved the wrong thing correctly.

The auditing posture

Because the dangerous bugs are about witnesses nobody wrote, ZK auditing uses techniques beyond unit testing. The goal is to establish soundness — that no accepted witness corresponds to a false statement — not merely that honest inputs work.

  1. 1Write the intended statement precisely, in plain language, before reading the circuit: exactly what must be true for a proof to be legitimate, including every public input the effect depends on.
  2. 2Enumerate every signal and ask: what constrains this? A signal reachable by the prover with no constraint pinning it is an under-constraint until proven otherwise.
  3. 3Construct adversarial witnesses: deliberately try to satisfy the circuit with values that make the statement false (a non-bit "bit", an out-of-range amount, a leaf not in the tree). If one succeeds, the circuit is unsound.
  4. 4Apply static and formal tools — linters that flag assignment-without-constraint, and solvers/formal verifiers that search the whole witness space for multiple satisfying assignments — rather than trusting manual review alone.
  5. 5Audit the setup and the Fiat–Shamir transcript explicitly: who ran the ceremony and what was destroyed; and does the challenge hash cover every commitment and public input?
  6. 6State the residual assumptions in the report: which proving system and setup, which hardness assumptions, what the circuit does and does not constrain.
Formal verification
Proving a property holds for ALL inputs, not sampled ones — via model checking, theorem proving, or SMT solving. The difference from testing is "all possible inputs" versus "the cases we tried".
Under-constraint detectors
Static analysers (e.g. Circom-oriented linters) and R1CS soundness checkers that flag signals which can take multiple values, or assignments (<--) without a matching constraint. They catch the dominant bug class automatically.
Note

Rules of thumb from the reference material: always use the constrain-and-assign operator, treat every <-- as a documented exception that must say where the constraint is; range-constrain every bounded input; never trust the prover — every signal that affects the result must be constrained; prefer audited, widely-used templates; and remember testing is not sufficient because a test uses an honest witness while an attacker uses a dishonest one.

A valid proof is not a correct decision (RootRaven)

This lesson is the sharpest statement of the RootRaven evidence model in the entire ZK track. A verifier that accepts a proof has established exactly one thing: some witness satisfied this circuit, under this proving system's assumptions and an honest setup. That is a precise, checkable fact. It is also the complete extent of what was established.

It does not establish that the circuit encodes the statement anyone intended (under-constraint); that the computation it models is the right computation (specification error); that the setup's toxic waste was destroyed (trust assumption); that the challenge was sound (Fiat–Shamir); or that the action the proof authorises is correct, legitimate or safe (a question the proof never addresses). When RootRaven reports on a ZK system, the finding is never "the proof verifies, therefore the system is secure." It is "the proof verifies, which attests to statement S under assumptions A; here is what S does not cover, and here is the independent evidence for the rest." A sound cryptosystem becomes an unsound decision the moment a valid proof is read as more than it is.

Exercise

Note

You are auditing a withdrawal circuit for a shielded pool. It has 100% unit-test coverage and all tests pass. The circuit computes the nullifier as `nf <-- Poseidon(n)` and computes the Merkle leaf the same way, then checks the root. The recipient is passed to the contract but is not a circuit input. Identify every soundness-relevant problem, explain why the passing tests did not catch them, and give the corrected design.

Expected direction: Problems: (1) `nf <-- Poseidon(n)` assigns the nullifier without constraining it — the prover can publish any nullifier value regardless of the real seed, so the deterministic-binding and double-spend protection are broken; it must be `nf <== Poseidon(n)` (constrain, not just assign). (2) If the Merkle leaf is likewise computed with <-- and not constrained to equal Poseidon(s, n), the membership check can be satisfied for a leaf the prover fabricated — an under-constraint that forges withdrawals. Use <== and constrain the leaf and every hash up the path. (3) The recipient is not a circuit public input, so the proof is not bound to it: a relayer/front-runner can take the proof from the mempool and redirect the payout. The recipient must be a public input mixed into the proven statement (and the Fiat–Shamir transcript). Why tests missed them: unit tests run honest witnesses and check completeness — that legitimate inputs prove. These are soundness bugs about dishonest witnesses (a forged nullifier, a fabricated leaf, a redirected recipient) that the honest tests never construct. Corrected design: use constrain-and-assign for the nullifier and the leaf/path, enforce nf = Poseidon(n) and leaf = Poseidon(s, n) with the path hashing to the root, add the recipient as a bound public input, and verify soundness with adversarial witnesses and an under-constraint detector rather than relying on test coverage.

Check your understanding

Why are ZK circuit bugs more dangerous than typical smart-contract bugs?
They are silent: a soundness bug makes the proof system produce a valid proof of a false statement with nothing reverting, and because the witness is private it can be exploited with no on-chain trace. Unlike a transparent contract where state is visible, a ZK soundness failure is invisible until it is used.
What is the difference between completeness and soundness, and which does a passing test suite demonstrate?
Completeness: every valid input has a satisfying witness (honest users can prove true statements). Soundness: only valid inputs have satisfying witnesses (nobody can prove a false statement). A passing test suite, run on honest witnesses, demonstrates completeness — not soundness, which concerns the dishonest witnesses an attacker would construct.
What is the difference between <-- and <== in a circuit, and why does it cause under-constraints?
<== both assigns a signal and adds a constraint enforcing the relation for every proof; <-- only assigns (the witness generator computes the value) and adds no constraint. Using <-- where a constraint is needed lets the prover set the signal to any value, so the circuit accepts witnesses it should reject — an under-constrained soundness bug.
When a verifier accepts a proof, what exactly has been established, and name three things it has not?
Established: some witness satisfied this specific circuit, under the proving system's assumptions and an honest setup. NOT established (any three): that the circuit encodes the intended statement (under-constraint/specification); that the trusted setup's toxic waste was destroyed; that the Fiat–Shamir challenge was sound; that bounded values were in range; or that the authorised action is correct, legitimate or safe.

References

  1. [1]Banco ZK — Vol. II (2025), Chapter 5: formal verification of circuits, soundness, under-/over-constraint, tooling and best practices — external educational reference
  2. [2]Banco ZK — Vol. III (2025), Block 19: attacks and defence, vulnerabilities of private contracts — external educational reference
  3. [3]Trail of Bits — "Frozen Heart" disclosures on weak Fiat–Shamir in several proving-system implementations (2022)
  4. [4]Circomspect — static analyser / linter for Circom (detection of assignment-without-constraint and under-constraints)
  5. [5]Picus / Ecne — tooling for detecting under-constrained signals and checking R1CS soundness

References point to public, well-known technical sources for further study.

Related articles