Smart Contractsintermediatearticle40 min read

SnarkJS Tutorial: Groth16 Workflow from R1CS to Proof and Verify

A snarkjs tutorial that walks the groth16 workflow from R1CS to proof and verify: circom compiles the circuit, powers of tau and zkey build the keys, snarkjs proves, and Solidity or Node verifies in milliseconds.

Vietcardano teamTeam

Đội ngũ biên tập VietCardanoCommunity — các chuyên gia blockchain và Cardano, cung cấp nội dung giáo dục và nghiên cứu chuyên sâu cho cộng đồng.

Published August 31, 2026Updated August 30, 2026

Minh is a backend developer in Hanoi who has shipped Node.js APIs for five years. A client asks for a zero-knowledge credential check: prove that a user is on an allowlist without sending the list, or prove that an account balance exceeds a threshold without revealing the number. This snarkjs tutorial walks the exact path he takes -- from a circuit description to a verified proof -- using the tool that has become the default for JavaScript developers. By the end you will understand what R1CS and the witness actually are, why the groth16 workflow is split into two setup ceremonies, and how to run a zk proof nodejs end to end: circom compiles the circuit, snarkjs builds the keys, proves the statement, and a tiny Solidity contract -- or a Node script -- verifies it in milliseconds.

Every proof that leaves your machine goes through the same six artifacts: a .r1cs constraint file, a .wasm witness calculator, a witness.wtns, a ptau file from the universal ceremony, a circuit-specific zkey, and the final proof.json with its matching verification_key.json. This article maps each artifact to the command that produces it, so the ceremony steps stop feeling like magic and start feeling like bookkeeping.

SnarkJS pipeline: circuit.circom → compile (.r1cs + .wasm) → witness.wtns in row 1, trusted setup (zkey) → prove (proof.json) → verify in row 2, six commands producing six artifacts

Figure 1: The snarkjs pipeline — every Groth16 proof travels the same six-step path from circuit to verified proof

What R1CS and the witness actually are

Before the tooling, the concept. A zk-SNARK does not verify "the computation" -- it verifies that a witness (the private input plus every intermediate value the computation produced) satisfies a set of equations called a constraint system. When you write a Circom circuit, the compiler turns your signal declarations and == checks into a Rank-1 Constraint System (R1CS): a list of equations of the form A * B = C, where A, B and C are linear combinations of the wires, evaluated in a huge prime field. Everything a circuit can express -- a hash, a Merkle proof, a comparison, a signature check -- becomes hundreds or thousands of these single multiplication constraints.

The canonical example is the multiplier. A circuit with component main { public [c] }, two private signals a and b, and one public signal c constrained to a * b === c compiles to one R1CS constraint. The witness is the concrete assignment {a: 3, b: 11, c: 33}. To prove "I know two factors of 33" you compute the witness from your private inputs, keep a and b secret, publish c, and generate a proof that the constraint holds for your witness. The verifier learns c = 33 and that you know a satisfying assignment -- and nothing else.

Two numbers are worth remembering. First, constraint count is the price of a circuit: a simple SHA-256 over one block costs roughly 25,000-30,000 constraints, which is why tutorial circuits use Poseidon instead of SHA-256. Second, the R1CS file is a binary; snarkjs r1cs info tells you its constraint count and snarkjs r1cs export json lets you read it. Run both on your first circuit -- seeing your program become equations is the moment the mental model clicks.

The groth16 workflow: from circuit to a verified proof

The groth16 workflow is the default proving system in snarkjs and the one most tutorials use, because its proofs are tiny and its verification is constant-cost. Groth16 requires a trusted setup per circuit, and that setup is deliberately split into two phases so that most of the work can be shared. Phase 1 is the powers of tau ceremony: universal, circuit-independent, run once for an entire ecosystem. Phase 2 is circuit-specific: it binds the universal parameters to your particular .r1cs. The output of both phases is a .zkey file containing the proving key, the verification key, and a record of every contribution made to the ceremony.

The whole flow looks like this. You compile the circuit (circom), compute a witness from your inputs, create or reuse a ptau, run snarkjs groth16 setup against your .r1cs, contribute randomness to the resulting zkey, then prove and verify. In one line of intent: constraints in, proof out, truth checked.

