How to Generate a zk-SNARK Proof in Python with py_ecc

How to Generate a zk-SNARK Proof in Python with py_ecc

A zk-SNARK sounds like something that only appears after a long compilation step and a ceremony with a dozen participants. The core idea is smaller than that. Underneath the tooling sits polynomial arithmetic on an elliptic curve, and one pairing check at the end. py_ecc gives you those primitives in a few hundred readable lines of Python, on the same curve Ethereum exposes through its precompiled contracts: BN128, also called alt_bn128. You can build a proof, verify it, and see every step, without leaving the interpreter.

What a zk-SNARK actually needs

Before touching code, hold the three pieces in your head. First, a statement written as an arithmetic circuit, then flattened into constraints. Second, a trusted setup that turns that circuit into a common reference string, split into a proving key and a verification key. Third, a prover that knows a secret witness and produces a short proof, plus a verifier that checks it with a single pairing equation.

In Groth16-style systems the proof is three group elements: two points on G1 and one on G2. Verification is one equation. That compactness is the whole point. The cost is the setup, which requires secrets nobody is allowed to keep.

Install py_ecc and sanity-check the curve

One dependency, no build step:

pip install py_ecc

Then pull in what you need from the BN128 module:

from py_ecc.bn128 import G1, G2, curve_order, multiply, add, pairing

Multiply the generator by the group order and you should land on the point at infinity. Check that curve_order equals 21888242871839275222246405745257275088548364400416034343698204186575808495617. If that number comes back different, you are on a different curve or a very old release, and nothing later in this walkthrough will line up.

Step 1: Turn your statement into constraints

Take the classic small circuit: prove you know x such that y = x³ + x + 5, where y is public. Rewrite it as multiplication-only constraints and introduce intermediate variables so each step is a single product of linear combinations:

  1. s1 = x · x
  2. s2 = s1 · x
  3. y = (s2 + x + 5) · 1

The witness vector is w = [1, x, y, s1, s2] and each constraint becomes three sparse vectors over that vector: left, right, output. py_ecc has no circuit compiler, and that is deliberate. You build the matrices yourself, which is annoying for two weeks and clarifying forever. If you have written Circom before, this is the layer the compiler hides from you.

Step 2: The trusted setup

The setup samples a secret point τ plus blinding scalars α, β, γ, δ, and publishes powers of τ in the exponent: multiply(G1, tau), multiply(G1, tau**2), and so on, along with the G2 equivalents. Everything lands in G1 or G2, so the secrets disappear. Whoever knows τ can forge proofs, which is exactly why real systems run a multi-party ceremony where at least one participant has to be honest.

For learning, generate the scalars with Python's secrets module, keep them in a local variable, and never write them to disk. Delete the notebook cell afterward. The habit matters more than the toy.

Step 3: Generating the proof

Here is the part that trips people up. The prover never evaluates the circuit polynomials at τ, because τ is gone. Every term already exists as a curve point in the proving key, and the prover combines those points with group operations, weighted by the witness values. Addition in the exponent is point addition, multiplication by a known scalar is multiply. The algebra happens without anyone learning the input.

A sketch of the shape, not copy-paste code: the prover computes A and B in G1 and G2 by summing the witness-weighted key points, adds random blinding scalars so the proof leaks nothing about x, then derives C so the final pairing equation balances. The randomness is not optional. Without it, A and B are deterministic functions of the witness and a curious verifier can extract information across repeated proofs.

Step 4: Verify with one pairing check

The verification equation has this form:

e(A, B) = e(α, β) · e(L, γ) · e(C, δ)

where L is the public-input term. In code, build both sides as field elements and compare:

lhs = pairing(B, A)

rhs = pairing(G2, alpha) * pairing(gamma_G2, public_term) * pairing(delta_G2, C)

Two practical notes. In recent py_ecc releases, pairing takes the G2 point first; if you get an assertion error, read the docstring before rewriting your proof. And the point at infinity is represented as None on BN128, so guard against it rather than passing it blindly into a pairing call.

Pitfalls that bite people

  • Skipping the subgroup check. A point can satisfy the curve equation and still sit outside the correct subgroup, which lets an attacker submit malformed proofs. py_ecc gives you the raw curve; the responsibility is yours.
  • Reusing toxic waste across circuits. Regenerate the setup whenever the circuit changes, or the security argument falls apart.
  • Swapping G1 and G2. The pairing argument order and the group each proof element lives in are easy to mix up. Write a comment next to every key point.
  • Confusing field elements with ints. Operations return FQ and FQ2 objects. Convert to int only for printing or hashing, never for arithmetic you will compare later.
  • Treating a toy setup as production-ready. A local scalar in a notebook is a teaching device, nothing more.

If you want a working reference rather than a sketch, check whether your installed version still ships the py_ecc.bn128.snark subpackage. Older releases bundled generator, prover, and verifier modules that implement a Pinocchio-style protocol end to end. Try importing it; if it fails, the code was removed and you are writing it yourself, which is the better exercise anyway.

Putting this to work

Keep the circuit tiny while you learn. Two or three constraints is enough to make the matrices readable and the debugging finite. Once a proof verifies, mutate a single witness value and confirm verification fails; that negative test catches most sign and index errors.

py_ecc is also a good cross-checking tool. If you generate proofs with Circom and snarkjs, you can rebuild the verifier equation in Python and run a known vector through it, which tells you whether a failing Solidity verifier is a contract bug or a proof bug. Use py_ecc to understand and to test. For anything guarding real value, reach for a maintained proving system with a real ceremony, an audit, and a subgroup-check story you can point at.

Photo: Pexels / Pixabay

Related Posts