How to Build Your First zk-SNARK with Circom and SnarkJS: A Step-by-Step Tutorial

How to Build Your First zk-SNARK with Circom and SnarkJS: A Step-by-Step Tutorial
. A circuit with unconstrained signals can prove false statements, and it will still verify.

Here is the trap in miniature. This line assigns the output but adds no constraint:

c <-- a * b;

This line adds the constraint that makes the proof meaningful:

c === a * b;

Beginners often write the first and forget the second. The circuit compiles, the witness calculates, and the proof verifies. It also proves nothing about multiplication. Auditors look for this exact pattern.

Compile the Circuit

Run the compiler:

circom multiplier.circom --r1cs --wasm --sym

You get three useful artifacts:

  • multiplier.r1cs — the constraint system in binary form
  • multiplier_js/ — a WebAssembly witness calculator, including multiplier.wasm
  • multiplier.sym — a map from signal names to indices, invaluable when debugging

Sanity-check the constraint count with snarkjs r1cs info multiplier.r1cs. For this circuit you should see a very small number of constraints and one public output. SnarkJS also reports a constant signal named "1," which surprises people the first time they read the output.

If you add an extra signal and forget to constrain it, the count may still look fine. That is why constraint count is a useful smell test, not a proof of correctness. A circuit can be small and still be wrong.

Run the Trusted Setup

Groth16, the proof system SnarkJS defaults to, needs a proving key derived from a ceremony. For local development you generate one yourself.

Phase 1: powers of tau

The number 12 is the exponent, and it caps the circuit size at roughly 4,096 constraints — plenty for this example.

snarkjs powersoftau new bn128 12 pot12_0000.ptau -v

snarkjs powersoftau contribute pot12_0000.ptau pot12_0001.ptau --name="first" -v -e="some long random text"

snarkjs powersoftau prepare phase2 pot12_0001.ptau pot12_final.ptau -v

Phase 2: circuit-specific keys

snarkjs groth16 setup multiplier.r1cs pot12_final.ptau multiplier_0000.zkey

snarkjs zkey contribute multiplier_0000.zkey multiplier_0001.zkey --name="first" -v -e="more random entropy"

snarkjs zkey export verificationkey multiplier_0001.zkey verification_key.json

For a real deployment, one contributor is not enough. Setup security depends on at least one participant genuinely destroying their randomness, which is why production circuits use multi-party ceremonies with published transcripts.

Think of the trusted setup as a necessary evil for Groth16. If you want to avoid it entirely, you can explore newer proof systems such as PLONK or STARKs, but the Circom and SnarkJS workflow remains a great introduction because it makes every moving part visible.

Generate and Verify a Proof

Write an input file, input.json:

{"a": "3", "b": "11"}

Field elements are strings, not JSON numbers, so the quotation marks matter. Compute the witness:

snarkjs wtns calculate multiplier_js/multiplier.wasm input.json witness.wtns

Then generate the proof:

snarkjs groth16 prove multiplier_0001.zkey witness.wtns proof.json public.json

public.json holds the public output, which should be 33. Verify it:

snarkjs groth16 verify verification_key.json public.json proof.json

A line reading [INFO] snarkjs: OK! means the proof checked out. Change a or b, regenerate the witness and proof, and the public value updates. Now edit public.json by hand and rerun the verify command. It fails, and that is the entire point.

You can also verify without trusting the prover by treating proof.json and public.json as separate inputs. The verifier only needs the verification key and the public statement. The secret inputs never enter the verification step.

Where Proofs Usually Break

Most failures at this stage are not cryptographic. They are operational. Here are the usual suspects:

  • Using a Circom 2 circuit with an old Circom 0.5 binary, or the reverse
  • Mismatched artifacts: proving with one zkey and verifying with a verification key from another setup
  • Field elements written as JSON numbers instead of strings, causing the witness calculator to reject the input
  • Unconstrained signals created with <-- and never checked with ===
  • Editing public.json and expecting the proof to still verify, which it should not
  • Running out of powers-of-tau capacity because the circuit has more constraints than the setup supports

When verification fails, resist the urge to regenerate everything immediately. First inspect the public output. If it differs from what you expect, the witness generation step is the problem. If it matches but verification still fails, the setup or proof files are mismatched.

A proof system does not care that your circuit is conceptually correct. It only cares that the witness satisfies the exact constraint system you compiled.

Debugging with Signals and .sym

The .sym file is the most underrated artifact in the folder. It maps every signal name to its index in the witness. When a constraint fails, SnarkJS may report an index rather than a readable name. Open multiplier.sym, find that index, and you will know which signal is misbehaving.

For a one-line circuit this feels unnecessary. For a circuit with dozens of templates and hundreds of signals, it is the difference between a five-minute fix and an evening of guesswork.

You can also add intermediate signals to inspect values during development:

signal intermediate;

intermediate <== a * b;

c <== intermediate;

This changes nothing mathematically, but it gives you a named checkpoint. When the witness looks wrong, you can isolate whether the problem is upstream or downstream.

Testing Edge Cases and Negative Inputs

The toy circuit works with 3 and 11. Try 0 and 42. Try a very large number near the field modulus. Try negative values if your Circom version and field handling support them. Each case exercises a different part of the arithmetic.

In a real circuit, edge cases are where security bugs hide. A range check that fails for zero, a comparison that breaks at the modulus boundary, or a division that assumes a nonzero denominator can all produce proofs for statements you did not intend to allow.

Write a small shell script or a few commands that run several inputs and verify each proof. It is not a full test suite, but it catches the obvious mistakes before they become expensive.

From Proof to Smart Contract

SnarkJS can export a Solidity verifier:

snarkjs zkey export solidityverifier multiplier_0001.zkey verifier.sol

That contract exposes a verifyProof function. You pass it the proof and the public inputs, and it returns true or false. This is how many on-chain privacy and scaling applications work: the heavy computation happens off-chain, and the chain only verifies a short proof.

The gas cost depends on the proof system and the number of public inputs. Groth16 verification is relatively cheap, but the trusted setup requirement is a real trade-off. For a production system, you would also want to audit the circuit, the ceremony, and the integration code around the verifier.

When to Move Beyond This Toy Circuit

The multiplier circuit is a teaching tool, not a product. The next step is usually one of three directions:

  1. More complex constraints: comparisons, range checks, hash functions, and Merkle proofs
  2. Better developer experience: Circom libraries, Hardhat or Foundry integration, and automated proof tests
  3. Different proof systems: PLONK, Halo2, or STARKs when trusted setups or proving time become the bottleneck

The pipeline stays recognizable. You still write a circuit, compile it, generate a witness, prove, and verify. The details change, but the mental model holds.

Final Checklist Before You Ship

  • Pin the Circom version with a pragma and use the matching compiler
  • Check every <-- assignment for a corresponding === constraint
  • Confirm the constraint count matches your expectations
  • Keep the proving key, verification key, and circuit artifacts in sync
  • Treat the trusted setup as a security-critical step, not a formality
  • Test edge cases, not just the friendly input
  • Verify the proof with the same public inputs a real verifier would see

Build the multiplier, break it, fix it, and verify it again. That loop is the real tutorial. Everything else is scale.

Photo: Pexels / Pixabay

Related Posts