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.
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.
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.
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:
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.
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.
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.
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.
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.
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