Lan is a backend developer in Ho Chi Minh City who joined a ZK workshop at a hacker house in District 1. The instructor draws two boxes on the whiteboard: one labeled "prover," one labeled "verifier." Between them, a single line: y = x * x. "Prove to the verifier that you know an x whose square is y — without telling them what x is," he says. "Tonight, you will write that circuit in Circom." Lan writes her first template, compiles it, generates a proof, and verifies it — all in about thirty minutes on her laptop. This circom tutorial walks through exactly that journey: what a circuit is, how to viết zk circuit đầu tiên (write your first ZK circuit), why the compiler complains, and what the resulting r1cs zk file actually means. By the end you will have a working proof for x² = y running in your terminal — the smallest possible real zero-knowledge proof.
The honest headline is that zero-knowledge proofs are not magic. They are arithmetic: a computation turned into equations, where the prover shows they know a secret solution to those equations without revealing it. Circom is the language that makes this practical for developers. It is the standard circuit language of the ZK ecosystem — used by Tornado Cash for private transactions and by hundreds of production projects — and it compiles your circuit into an R1CS (Rank-1 Constraint System) plus a witness generator you can run locally.
What a ZK circuit actually is
A circuit in the ZK sense has nothing to do with electrons. It is a program that only does arithmetic — addition and multiplication — over signals, which are numbers in a big finite field (on the default bn128 curve, roughly 254-bit numbers). The circuit takes input signals, computes intermediate signals, and produces output signals. What makes it special is that it also produces constraints: equations the prover must satisfy.
The classical way to explain a constraint is with a story. You want to prove to your friend that you know the factorization of a number n, without revealing the factors. That is exactly the statement Circom's canonical first example encodes: a template named Multiplier2 that declares two private inputs and one output, then writes one constraint, c <== a b. Our version is even smaller — a single input x, a single output y, and the constraint y <== x x.
Viết zk circuit đầu tiên: your first template
Open a terminal, create a folder, and save this as circuit.circom:
pragma circom 2.0.0;
template XSquare() { signal input x; // private by default signal output y; // outputs are always public
y <== x * x; // assign the value AND add the constraint }
component main = XSquare();
Three things are happening here.
First, pragma circom 2.0.0 pins the compiler version, like a package.json version field. The compiler warns if your installed version does not match, which prevents silently different behavior on someone else's machine.
Second, the template is a blueprint, not a circuit. Like a class in Java or a function in Solidity, a template describes a shape; you instantiate it with component main = XSquare();. Every circuit file must declare a main component — that is what the compiler compiles.
Third, the signals. signal input x is a number that flows into the circuit. Inputs are private by default — that is where the zero-knowledge hiding lives. signal output y is the result, and outputs of main are always public, because the verifier needs them to know what statement is being proved. In the middle, a signal with neither input nor output is called an intermediate signal — a helper value computed along the way. In our one-line circuit we have no intermediates, but the moment you write y <== x x x you will meet one (more on that below).
The heart of the file is the operator <==. It does two jobs at once: it assigns the signal y the value x x (so the witness generator can compute it), and it adds a constraint y === x x to the R1CS. Circom ships a second operator, ===, that adds a constraint without assigning — useful when the value was already set — and a pair of "single-arrow" operators, <-- and -->, that assign without constraining. Those single arrows are the most dangerous tools in the language, and we will come back to them in the pitfalls section. For now, remember: <== is the safe default because the assigned value is the only solution to the constraint.
Figure 1: The anatomy of a Circom circuit — three signals and one constraint are the whole R1CS for x² = y
The quadratic-only rule: why x x x fails
Compile what you just wrote and you will see one constraint. Now change the body to y <== x x x and compile again — Circom refuses: "Non quadratic constraints are not allowed!" This error is the single most common first-day shock for new circom developers, and it is worth understanding instead of fighting it.
Every constraint in an R1CS must have the shape linear × linear = linear, where "linear" means a sum of signals with constant coefficients. x x is quadratic — it is allowed. x x x is cubic — it is not, because three factors cannot be split into exactly two linear expressions. Division and powers are banned for the same reason: y <== x / 2 and y <== x * 3 are not quadratic either.
The solution is to break the computation into gates, exactly the way a hardware engineer breaks a circuit into chips. To compute x³, you create an intermediate signal:
signal sq;
sq <== x * x; // constraint 1
y <== sq * x; // constraint 2
Now you have two quadratic constraints, and the circuit compiles. Notice what this reveals: the number of constraints is the real "size" of your circuit. Every multiplication gate adds one; additions fold in for free; and both proving time and proving-key size grow linearly with constraint count. That is the mental metric that matters. A developer who internalizes "one multiplication per constraint" stops fighting the compiler and starts designing circuits the way the machine expects — which is also how you write fast, cheap ZK circuits.
Installing the tools: circom and snarkjs
Circom compiles circuits; snarkjs turns the compiled output into proofs. Install both with Node.js:
npm install -g circom
npm install -g snarkjs
Compile your circuit:
circom circuit.circom --r1cs --wasm --sym
Three files come out. circuit.r1cs is the binary R1CS — the constraint system itself, which will be fed into the trusted setup. circuit.wasm is a WebAssembly witness generator: a small program that runs your circuit on a given input and records every intermediate signal. circuit.sym is a symbol map used for debugging, much like -g debug symbols.
Before going further, ask the compiler what it produced:
snarkjs r1cs info circuit.r1cs
The output tells you the curve (bn-128), the number of wires, the number of constraints, and how many inputs are private. For XSquare you get a single constraint. Your entire zero-knowledge statement is one equation. Everything downstream — witness, proving key, proof — scales from that number.
Computing the witness: the receipt of the computation
A proof is not built from thin air. First you must run the circuit on a concrete input and collect the witness — the assignment of every signal that satisfies the constraints. The wasm file does this. Write an input file and execute the generator:
echo '{"x": 9}' > input.json
cd circuit_js
node generate_witness.js circuit.wasm ../input.json ../witness.wtns
witness.wtns is a binary transcript: x = 9, y = 81, and the constant 1 that R1CS always carries. You can inspect it:
cd ..
snarkjs wtns export json witness.wtns witness.json
The witness is the prover's receipt — it proves the computation was actually done. It is also the secret: anyone holding the witness knows x. The whole point of the proof step is to convince a verifier of the witness's existence without sending the witness itself.
Trusted setup and the Groth16 pipeline
Before any proof can be generated, the circuit needs keys, and Groth16 — the most widely deployed proving system, and the one Tornado Cash uses — requires a trusted setup: a ceremony where a secret "toxic waste" value is mixed into the parameters. The ceremony is two phases. Phase 1, the powers of tau, is universal (shared by every circuit on the curve). Phase 2 is per-circuit.
snarkjs powersoftau new bn128 12 pot12_0000.ptau
snarkjs powersoftau contribute pot12_0000.ptau pot12_0001.ptau --name="First" -v
snarkjs powersoftau prepare phase2 pot12_0001.ptau pot12_final.ptau
snarkjs groth16 setup circuit.r1cs pot12_final.ptau circuit_0000.zkey
snarkjs zkey contribute circuit_0000.zkey circuit_final.zkey --name="Second" -v
snarkjs zkey export verificationkey circuit_final.zkey verification_key.json
For a tutorial this is fine to run alone, but in production the ceremony is a multi-party computation: dozens of independent contributors each add randomness, and the parameters are only safe if at least one contributor destroys their secret. The contribute step is what makes the difference between "works" and "secure."
Now generate and verify a proof:
snarkjs groth16 prove circuit_final.zkey witness.wtns proof.json public.json
snarkjs groth16 verify verification_key.json public.json proof.json
Two files come out of prove. proof.json is the actual zk-SNARK proof — for Groth16, exactly three elliptic-curve points, about 192 bytes of data. public.json holds the public signals, which for our circuit is just y = 81. The verifier reads the proof and the public inputs, checks three pairing equations, and returns OK in milliseconds — verification cost does not depend on the circuit's size at all. The prover, by contrast, pays for every constraint.
To make this real on a blockchain, export a Solidity verifier, deploy it, and call its verifyProof function — the same pattern used by Tornado Cash's withdrawal contract:
snarkjs zkey export solidityverifier circuit_final.zkey verifier.sol
Figure 2: From circuit to verified proof — circom compiles, snarkjs proves, the contract verifies
R1CS zk: what the compiler really produced
Here is the moment where the "zero knowledge" becomes concrete. Circom has turned y <== x * x into a Rank-1 Constraint System, and an R1CS is just three matrices A, B, C and a witness vector s such that, for every constraint row,
⟨aᵢ, s⟩ · ⟨bᵢ, s⟩ = ⟨cᵢ, s⟩
That is: take the dot product of row aᵢ with the witness, multiply it by the dot product of row bᵢ with the witness, and the result must equal the dot product of row cᵢ with the witness.
For x² = y, the witness is tiny — three entries: s = [1, x, y]. The constant 1 is always present by convention, so the equations can express "equals this exact value" rather than just "equals zero." Our single constraint is then encoded by three rows:
A = [0, 1, 0] → ⟨A, s⟩ = x
B = [0, 1, 0] → ⟨B, s⟩ = x
C = [0, 0, 1] → ⟨C, s⟩ = y
Multiplying the first two results: x · x = y. That is the entire constraint system, and the witness [1, 9, 81] satisfies it. A dishonest prover who tried s = [1, 9, 82] would produce 9 · 9 = 82, which is false — the proof would be rejected.
This is why you should read the --r1cs output as the literal contract of your circuit. Every === or <== you write becomes one row in these matrices. The size of a ZK system — its proving time, its key size, its gas cost — is measured in these rows. When a project says "our circuit has 5,000 constraints," it means the A, B and C matrices have 5,000 rows that every prover must work through.
Figure 3: One constraint as matrices — every === in circom becomes one row of the R1CS
" (value tone). Row 2 (3 cols): "A row = [0, 1, 0] → ⟨A,s⟩ = x" (neutral) → "B row = [0, 1, 0] → ⟨B,s⟩ = x" (neutral) → "C row = [0, 0, 1] → ⟨C,s⟩ = y" (neutral). Row 3: full-width equation band "⟨A,s⟩ · ⟨B,s⟩ = x · x = y = ⟨C,s⟩ ✓" (highlight tone). Bottom callout (neutral tone): "every === in circom becomes one row of these matrices — proving time scales with the row count". Title: "R1CS zk: One Constraint as Matrices", subtitle "x² = y — the smallest possible real zero-knowledge proof"]
What makes it zero-knowledge
Now take a step back and see the whole picture. The prover holds the witness [1, 9, 81] — and crucially, the private input x = 9. The verifier holds only y = 81 and the proof. During proving, the zk-SNARK "blinds" the private parts of the witness by mixing in random field elements, so the three elliptic-curve points in proof.json carry no information about x. You can change x from 9 to 5 and watch public.json update while proof.json reveals nothing that would let an observer reconstruct x. That is the definition of the property: the verifier learns that some x with x² = y is known to the prover, and nothing else.
Two safety properties come along for the ride. Soundness means a prover cannot fabricate a proof for a false statement (it would need to break the elliptic-curve discrete log — computationally infeasible). Completeness means an honest prover's proof always verifies. Together with zero-knowledge, these three properties are what make a zk-SNARK trustworthy enough for a contract holding millions of dollars, which is exactly what Tornado Cash does: its withdrawal circuit proves knowledge of a secret and a nullifier tied to a Merkle-tree commitment, and the verifier contract only ever sees the proof and the public values.
Common pitfalls: three mistakes that silently break circuits
The ZK ecosystem has its share of "it worked in the demo" horror stories, and the failures cluster around a few patterns. The first is using <-- instead of <==. A single-arrow assignment tells the witness generator what value to compute, but writes no constraint. If a developer writes y <-- x x and forgets the matching y === x x, then the R1CS does not actually enforce anything — a malicious prover can set y to any value they like and still produce a proof, because the constraint row never existed. A security study of real circuits found exactly this class of bug in production projects: unconstrained signals that let bogus witnesses pass verification. The compiler's warnings exist to stop this.
The second pitfall is declaring inputs public when they should be private. Inputs are private by default, but a developer can write component main { public [x] } = XSquare();, and then x appears in public.json — the "zero knowledge" quietly becomes "revealed knowledge." For x² = y, publishing x makes the proof pointless, because the verifier could just square x themselves. Keep public only for values the verifier is supposed to see.
The third pitfall is forgetting that signals are immutable. Assign y twice and the compiler refuses. This trips up developers who write imperative-style loops that reassign a signal — the fix is to use a mutable var for the loop counter and reserve signals for the final values.
Scaling up: from one constraint to real circuits
The x² = y circuit is the smallest possible proof, but the exact same pipeline — compile, witness, setup, prove, verify — runs circuits with thousands of constraints. The numbers scale predictably: a Groth16 proof is always about 192 bytes and always verifies in a few milliseconds, but proving time and proving-key size grow with the constraint count (a million-constraint circuit needs roughly a 500 MB proving key). That is why circuit optimization — packing a computation into as few multiplication gates as possible — is a core skill for ZK engineers. A Poseidon hash, for example, is deliberately designed to be cheap in constraints, which is why privacy protocols reach for it over SHA-256.
For a Vietnamese developer, this skill is directly on-ramped by the local ecosystem. ETH Vietnam and the Vietnam Blockchain Week hacker houses run ZK tracks; Ho Chi Minh City hosted a dedicated ZK hacker house in 2025 where 18 builders shipped 13 privacy-focused projects — private transactions, stealth addresses, and ML-to-circuit demos. The point is that you do not need special hardware, a GPU, or funding to enter this field: a laptop, Node.js, and this one-constraint circuit are enough to get from zero to a verified proof, and the next steps — a Poseidon hash, a Merkle proof, a Semaphore-style anonymous signal — are all documented extensions of the same template. ZK is one of the few technical tracks in crypto where the binding constraint is understanding, not capital.
Frequently asked questions
Why does Circom reject y <== x x x?
Because every R1CS constraint must be quadratic — of the form linear × linear = linear. x x is quadratic; x x x is cubic. Fix it by adding an intermediate signal: sq <== x x; y <== sq * x; now two quadratic constraints, and the circuit compiles.
What is the difference between <==, ===, and <--?
<== assigns a value and adds a constraint (safe, default). === adds a constraint without assigning. <-- (and -->) assigns without constraining — use them only when the value cannot be expressed quadratically, and immediately follow with a === that ties the value down. An assignment without a matching constraint is the classic way to produce a broken circuit.
Is Groth16 the only proving system in snarkjs?
No. snarkjs also supports PLONK, which needs only the universal powers-of-tau ceremony and skips the per-circuit phase 2 — at the cost of larger proofs. Groth16 remains the default choice for on-chain verification because its proofs are the smallest (three group points, ~192 bytes) and verification is a few milliseconds.How big is the witness for my circuit?
Exactly the number of signals plus the constant1. For XSquare that is [1, x, y] — three entries. Every intermediate signal you add makes the witness longer, and the witness length (not the constraint count) bounds the private data the prover must hide.
Do I need a trusted setup for a test?
No. For testing, onecontribute is enough. For production, the ceremony must include many independent contributors and a verifiable transcript — and note that PLONK avoids the per-circuit ceremony entirely. Treat any zkey with zero contributions as an unsafe toy.
Where can I go next after x² = y?
Three natural steps, all in circom's ecosystem: hash a value with a Poseidon template (cheap in constraints), prove a Merkle-tree membership (the basis of private pools and anonymous voting), and deploy a Solidity verifier on a testnet. Each builds on the same compile → witness → setup → prove → verify pipeline you just ran.Conclusion
Lan's thirty minutes covered the entire arc of practical ZK: a template, one quadratic constraint, a witness, a trusted setup, and a Groth16 proof that a verifier checked in milliseconds. The trick that made it all possible is smaller than most people expect — a single equation, y <== x * x, that became one row of the r1cs zk matrices. Everything else — the wasm witness generator, the powers-of-tau ceremony, the 192-byte proof — is machinery that exists to make that one constraint usable in the real world. Viết zk circuit đầu tiên is the fastest path into the field, because it is the same path a production protocol walks, just with a constraint count of one. The compiler, the ceremony, and the verifier contract will all scale up the same way; only the row count changes. ZK is a field where the barrier to entry is genuinely low and the demand for competent circuit writers is genuinely high — and you now hold the receipt for the smallest real proof you can build. The next constraint is up to you.
Quick Reference
| Step | Command | What it produces |
|---|---|---|
| Write the circuit | template XSquare() { signal input x; signal output y; y <== x * x; } | 1 constraint: y = x·x |
| Compile | circom circuit.circom --r1cs --wasm --sym | circuit.r1cs, circuit.wasm, circuit.sym |
| Inspect | snarkjs r1cs info circuit.r1cs | curve, #wires, #constraints, #private inputs |
| Witness | node generate_witness.js circuit.wasm input.json witness.wtns | full signal assignment (the receipt) |
| Setup phase 1 | snarkjs powersoftau new bn128 12 ... + contribute + prepare phase2 | pot12_final.ptau (universal) |
| Setup phase 2 | snarkjs groth16 setup + zkey contribute + zkey export verificationkey | circuit_final.zkey, verification_key.json |
| Prove | snarkjs groth16 prove circuit_final.zkey witness.wtns proof.json public.json | proof.json (~192 bytes), public.json |
| Verify | snarkjs groth16 verify verification_key.json public.json proof.json | OK in milliseconds |
| Deploy | snarkjs zkey export solidityverifier circuit_final.zkey verifier.sol | Solidity verifier contract |
| Privacy default | component main = XSquare(); | x private, y public |
| Safe operators | <== (assign + constrain), === (constrain) | constraint rows in R1CS |
| Dangerous operator | <-- (assign only) | no constraint — broken circuits live here |
| Sizing rule | one multiplication per constraint | proving time & key size grow with row count |