Phase 1: the powers of tau ceremony

snarkjs powersoftau new bn128 14 pot14_0000.ptau starts a ceremony that can support circuits up to 2^14 = 16,384 constraints; the maximum power is 28, which covers roughly 268 million constraints. Every participant runs snarkjs powersoftau contribute, which mixes their entropy into the transcript, then a final beacon derives a public, verifiable random contribution from a source like a blockchain hash, and snarkjs powersoftau prepare phase2 produces the pot_final.ptau you actually feed to groth16 setup.

The critical fact for a small team: you almost never need to run phase 1 yourself. Because the ceremony is universal, the ecosystem maintains shared public ptau files (the common "power of 28" file used in most tutorials comes from exactly this practice), and projects fork a round of the same ceremony -- the model called the perpetual powers of tau. The Zcash Sapling ceremony was the first large use of this protocol; Aztec, Filecoin, Semaphore, Loopring, Tornado Cash and others followed. For a production circuit you still run your own phase 2 -- that part is genuinely yours -- but the expensive, security-critical phase 1 is a community good.

Phase 2: the circuit-specific zkey

snarkjs groth16 setup circuit.r1cs pot_final.ptau circuit_0000.zkey creates the reference zkey. The snarkjs documentation is explicit: do not use this file in production. It has zero contributions, which means a single malicious setup operator could forge proofs. The security of the whole system rests on the rule "at least one honest contributor": as long as one participant keeps their secret randomness safe, the final parameters are sound. So you call snarkjs zkey contribute at least once (ideally several times, from different people, ideally from air-gapped machines), then snarkjs zkey beacon to add public randomness, then snarkjs zkey verify to confirm every contribution chained correctly. Finally snarkjs zkey export verificationkey circuit_final.zkey verification_key.json gives the small public file that everyone can verify against.

Groth16 proving phases: Phase 1 powers of tau (universal, one-time) and Phase 2 zkey ceremony (circuit-specific) each run a contributor chain and end in a shared output, pot_final.ptau and circuit_final.zkey

Figure 2: The two groth16 setup ceremonies — phase 1 is universal and shared, phase 2 binds the parameters to your circuit

Proving and verifying in Node.js: the zk proof nodejs walkthrough

With the keys in place, the actual proving is the shortest step in the whole pipeline. Install snarkjs (npm install snarkjs), and the entire zk proof nodejs experience collapses to two function calls. This is the part Minh actually ships.

`javascript const snarkjs = require("snarkjs"); const fs = require("fs");

async function main() { // 1. Prove: witness is computed from your (private) input, // the .wasm file, and the proving key embedded in the zkey. const { proof, publicSignals } = await snarkjs.groth16.fullProve( { a: 3, b: 11 }, // private + public inputs "circuit.wasm", // from circom --wasm "circuit_final.zkey" // proving key );

// 2. Verify locally with the verification key. const vKey = JSON.parse(fs.readFileSync("verification_key.json", "utf8")); const ok = await snarkjs.groth16.verify(vKey, publicSignals, proof); console.log(ok ? "Verification OK" : "Invalid proof"); } main(); `

fullProve does witness calculation and proof generation in one step, so you never touch witness.wtns from code unless you need it for debugging. The function returns the proof (a Groth16 proof is three points -- pi_a, pi_b, pi_c -- and is always the same size, about 512 bytes, no matter how large the circuit) and the public signals (the public inputs and outputs, e.g. c = 33). verify takes the verification key, the public signals, and the proof, and returns a plain boolean. Flip one bit in the proof and it returns false -- that one-line test is the fastest way to feel what a proof actually binds.

The same two calls run in the browser. Because snarkjs is a pure JavaScript + WebAssembly implementation, fullProve can run on the client, which is exactly what private-credential apps want: the witness (the secret) never leaves the device; only the proof travels to your API. That is the architectural reason zk proof nodejs and browser-side proving are the same code path -- there is nothing server-side about the prover unless you choose to put it there.

Verifying on-chain: from snarkjs to Solidity

