A Groth16 verifier written on Simplicity. A proof made with circom and snarkjs can spend a Liquid TX output.
⚠️ The library is highly experimental and has not been audited. Don't use it in production! To report a vulnerability, see SECURITY.md.
The library provides a Python generator which compiles a Groth16 verifying key into a Simplicity program that checks
Simplicity has no pairing jets, so we applied a bunch of tricks to fit Groth16 verifier limits set for Liquid transactions:
- BN254 field arithmetic runs on the secp256k1 field and scalar jets. A product of two BN254 elements is computed exactly modulo both secp256k1 primes, then reduced with Montgomery reduction carried out in CRT form.
- Curve arithmetic needs no inversions. Adding or doubling points normally divides to get the slope of a line; here the prover puts every slope in the witness, and the program checks it with a multiplication:
λ * (x2 - x1) = y2 - y1for an addition,λ * 2y = 3x^2for a doubling. - There is no arithmetic in F_p12. The prover writes every intermediate value of the Miller loop into the witness, and the program checks all 88 steps at once, as one polynomial identity at a random point chosen by hashing the witness (Fiat–Shamir with SHA-256), so a product in F_p12 costs one product in F_p.
- There is no final exponentiation. The prover supplies a residue witness (Novakovic and Eagen, 2024), and the program checks a short identity instead.
- The key is compiled in. The lines of delta are constants; gamma and the IC points become lookup tables of pairing values, since
$e(L, \gamma) = e(IC_0, \gamma) \prod_i e(IC_i, \gamma)^{a_i}$ , so L is never computed; and$e(\alpha, \beta)$ is folded into the final comparison. - The circuit requires a single public signal: the SHA-256 Merkle root of its public values, with the sighash of the spending transaction as leaf 0. The program recomputes that root from its
sig_all_hashjet, so a proof spends exactly one transaction and cannot be replayed or redirected.
Design document explains each part in more detail.
| program cost, one public signal | 48.6 M mWU |
| transaction | 53.3 kWU, 13.3 kvB |
| pruned program / witness | 34 kB / 18 kB |
| fee at 0.115 sat/vB | ~ 1,500 sat |
The program's bytes earn more budget than it costs, so it needs no padding. The transaction is about a seventh of the 400 kWU standard limit.
Requirements:
- Python 3.10 or later (no packages) and Rust.
- The circuits require circom 2, Node.js 22 or later
- Elements Core 23.3.
Tested with rust-simplicity 0.9.0, Elements Core 23.3.1, circom 2.2.2, snarkjs 0.7.6, Python 3.10 to 3.14 and Node.js 22.
make harness # builds hand, deploy, costs and sizes (tools/harness)
make test-fp # field arithmetic
make test-groth16 # the whole verifier
make -j4 test # all tests, in parallelEach test generates programs for one layer, runs them through hand (which executes them the way a node does) and compares every result with the plain-Python model. make test-bn254_ref checks the reference code against py_ecc (pip install py_ecc).
circuits/multiplier.circom is a toy statement: the prover knows a, b with a * b = c. Its public signal is the Merkle root of the sighash and c.
-
Build the key and the program:
cd circuits && npm install && cd .. python3 tools/circom_demo.py setup --out build/multiplier D=tools/harness/target/release/deploy $D compile build/multiplier/verifier.simpl build/multiplier/verifier
setupcompiles the circuit, downloads a powers-of-tau file, runs the snarkjs setup and writesverifier.simpl.deploy compileparses it and writesverifier.uprog(andverifier.wnames, the order of its witness nodes). -
Get the address and fund it:
$D address build/multiplier/verifier.uprog elementsregtest elements-cli sendtoaddress <address> 1.0
-
Compute the sighash of the spending transaction:
G=$(elements-cli getblockhash 0) $D sighash build/multiplier/verifier.uprog elementsregtest <txid> <vout> 100000000 <asset> <dest> 100000 --genesis $G
-
Prove with that sighash. This also checks the proof and writes the verifier's witness:
python3 tools/circom_demo.py prove --out build/multiplier --sighash <sighash> --a 3 --b 7
-
Build the transaction and send it:
$D spend build/multiplier/verifier.uprog build/multiplier/verifier.w0.json elementsregtest \ <txid> <vout> 100000000 <asset> <dest> 100000 --genesis $G > spend.hex elements-cli -stdin sendrawtransaction < spend.hex
deploy spend prunes the program against this transaction and executes it, so a wrong witness fails before anything is sent. You don't need a node for a Liquid testnet -- the faucet funds the address and the transaction can be broadcast through Blockstream's Esplora API (the liquidtestnet network in deploy).
Include circuits/public_merkle.circom, add a sighash[2] input and feed your public values into PublicMerkleRoot(n). Its root must be the circuit's only public signal. Then:
python3 tools/groth16_snarkjs.py program verification_key.json --values n --out build/x
python3 tools/groth16_snarkjs.py witness verification_key.json proof.json public.json --leaves leaves.json --out build/xleaves.json is {"sighash": "<64 hex digits>", "values": ["<decimal>", ...]}, with the sighash from deploy sighash and the values in circuit order. Without --values, each public signal becomes a verifier input (about 6.6 M mWU and 14 kB each) and nothing binds the proof to the transaction.
The program sees only the root, not the values behind it. A contract that must act on a value, say an address the proof commits to, would have to open that leaf as well; the generator does not do that yet.
groth/ |
the generator: bn254.py (plain-Python reference), sym.py and dsl.py (the two backends and the Simplicity text), fp.py, poly.py, curve.py, pairing.py, groth16.py (one layer each), merkle.py (the public signal) |
circuits/ |
public_merkle.circom (the public-signal convention) and the toy multiplier.circom |
tools/harness/ |
Rust: hand runs generated programs for the tests, costs and sizes profile them, deploy builds addresses and transactions |
tools/ |
groth16_snarkjs.py (snarkjs key and proof to program and witness), circom_demo.py and regtest.sh |
tests/ |
tests (plus test_bn254_ref.py against py_ecc) |
examples/ |
generated programs |
docs/ |
design notes and benchmarks |
Makefile, pyproject.toml |
development tasks, the linters' configuration |
The repository has no hand-written Simplicity: each verifying key gives a different program (2.8 MB of text for the toy circuit), which the tools write into build/.
make lint runs what CI checks besides the tests: ruff, mypy, cargo fmt --check and clippy (pip install -e '.[dev]' installs the Python tools). make fmt formats the code. make example checks that the files in examples/ regenerate byte for byte, so a change that alters the generated program shows up there.
- Blockstream Research: for Simplicity, rust-simplicity and SimplicityHL;
- iden3: for circom and snarkjs;
- Novakovic and Eagen: the residue witness On Proving Pairings (2024).
- py_ecc, which is used to cross-check the reference code.
