Witnesses, Public Inputs & Constraints — Explained

The three things every proof is made of

Every ZK proof, regardless of which language or proving system you use, involves exactly three categories of data: the witness, the public inputs, and the constraints. Understanding what each one is, who can see it, and what role it plays is foundational to reasoning about ZK application security and correctness.

The witness

The witness is all the private data the prover holds. It is the secret. The thing being proved about without being revealed.

In circuit terms, the witness is the full assignment of values to all private signals in the circuit. This includes not just the top-level private inputs but every intermediate signal computed inside the circuit during witness generation.

Consider a circuit that proves knowledge of a Merkle path:

``` Private inputs (witness): - leaf value - sibling hashes along the path - path direction bits (left or right at each level)

Public inputs: - Merkle root - leaf index

What the proof asserts: "I know a leaf and path such that hashing up the path produces the given root" ```

The verifier learns the Merkle root and leaf index. They learn nothing about the leaf value, the sibling hashes, or the path directions. Those are the witness.

In Circom, the witness is computed outside the circuit by the witness generator:

``bash node generate_witness.js circuit.wasm input.json witness.wtns ``

The input.json contains all signals including private ones. The witness.wtns is a binary file containing the full signal assignment. It is passed to the prover but never to the verifier.

In Noir, the witness is called the execution trace. When you run nargo execute, Noir computes the witness by executing the circuit with your input values and recording the value of every signal at every step.

Public inputs

Public inputs are values known to both the prover and the verifier. They are part of what the proof is asserting. Changing a public input produces a different statement and requires a different proof.

Public inputs appear in the verification step. The verifier checks that the proof is valid for these specific public values. If you submit a proof generated with public input x = 7 but claim x = 8 to the verifier, verification fails.

In Circom, signals are made public by listing them in the main component declaration:

``circom component main { public [root, nullifier] } = MerkleVerifier(20); ``

Any signal not listed in public is private. Outputs are always public regardless of whether they appear in the list.

In Noir, function parameters are private by default. Public inputs are declared explicitly:

``rust fn main( root: pub Field, // public nullifier: pub Field, // public leaf: Field, // private (witness) path: [Field; 20], // private (witness) ) { // circuit logic } ``

The pub keyword marks a parameter as a public input.

Constraints

Constraints are the equations that the witness must satisfy. They define the relationship between signals that the proof system checks. A proof is valid if and only if there exists a witness that satisfies all constraints for the given public inputs.

In R1CS, every constraint has the form:

`` (A · w) * (B · w) = (C · w) ``

Where w is the witness vector and A, B, C are sparse matrices encoding which signals participate in the constraint.

In practice, when you write c <== a * b in Circom, the compiler generates one R1CS constraint. When you write c <== a + b, no new constraint is generated because addition is a linear operation that can be absorbed into existing constraints. This is why circuit developers count multiplication gates, not operations generally.

The relationship between the three

The security of a ZK proof rests on the relationship between these three things being correct.

Soundness requires that no witness can satisfy the constraints for a false statement. If the constraints are under-specified, a malicious prover can find a witness that satisfies them for a computation that did not actually happen correctly.

Completeness requires that an honest prover with a valid witness can always generate a valid proof. If the constraints are over-specified or incorrect, an honest prover may fail to find a satisfying witness even for a true statement.

Zero-knowledge requires that the proof reveals nothing about the witness beyond the fact that it exists and satisfies the constraints. The proof system's cryptographic construction ensures this, but the circuit designer must verify that the public inputs do not inadvertently leak information about private signals.

💡
A constraint system can be sound and complete without being zero-knowledge if poorly designed. For example, if a public output uniquely determines a private input, the verifier can reconstruct the private input from the public output even without seeing the proof. This is a circuit design problem, not a cryptographic failure.

Under-constrained circuits: the critical bug class

The most dangerous class of ZK circuit bug is the under-constrained circuit. The circuit compiles, proofs generate, and verification passes, but the circuit does not actually enforce the computation it claims to enforce.

A simple example in Circom:

```circom template BuggyDivide() { signal input a; signal input b; signal output c;

c <-- a / b; // assigns a/b to c // MISSING: c * b === a; } ```

This circuit computes a / b and assigns the result to c but never constrains the relationship. A malicious prover can set c to any value and generate a valid proof because no constraint checks that c * b === a. The prover could claim 100 / 5 = 99 and produce a valid proof.

The fix is to add the missing constraint:

```circom template CorrectDivide() { signal input a; signal input b; signal output c;

c <-- a / b; c * b === a; // constraint enforces correctness } ```

Now the prover cannot cheat because any value of c that does not satisfy c * b === a will fail the constraint check.

This pattern of <-- assignment followed by === constraint is the canonical Circom pattern for operations that cannot be expressed directly with <==. Anytime you use <--, the corresponding === constraint is required for security.

Signal assignment vs constraint creation

The distinction between <== and <-- in Circom is the most important syntactic detail to internalize.

<== does both: it assigns the value and creates the constraint. It is safe to use when the right-hand side is a quadratic expression in the signals.

<-- only assigns. It is used when the value you need to compute is not expressible as a polynomial constraint in the existing signals. The canonical cases are: field element inversion, bit decomposition, and square root computation.

```circom // Safe: quadratic expression, use <== c <== a * b + d;

// Requires <--: inversion is not a polynomial of a inv <-- 1 / a; inv * a === 1;

// Requires <--: bit decomposition bit <-- (value >> i) & 1; bit * (bit - 1) === 0; // enforce bit is 0 or 1 ```

In Noir, this distinction does not exist at the language level. The compiler handles it. But the underlying constraint system still has this property: some operations generate constraints automatically, others require the compiler to insert auxiliary signals and constraints. Being aware of which operations are expensive in constraints helps you write efficient Noir circuits even without managing them manually.

Witness generation in practice

Witness generation is the step between writing your inputs and generating the proof. It executes the circuit logic to compute the value of every signal.

In Circom this is explicit:

``bash # input.json contains all signals including private node generate_witness.js circuit.wasm input.json witness.wtns ``

In Noir this is integrated into the prove command:

``bash nargo prove # Noir executes the circuit, computes the witness, # and generates the proof in one step ``

Witness generation can fail if your private inputs are inconsistent with your circuit logic. For example, if your circuit expects a valid Merkle path and you provide an invalid one, witness generation will fail with an unsatisfied constraint error before any proving happens. This is the first line of defence against incorrect inputs.

The nullifier pattern

One of the most important patterns in ZK application design uses the relationship between witness and public inputs deliberately.

A nullifier is a value derived deterministically from a secret. You reveal the nullifier as a public input without revealing the secret. The circuit proves that the nullifier was derived correctly from a secret you know, without revealing the secret.

This is how anonymous transactions prevent double spending. The nullifier is public and stored on-chain. The secret remains private. The circuit proves:

`` "I know a secret such that hash(secret) = nullifier AND hash(secret, merkle_root) proves membership" ``

The verifier confirms the proof. The nullifier is recorded. If you try to use the same secret again, the nullifier will be the same, and the contract rejects it as already used.

The secret is the witness. The nullifier and Merkle root are the public inputs. The hash relationship is the constraint.

💡
The nullifier pattern demonstrates the precise value of the public/private distinction. The public output (nullifier) is enough to prevent double-spending without revealing the private input (secret) that would break anonymity. Designing which values are public and which are private is as important as designing the circuit logic itself.

What to check before deploying a circuit

Before any circuit goes to production, three questions must have definitive answers.

Is every signal that carries semantic meaning constrained? An unconstrained signal that affects the output of your circuit is a critical vulnerability. Review every use of <-- in Circom. In Noir, review any use of unsafe or unchecked arithmetic.

Do the public inputs fully specify the statement? If two different computations can produce the same proof for the same public inputs, your circuit is under-specified. The proof proves something weaker than you intend.

Can the witness be uniquely determined from the public inputs and the circuit? If the circuit accepts multiple witnesses for the same public inputs and not all of them represent valid computations, soundness may be compromised.

These questions are the starting point for a circuit audit. They apply to Circom and Noir equally.

Answer the quiz correctly to continue →

Quiz · Multiple Choice1 / 3

In R1CS, why does `c <== a + b` in Circom not generate a new constraint while `c <== a * b` does?