The reason a proof is "succinct" becomes concrete the moment you put the verifier on a blockchain. One command -- snarkjs zkey export solidityverifier circuit_final.zkey verifier.sol -- generates a Solidity file containing two contracts: a Pairing library and a Verifier. You deploy only Verifier, and call its verifyProof view function with the proof and the public signals. The function returns true if and only if the proof is valid for those exact public values. snarkjs generatecall prints the properly formatted calldata so you can paste it into Remix, or wire the contract into a Hardhat test:

`javascript const verifier = await ethers.getContractFactory("Verifier"); const v = await verifier.deploy(); // proof + publicSignals from snarkjs.groth16.fullProve expect(await v.verifyProof(proof.pi_a, proof.pi_b, proof.pi_c, publicSignals)) .to.equal(true); `

This is where the prover/verifier asymmetry pays off. Proving runs on your hardware and scales with the circuit; verification is a fixed, tiny computation. A 2025 benchmark of circom-snarkjs circuits measured witness generation at roughly 58 ms, proof generation between 832 ms and 1.15 s, and verification between 741 ms and 884 ms -- with verification time almost completely independent of constraint count. On-chain that translates to a few hundred thousand gas per verification, regardless of whether your circuit has 100 or 1,000,000 constraints. Your server does the heavy math once per user; the contract re-checks the result cheaply on every submission.

Prover vs verifier comparison: the Node.js prover runs groth16.fullProve with the full zkey (heavy, once), the on-chain verifier checks 2-3 pairings with only the verification key (cheap, every time)

Figure 3: Prover vs verifier — the prover works hard once, the verifier checks cheaply every time

Common mistakes and how to debug them

Most first-time failures are not cryptographic -- they are file-lifecycle mistakes. These four are the ones Minh will hit, and each has a one-line diagnosis:

  1. "zkey is not safe / has no contributions." You skipped phase 2 contributions. Run zkey contribute at least once and zkey verify before exporting anything.
  2. groth16 setup errors about the ptau power. The ptau power must be large enough for your constraint count. snarkjs r1cs info circuit.r1cs prints the constraint count; pick a power of two above it (power 14 covers 16,384; power 20 covers 1,048,576).
  3. Proof verifies locally but not on-chain. The on-chain verifier and the Node verify check the same Groth16 statement, so the usual cause is mismatched artifacts -- you exported verifier.sol from a different zkey than the one you proved with, or you deployed an old Verifier after a ceremony update. Re-export and redeploy together.
  4. fullProve hangs on large circuits. WASM proving is memory-hungry; for circuits above ~100k constraints consider the snarkjs C++ witness calculator or a native prover, and always warm the zkey in memory once per process rather than re-reading it per request.

None of these are cryptography failing. The ceremony and the file chain are the only parts of the workflow that are easy to get wrong, which is why the diagrams in this article treat them as first-class steps.

What this means for Vietnamese developers

Vietnam has quietly built one of the more organised ZK developer communities in Southeast Asia. ZKP Labs -- a non-profit "ZKP hub" in the region -- runs workshops, conferences and a training track aimed at producing engineers who can contribute to proof systems rather than only call libraries; it co-organised the Vietnam Rust Hackathon, whose winning team built a CKB zk-rollup and went on to showcase it internationally. Vietnamese engineers currently work on ZK teams at ZKM, GOAT Network and Untangled.Finance, and local teams have won Chainlink hackathon tracks with privacy DAO and prediction-market projects. The thread connecting all of them is that the entry point was a tool like snarkjs in a language they already knew.

That matters because the most common Vietnamese web stack -- Node.js on the backend, React or Vue on the frontend -- maps directly onto the snarkjs story: the same npm install snarkjs gives you fullProve in your API and verify in the browser or in a Verifier.sol contract deployed from Hardhat. The realistic first projects are the boring-but-valuable ones: private allowlist membership (an exclusive NFT club or airdrop without leaking who holds it), proof-of-account-balance for lending, or verifiable age/region checks for a loyalty platform. None of these needs a bespoke proving system -- they need a well-run phase 2 ceremony, a careful witness, and the discipline to verify artifacts, which is 80% of the job.

