Skip to content

About

An experimental library for Groth16 Simplicity verifier

Resources

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

17 Commits

Folders and files

Repository files navigation

Simple Groth16

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.

TL;DR

The library provides a Python generator which compiles a Groth16 verifying key into a Simplicity program that checks

$$e(A, B) = e(\alpha, \beta) * e(L, \gamma) * e(C, \delta).$$

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 - y1 for an addition, λ * 2y = 3x^2 for 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_hash jet, so a proof spends exactly one transaction and cannot be replayed or redirected.

Design document explains each part in more detail.

Cost

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.

Quick start

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 parallel

Each 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).

How it works and a toy example

From a circom circuit to a Liquid spend: what circom and snarkjs produce, what the Python generator produces, and what deploy builds

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.

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

    setup compiles the circuit, downloads a powers-of-tau file, runs the snarkjs setup and writes verifier.simpl. deploy compile parses it and writes verifier.uprog (and verifier.wnames, the order of its witness nodes).

  2. Get the address and fund it:

    $D address build/multiplier/verifier.uprog elementsregtest
    elements-cli sendtoaddress <address> 1.0
  3. 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
  4. 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
  5. 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).

Your circuit

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/x

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

Repository

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

Development

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.

Credits

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

License

MIT

About

An experimental library for Groth16 Simplicity verifier

Resources

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages