zk-SNARKs: Understand It, Then Build It
A hands-on guide to zk-SNARKs — from equations to working code, using TypeScript and elliptic curves
Have you ever wanted to prove you know something — without revealing what it is?
Imagine:
"I know the solution to this puzzle, but I won’t tell you the answer."
"I’ve done the computation correctly, but I don’t want to show you the inputs."
That’s the magic of Zero-Knowledge Proofs (ZKPs) — a way to convince someone you know something, without giving away a single clue about what that something is.
This post is your beginner-friendly guide to zk-SNARKs — a powerful type of ZKP — explained with metaphors, light formulas, and grounded examples.
🛠️ What Makes This Guide Different?
There are plenty of articles that explain zk-SNARKs — but very few that actually show you how to implement them step-by-step in code.
That’s what sets this guide apart.
Throughout this post, every concept is directly connected to real, working code that you can read, run, and modify. This isn't just theory — it's an implementation-first approach that is designed to help you truly understand zk-SNARKs by building them yourself.
Here’s what you’ll get that most other resources don’t:
R1CS not just as equations, but broken down into variable flows and witness vectors — so you can see how each part of the computation maps into constraints.
Lagrange interpolation implemented in code, so you can build QAP polynomials from constraint matrices, not just read about it.
The full QAP transformation: from R1CS constraints to polynomial identity checks.
Pairing-based proof verification using
noble-bls12-381
— so you can see how the cryptographic pairing check works under the hood.
This hands-on approach is powered by a complete Learning Module on GitHub that complements this post. You’ll be guided through building a zk-SNARK prover and verifier from scratch — no hidden black boxes.
If you've ever thought, "I kind of get the idea… but I wish someone showed me how to actually code it," this is exactly the guide for you.
Why Should You Care?
zk-SNARKs aren't just cryptographic novelties. They're already being used to solve real problems in privacy, scalability, and verification.
Privacy: Prove you've made a valid transaction without revealing the sender, receiver, or amount.
Scalability: Summarize thousands of off-chain transactions into a single on-chain proof (as in zkRollups).
Selective Disclosure: Prove you're over 18, or a verified user, without revealing anything else about your identity.
Verifiable Voting: Enable private but auditable election systems.
At a deeper level, zk-SNARKs let us move from trust-based systems to proof-based systems: from "trust me" to "prove it — without showing me how."
How zk-SNARKs Work - An Overview
Before diving into code, let’s map the process from idea to proof — with enough detail to help you truly understand what each stage means and why it matters.
Model the computation as an equation
Let’s say you want to prove you know a numberx
such that:(x + 2)(x + 3)(x - 1) = 60
You want to prove this without revealing whatx
is. So the first step is to define this target computation.Convert it into constraints (R1CS)
We break it into intermediate steps:a = x + 2 b = x + 3 c = a * b d = x - 1 out = c * d
Create constraints for the multiplication steps:
c = (x + 2)(x + 3) // Constraint 1 out = c(x - 1) // Constraint 2
Each step becomes a constraint of the form
(A · w)(B · w) = (C · w)
, wherew
holds all inputs and intermediate values. If all constraints hold, the computation is correct. We'll later convert them into polynomials using Lagrange interpolation.Run a trusted setup (CRS)
A one-time ceremony generates encrypted references to powers of a secret number au au. These values (the CRS) are shared between the prover and verifier and enable secure computations "in the exponent" without ever revealing au au.Create a proof
The prover uses their secret witness (the values of xx, aa, bb, etc.) and the CRS to generate a compact zk-SNARK proof. This proof confirms that the prover knows inputs satisfying the constraints, and thus, the original computation.Verify the proof
Using the CRS and elliptic curve pairings, the verifier checks a single equation involving the committed polynomials. If it holds, the verifier is convinced the prover did the computation correctly — without learning any private data.
Each of these stages is broken down in the following sections — each one comes with real TypeScript code and detailed explanations.
Step 1: Model the Computation as an Equation
The first step in constructing a zk-SNARK is to define what you're trying to prove — without revealing the private input. For example:
(x + 2)(x + 3)(x - 1) = 60
You know some x
that satisfies this equation, but you don’t want to reveal x
. This step transforms a real-world claim into a formal computation — a statement that can be checked by others.
This is essential because zk-SNARKs can only prove knowledge of satisfying inputs for known computations. So you begin by explicitly expressing the logic you want to prove.
What’s introduced here:
Encrypted exponentiation: like proving 1 + 2 = 3 using
g^1 * g^2 = g^3
The target statement: a computation we want to prove knowledge of.
The concept of hiding the witness:
x
is known only to the prover.
Step 2: Convert to R1CS (Rank-1 Constraint System)
To move toward a verifiable proof, we break down our equation (x + 2)(x + 3)(x - 1) = 60
into small, checkable steps. Why? Because zk-SNARKs need to express logic as a sequence of multiplications, which are non-linear and harder to fake than simple additions:
a = x + 2
b = x + 3
c = a * b
d = x - 1
out = c * d
We now define constraints only for the two multiplication steps:
c = a * b
out = c * d
Each of these becomes a separate R1CS constraint:
(A · w) * (B · w) = (C · w)
Let’s define our witness vector:
w = [1, x, a, b, c, d, out]
(The 1
is for constant terms.)
Constraint matrices:
Constraint 1:
c = (x + 2)(x + 3)
A = [2, 1, 0, 0, 0, 0, 0] → selects
x + 2
B = [3, 1, 0, 0, 0, 0, 0] → selects
x + 3
C = [0, 0, 0, 0, 1, 0, 0] → outputs to
c
Constraint 2:
out = c * (x - 1)
A = [0, 0, 0, 0, 1, 0, 0] → selects
c
B = [-1, 1, 0, 0, 0, 0, 0] → selects
x - 1
C = [0, 0, 0, 0, 0, 0, 1] → outputs to
out
Why Multiplication Constraints?
Additions are linear and easy to merge — but multiplications are where logic becomes non-trivial. Every interesting computation can be reduced to additions and multiplications, and R1CS tracks each multiplication independently.
By checking that each multiplication constraint is satisfied, we know the entire logic has been executed correctly — this is what makes R1CS powerful:
If all constraints are satisfied, then the full computation is correct.
What’s introduced here:
The witness vector: holds all inputs and intermediate values.
The R1CS format: breaks logic into clean multiplication constraints.
Multiplication as the core of verification.
Step 3: Transform R1CS to QAP (Quadratic Arithmetic Program)
We now want to transform our R1CS constraints into a form that supports efficient proof generation and verification — this is where the Quadratic Arithmetic Program (QAP) comes in.
From Step 2, we had two multiplication constraints represented as R1CS matrices. We now assign each constraint a unique position on the x-axis:
Constraint 1 → x = 1
Constraint 2 → x = 2
Then, for each variable in the witness vector ([1, x, a, b, c, d, out]
), we extract its role in the constraints to build polynomials:
Let’s take variable x
as an example:
In A matrix: it appears in both constraints → coefficients = [1, 0]
In B matrix: also in both → coefficients = [1, 1]
In C matrix: not used directly → coefficients = [0, 0]
We now use Lagrange interpolation to construct a polynomial u_x(x)
that satisfies:
u_x(1) = 1 (from constraint 1)
u_x(2) = 0 (from constraint 2)
This gives us:
u_x(x) = (x - 2) / (1 - 2) = -(x - 2)
Similarly, we do this for all variables and all matrices (A, B, C), building sets of polynomials:
uᵢ(x)
: left inputvᵢ(x)
: right inputwᵢ(x)
: output
Using these, we compute the overall identity:
P(x) = (Σ wᵢ * uᵢ(x)) * (Σ wᵢ * vᵢ(x)) - (Σ wᵢ * wᵢ(x))
This polynomial P(x)
must evaluate to 0 at all constraint points (x = 1, 2).
To check that without revealing anything, we introduce:
The Vanishing Polynomial t(x)
t(x) = (x - 1)(x - 2)
If P(x)
is divisible by t(x)
, then all constraints are satisfied. So we require:
P(x) = h(x) * t(x)
What’s introduced here:
Lagrange interpolation: turns discrete constraint coefficients into polynomials
QAP polynomials (
uᵢ
,vᵢ
,wᵢ
): one per witness variableP(x): encodes all constraint logic into one equation
Vanishing polynomial
t(x)
: lets us check all constraints together
This transformation sets us up to prove that the computation is valid — without revealing any of the inputs.
Step 4: Trusted Setup (CRS)
Let’s say you want to prove that 1 + 2 = 3
, but without showing any of the actual numbers.
Instead of sharing 1
, 2
, or 3
, you raise a public group generator g
to the power of those numbers:
g¹, g², g³
Then the verifier can check:
g¹ · g² = g³
This works because exponentiation in cryptographic groups preserves multiplication:
g¹ · g² = g^(1+2) = g³
Even though the verifier never sees the actual values, they can still check the computation.
This is the intuition behind working in the exponent — we represent computations using group elements like g^x
, and the verifier checks relations using elliptic curve pairings. a trusted setup to generate a Common Reference String (CRS) — a set of cryptographic values derived from a secret parameter au au, like:
g¹, gᵗ, gᵗ², gᵗ³, …, g^(t(τ))
These values are generated once and published publicly. Importantly, no one should ever learn the value of au au — otherwise, they could forge proofs.
The CRS enables us to work with polynomials in encrypted form. The prover can commit to their polynomial evaluations at au au using these powers, and the verifier can later check that those commitments satisfy the necessary identity.
What’s introduced here:
CRS (Common Reference String): shared cryptographic data based on a secret au au
Commitments to polynomials in exponent: instead of sending raw values, we send elliptic curve points
Trusted setup ceremonies: often multi-party, to ensure no one knows the full au au
This sets the foundation for zero-knowledge: polynomials are committed without revealing them.
Step 5: Create a Proof
With the CRS and secret witness in hand, the prover builds the zk-SNARK proof. This proof contains cryptographic commitments to the polynomials we built earlier:
A(τ)
: evaluation of the left polynomial sideB(τ)
: evaluation of the right sideC(τ)
: output polynomialH(τ)
: the quotient polynomial whereP(x) = H(x) * t(x)
Each is committed using elliptic curve points, e.g.:
A = g^{A(τ)} ∈ G1
B = g^{B(τ)} ∈ G2
C = g^{C(τ)} ∈ G1
H = g^{H(τ)} ∈ G1
Together, these commitments encode the proof that:
You know a valid witness
w
It satisfies all constraints in R1CS → QAP
Without revealing any part of
w
What’s introduced here:
Cryptographic commitments to polynomial evaluations
Quotient polynomial H(x): shows
P(x)
divides byt(x)
Structured proof format: allows small, efficient zk-SNARKs
The proof is compact and non-interactive — no need to ask the prover any questions afterward.
Step 6: Verify the Proof
The verifier now uses the CRS and pairing-based cryptography to check a single equation:
e(A, B) = e(C, g) · e(H, g^{t(τ)})
This pairing check validates the main QAP identity in encrypted form:
(A(τ) * B(τ)) - C(τ) = H(τ) * t(τ)
If this identity holds, it means:
The prover followed the computation logic
The secret witness satisfied all constraints
The proof is valid — without revealing the witness
What’s introduced here:
Pairing checks: special cryptographic operations verifying equations in the exponent
Soundness via randomness: the prover doesn’t know au au, so they can’t forge false polynomials
Efficient verification: checking the proof takes constant time, no matter how big the original computation was
In one elegant equation, the verifier confirms the truth of the entire computation.
Theory to Code
Feeling overwhelmed by zk-SNARK papers and slides? We get it. That’s why this guide includes a companion Learning Module — a GitHub repo full of walkthroughs and testable code.
In the repo, you'll find:
Modular files: cleanly separated logic for R1CS, QAP, CRS, and proofs
Tests that match theory: each test corresponds to a concept in this guide
Minimal but modern stack: written in TypeScript, built on
noble-bls12-381
🔗 View the zk-SNARK Learning Module on GitHub
Ready to See It in Action?
Now that you've seen how zk-SNARKs work — from constraint modeling to polynomial transformations to cryptographic verification — it’s time to put the theory into code.
Our Learning Module takes everything you’ve just read and turns it into an actual working implementation. You’ll build your own prover and verifier using TypeScript and noble-bls12-381
.
Each step — from R1CS generation to QAP polynomials to trusted setup — is covered with clear, testable code. If you're ready to turn ideas into code, dive into the repo and start experimenting.
Appendix: What This Guide Leaves Out — and Why
This guide walks you through a full working version of zk-SNARKs — from R1CS to QAP to pairing-based verification. But it intentionally leaves out a few advanced features found in fully optimized zk-SNARK schemes like Groth16. Here's a quick overview:
1. No Public Inputs Handling
In many real-world use cases, you need to prove something about a public value — like proving that a transaction involves a known address. Groth16 handles this by including public inputs in the proof structure.
To do this, the trusted setup must include terms like:
[g^{α·uᵢ(τ)}], [g^{β·vᵢ(τ)}], [g^{γ}], etc.
And the final proof includes a separate component for public input evaluation.
Why we skipped it: We focused this guide on private inputs only, to simplify the core ideas. Handling public inputs securely introduces extra pairing terms and CRS setup rules that would distract from the fundamentals.
2. No α, β, γ Powers
Groth16 introduces additional secret powers (α, β, γ) into the CRS, which are used to tie the different parts of the proof together and prevent specific types of forgery.
These terms affect how the proof elements like π_A, π_B, π_C are constructed and verified:
e(π_A, π_B) = e(π_C, g) · e(g^{α·β}, g^γ)
Why we skipped it: Our goal was to focus on the structure of the computation and constraint system, not optimizations or tighter zero-knowledge guarantees. Adding α, β, γ increases complexity in both setup and verification.
By leaving these out, we can help you build an end-to-end zk-SNARK from scratch, understand all the intermediate representations, and trace how constraints become provable identities.
Once you're comfortable with the basics, stepping up to Groth16 is just a matter of adding public input handling and adjusting the CRS and pairing logic.
We hope this was useful and provided you with a clear understanding on zk-SNARKs.
🔗 If you have questions or want to discuss about the topic and/or Learning Module, join us on Discord!