ZK App Architecture & Intro to Circom

This flow is fixed regardless of which proof system you use. What changes between Circom, Noir, RISC Zero, and SP1 is how you express the circuit layer and which toolchain handles the prover and verifier.

What Circom is

Circom is a domain-specific language for writing arithmetic circuits that compile to R1CS. It was developed by the iden3 team at the Ethereum Foundation and is the most widely used circuit language for Groth16 and PLONK-based ZK applications.

Circom is not a general-purpose programming language. You cannot write arbitrary logic and expect it to compile to an efficient circuit. You write constraints. The compiler checks that your constraints are satisfiable and generates the R1CS representation that a proving system like snarkjs can consume.

The toolchain that surrounds Circom:

circom is the compiler. It takes .circom source files and outputs .r1cs, .wasm, and .sym files. The .r1cs file encodes the constraint system. The .wasm file is used to compute the witness.

snarkjs is the JavaScript library that handles trusted setup, proof generation, and verification. It consumes the .r1cs and witness files to produce a Groth16 or PLONK proof.

circomlib is a standard library of battle-tested circuit templates including hash functions, comparison operators, binary arithmetic, elliptic curve operations, and Merkle tree verifiers. You import from circomlib rather than building these from scratch.

Circom syntax fundamentals

A Circom program is a set of templates. A template is a parameterizable circuit definition, similar to a function in a regular language but one that generates constraints rather than computing values.

Signals are the variables. Every input, output, and intermediate value is a signal. Signals are field elements, not integers in the conventional sense.

```circom pragma circom 2.0.0;

template Example() { signal input a; // private by default signal input b; // private by default signal output c; // output is public

c <== a * b; }

component main { public [c] } = Example(); ```

The `<==` operator simultaneously assigns a value and creates a constraint. c <== a * b means two things: assign a * b to c, and constrain that c === a * b. Both happen at compile time in terms of constraint generation.

The `===` operator creates a constraint without assignment. It asserts that two expressions are equal.

The `<--` operator assigns a value without creating a constraint. This is used for intermediate computations where you compute a value and then separately constrain it. Using <-- without a corresponding === is an unconstrained signal, which is a security vulnerability because the prover can set it to any value.

Unconstrained signals are one of the most common security bugs in Circom circuits. If you use `<--` to assign a value, you must always add a `===` constraint to verify it. An unconstrained signal means a malicious prover can set it to anything and still produce a valid proof.

Templates and components

Templates are reusable circuit definitions. Components instantiate templates and wire their signals together.

```circom pragma circom 2.0.0;

template IsZero() { signal input in; signal output out; signal inv;

inv <-- in != 0 ? 1/in : 0; out <== -in inv + 1; in out === 0; }

template CheckEqual() { signal input a; signal input b; signal output equal;

component isZero = IsZero(); isZero.in <== a - b; equal <== isZero.out; }

component main { public [a, b] } = CheckEqual(); ```

IsZero checks whether its input is zero. It uses a pattern common in Circom: compute an intermediate value outside the constraint system with <--, then constrain its relationship to other signals with ===.

CheckEqual instantiates IsZero as a component and wires signals into it. The output equal is 1 if a === b and 0 otherwise.

This composability is how complex circuits are built. The circomlib library provides templates for Poseidon hash, SHA256, EdDSA signature verification, Merkle proofs, and more. You compose them rather than implementing cryptographic primitives from scratch.

The compilation pipeline

Given a Circom source file:

```bash # Compile the circuit circom circuit.circom --r1cs --wasm --sym

# Generate witness (given input.json with private + public inputs) node generate_witness.js circuit.wasm input.json witness.wtns

# Trusted setup (Groth16 — circuit specific) snarkjs groth16 setup circuit.r1cs pot12_final.ptau circuit_final.zkey

# Export verification key snarkjs zkey export verificationkey circuit_final.zkey vkey.json

# Generate proof snarkjs groth16 prove circuit_final.zkey witness.wtns proof.json public.json

# Verify proof snarkjs groth16 verify vkey.json public.json proof.json ```

The input.json contains both private and public inputs. The witness.wtns is the full assignment of all signals in the circuit. The proof.json is the Groth16 proof. The public.json contains only the public signals. The verifier receives vkey.json, public.json, and proof.json and has no access to the private inputs or the full witness.

What public vs private means in practice

In the component main { public [c] } = Example() declaration, any signal listed in the public array is a public input. Any signal not listed is private. Outputs are always public.

In the input.json you provide to the witness generator, you include all signals including private ones. The witness generator computes the full circuit assignment. snarkjs then generates a proof that the witness satisfies all constraints, and the public.json output contains only the signals marked public.

The verifier checks the proof against the public signals only. The private signals never appear outside the prover's machine.

Constraint count and why it matters

Every multiplication gate in your circuit generates one R1CS constraint. Addition gates and linear operations are free. This asymmetry shapes how you write Circom circuits.

When you import a Poseidon hash from circomlib, you are adding roughly 300 constraints per hash call. When you import SHA256, you are adding roughly 30,000 constraints because SHA256 relies heavily on bitwise operations that are expensive in field arithmetic.

The number of constraints determines proving time, the size of the proving key, and indirectly the verification cost. Checking your constraint count before and after adding components is standard practice in Circom development.

``bash # Check constraint count after compilation snarkjs r1cs info circuit.r1cs # Output shows: # of wires, # of constraints, # of labels ``

What Circom is not suited for

Circom is the right tool when you need precise control over constraints, are targeting Groth16 or PLONK, and can express your computation efficiently in field arithmetic.

It becomes the wrong tool when your computation involves extensive branching, dynamic array access, or operations that do not map naturally to polynomial constraints. Hash-heavy computations using SHA256 or Keccak are expensive in Circom. If you need to prove execution of arbitrary Rust code, RISC Zero or SP1 are more appropriate. If you want a higher-level language that still compiles to arithmetic circuits, Noir is worth evaluating.

Circom rewards developers who think in constraints. The more precisely you understand what your circuit needs to express, the more efficient the circuit you can write.

Answer the quiz correctly to continue →

Quiz · Multiple Choice1 / 3

What is Circom primarily used for in ZK development?