Frequently asked questions

Do I have to run the powers of tau ceremony myself?

No. Phase 1 is universal and shared; production projects reuse a publicly maintained ptau file (such as the common power-28 file) or fork a round of the perpetual powers of tau ceremony. You do need to run the circuit-specific phase 2 -- groth16 setup plus at least one zkey contribute and a beacon -- because that is what binds the universal parameters to your .r1cs.

What is the difference between Groth16, PLONK and FFLONK in snarkjs?

Groth16 gives the smallest proofs and cheapest verification but requires a per-circuit trusted setup. PLONK and FFLONK drop the circuit-specific ceremony -- the universal powers of tau is enough -- at the cost of larger verification work (and, for PLONK, a larger proof). For a tutorial, Groth16 is the right default; for a project that ships many circuits, PLONK or FFLONK removes the ceremony maintenance burden.

Why is the proof always the same size no matter how big the circuit?

Groth16 proofs are three curve points (one on G2, two on G1), and that count does not depend on the circuit. The benchmark study confirmed a constant 512-byte proof across circuits of very different sizes. What grows with the circuit is the proving key and the proving time -- never the proof.

How do I run a zk proof nodejs verification without a blockchain?

Use snarkjs.groth16.verify(vKey, publicSignals, proof) directly in your Node process; it returns a boolean and needs no contract. This is ideal for server-side checks where "trusted server" is acceptable. Only reach for the Solidity verifier when a third party must check the proof without trusting your server.

What happens if one ceremony contributor is malicious?

Nothing, provided at least one other contributor was honest. The Groth16 setup is designed so that a single secret, undisclosed contribution makes the final parameters sound even if every other participant colludes. This is the core security guarantee of the whole ceremony design.

How expensive is on-chain verification in gas terms?

Verification is constant-cost regardless of circuit size: the Verifier.sol contract computes two or three pairings and returns a boolean. In practice that is on the order of a few hundred thousand gas per call, which is why proof-of-computation use cases become economical -- the heavy work happens off-chain, once, and the on-chain check is tiny.

Conclusion

The groth16 workflow looks intimidating because of the ceremony, but it is really a six-step pipeline: write a circuit, compile it to R1CS and a witness calculator, compute a witness, prepare a universal ptau (or reuse a shared one), bind it to your circuit in a zkey ceremony, then prove and verify. The proof is constant-size, verification is constant-cost, and the zk proof nodejs path needs exactly two function calls once your keys exist. Minh's credential-check project is within reach of a weekend: circom a small circuit, run a three-contributor phase 2 with a beacon, prove on the server or in the browser, and let a Verifier.sol contract -- or a plain verify() call -- do the checking forever after. The ceremony is the part that needs care, not courage; the cryptography does the rest. For a Vietnamese developer already comfortable with Node.js, this snarkjs tutorial is the shortest path from "what is a zk-proof" to "I shipped one."


Quick Reference

StepCommandArtifactPurpose
1. Compilecircom circuit.circom --r1cs --wasm --symcircuit.r1cs, circuit.wasm, circuit.symConstraints + witness calculator
2. Witnesssnarkjs wtns calculate (or fullProve)witness.wtnsSatisfying assignment for your inputs
3. Phase 1snarkjs powersoftau new/contribute/beacon/prepare phase2pot_final.ptauUniversal setup (reuse a shared one)
4. Phase 2snarkjs groth16 setup + zkey contribute + zkey beacon + zkey verifycircuit_final.zkeyCircuit-specific proving + verification keys
5. Provesnarkjs groth16 prove (or fullProve)proof.json, public.jsonThe zero-knowledge proof
6. Verifysnarkjs groth16 verify or Verifier.solverification_key.jsonConstant-cost check (Node or on-chain)
Size facts—proof ≈ 512 bytes; vkey ≈ 1 KBSize is constant, independent of circuit
Performance—witness ~58 ms; prove ~0.8-1.2 s; verify ~0.7-0.9 sVerification time independent of constraint count
Security—one honest contributorThe whole ceremony rests on this rule

Tags:

snarkjs tutorialgroth16 workflowzk proof nodejs