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 ci --ignore-scripts && 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; it also refuses a spend that a node would not take (above the consensus cap on the Bit Machine's cells, a budget that does not cover the cost, above the standard weight). 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. n may be 0 (PublicMerkleRoot(0), a tree that holds only the sighash).
A circuit that does not follow this convention needs --unbound in place of --values and --leaves. Then each public signal becomes a verifier input (about 6.6 M mWU and 14 kB each), and nothing binds the proof to the transaction: anyone who sees the spend can reuse its witness in another transaction to the same output. The spender also chooses the public inputs, so the statement's constants must be fixed in the circuit.
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.
The repository also has a verifier for snarkjs's fflonk proofs (docs/fflonk.md). fflonk's setup is universal: a circuit needs only a powers-of-tau file, with no ceremony of its own. Its verifier costs more than Groth16's: 123 M mWU with a SHA-256 transcript (proofs from tools/snarkjs_sha256.mjs) or 172 M with snarkjs's own Keccak-256 transcript, and a spend pads its witness to pay for it.
python3 tools/circom_demo.py setup --protocol fflonk --transcript sha256 --out build/fflonk
tools/harness/target/release/deploy compile build/fflonk/verifier.simpl build/fflonk/verifier
tools/regtest.sh build/fflonk # an Elements regtest node: spend, a redirected spend rejected, key pathgroth/ |
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); for fflonk, keccak.py, msm.py, frprog.py (F_r straight-line code), fflonk_ref.py (the port of snarkjs's verifier) and fflonk.py |
circuits/ |
public_merkle.circom (the public-signal convention), the toy multiplier.circom, and multiplier_fflonk.circom for fflonk |
tools/harness/ |
Rust: hand runs generated programs for the tests, costs and sizes profile them, deploy builds addresses and transactions |
tools/ |
groth16_snarkjs.py and fflonk_snarkjs.py (snarkjs key and proof to program and witness), snarkjs_sha256.mjs (fflonk proofs with a SHA-256 transcript), 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.
