Verify a Groth16 proof inside your circuit
Eight steps from an inner proof to a contract that verifies the recursive proof. All code comes
from a module that runs the whole flow in about a minute. You need Go 1.26.8, and solc for step
7.
Only wrapping a proof? Skip step 4.
recursion.NewBN254Circuit(vk) is a complete outer circuit, and its Assign method
fills the witness.
-
Install
polyring needs a gnark fix that isn't upstream yet. Go ignores
replacedirectives in dependencies, so copy polyring's into yourgo.mod.shellgo get github.com/mistcash/polyringgo.mod// Copy the current line from polyring's go.mod; it moves with polyring. replace github.com/consensys/gnark v0.16.4 => github.com/mistcash/tiny-gnark v0.0.0-20261018205550-0a3abd2ee477Then run
go mod tidy. Check polyring's go.mod for the current line. -
Prove the inner statement
Any gnark circuit over BN254 or BLS12-381, proved with stock Groth16. Keep the verifying key, the proof and the public witness.
inner.go// Error handling omitted. For a BLS12-381 inner proof use ecc.BLS12_381. ccs, err := frontend.Compile(ecc.BN254.ScalarField(), r1cs.NewBuilder, &InnerCircuit{}) pk, innerVK, err := groth16.Setup(ccs) full, err := frontend.NewWitness(innerAssignment, ecc.BN254.ScalarField()) innerProof, err := groth16.Prove(ccs, pk, full) innerPublic, err := full.Public()To try the flow first,
mimc.Prove(ecc.BN254)from the examples does all of this for a MiMC circuit.No commitments in the inner circuit. It must not callapi.Commit, directly or through a gadget that does (range checks, lookup tables and emulated arithmetic can). Step 3 rejects such keys. -
Bake in the inner key
The inner verifying key becomes circuit constants.
NewVerifyingKeychecks its points, precomputes what it can, and rejects keys with commitments.outer.govk, err := recursion.NewVerifyingKey(innerVK) -
Write the outer circuit
Add the inner proof and public inputs as fields, keep the key in an unexported field, and verify the proof first in
Define. Your own constraints follow.outer.go// The inner proof's curve. For BLS12-381 use emparams.BLS12381Fp / // BLS12381Fr and sw_bls12381.G2Affine / GTEl. type ( Fp = emparams.BN254Fp // inner curve base field Fr = emparams.BN254Fr // inner curve scalar field G2 = sw_bn254.G2Affine GTEl = sw_bn254.GTEl ) type Outer struct { InnerProof recursion.Proof[Fp, G2] `gnark:",secret"` InnerPublic recursion.PublicWitness[Fr] `gnark:",public"` Nonce frontend.Variable `gnark:",public"` // your own inputs vk *recursion.VerifyingKey // the inner key, baked in as constants } // NewOuter sets the key and sizes the inner public inputs. // Use it for both compiling and assigning. func NewOuter(vk *recursion.VerifyingKey) *Outer { return &Outer{InnerPublic: recursion.NewBN254Circuit(vk).PublicWitness, vk: vk} } func (c *Outer) Define(api frontend.API) error { // First line: the ring checker must exist before any emulated field. v, err := recursion.NewVerifier[Fp, Fr, G2, GTEl](api, c.vk) if err != nil { return err } if err := v.AssertProof(c.InnerProof, c.InnerPublic); err != nil { return err } api.AssertIsDifferent(c.Nonce, 0) // your own constraints return nil }Why first? The ring checks register range checks of their own, and gnark runs deferred checks in registration order. -
Compile and set up
The outer circuit is always over BN254, whatever the inner curve. Compile with the same
vkyou assign with.main.goccs, err := frontend.Compile(ecc.BN254.ScalarField(), r1cs.NewBuilder, NewOuter(vk)) pk, outerVK, err := groth16.Setup(ccs) // about 491k constraintsgroth16.Setupis a local trusted setup, fine for tests. Keys that secure anything need a ceremony. -
Assign and prove
The circuit helper converts the native proof and public witness into circuit values. Copy them into your assignment.
main.gotmp := recursion.NewBN254Circuit(vk) if err := tmp.Assign(innerProof, innerPublic); err != nil { return err } assignment := NewOuter(vk) assignment.InnerProof, assignment.InnerPublic = tmp.Proof, tmp.PublicWitness assignment.Nonce = 7 // your own inputs full, err := frontend.NewWitness(assignment, ecc.BN254.ScalarField()) public, err := full.Public() proof, err := groth16.Prove(ccs, pk, full, gnarksolidity.WithProverTargetSolidityVerifier(backend.GROTH16)) err = groth16.Verify(proof, outerVK, public, gnarksolidity.WithVerifierTargetSolidityVerifier(backend.GROTH16))The Solidity target derives commitment challenges with Keccak, as the contract does. Leave it out if you only verify off chain.
-
Export the Solidity verifier
Use polyring's exporter. gnark's own supports one commitment, and polyring proofs carry three.
main.gobnVK := outerVK.(*groth16bn254.VerifyingKey) bnProof := proof.(*groth16bn254.Proof) f, err := os.Create("Verifier.sol") err = solidity.ExportSolidity(bnVK, f) // polyring's lib/solidity proofBytes := solidity.MarshalSolidity(bnProof) // 512 bytes // Public inputs: the outer public witness, minus the values the // contract derives from the commitments itself. vec := public.Vector().(fr.Vector) n := len(bnVK.G1.K) - 1 - len(bnVK.PublicAndCommitmentCommitted) inputs := make([]*big.Int, n) for i := range inputs { inputs[i] = vec[i].BigInt(new(big.Int)) } // On chain: Verifier.verifyProof(proofBytes, inputs) reverts unless valid.Each inner public input enters as four 64-bit limbs. With one inner input and a nonce, this example passes five.
-
Test it
Check the assignment before paying for setup, then run polyring's end-to-end tests as a reference.
outer_test.go// Seconds, no setup: does the assignment satisfy the circuit? err := test.IsSolved(NewOuter(vk), assignment, ecc.BN254.ScalarField())shell, in a polyring checkoutgo test ./examples/recursion/ -run TestGroth16Verifier -v # both inner curves, tampered proofs rejected go test ./examples/recursion/ -run TestOuterOnChain -v # prove, deploy, verify on a simulated EVM (needs solc)Imports used in this guide
importsimport ( "math/big" "os" "github.com/consensys/gnark-crypto/ecc" "github.com/consensys/gnark-crypto/ecc/bn254/fr" "github.com/consensys/gnark/backend" "github.com/consensys/gnark/backend/groth16" groth16bn254 "github.com/consensys/gnark/backend/groth16/bn254" gnarksolidity "github.com/consensys/gnark/backend/solidity" "github.com/consensys/gnark/frontend" "github.com/consensys/gnark/frontend/cs/r1cs" "github.com/consensys/gnark/std/algebra/emulated/sw_bn254" "github.com/consensys/gnark/std/math/emulated/emparams" "github.com/consensys/gnark/test" "github.com/mistcash/polyring/lib/solidity" "github.com/mistcash/polyring/std/recursion" )
Hand this to your agent
Paste the prompt below, or point your agent at llms.txt, which adds the full tested code. These are the rules agents get
wrong without help.
You are adding recursive Groth16 verification to a Go project with polyring
(github.com/mistcash/polyring), a gnark library. Read
https://polyring.mist.cash/llms.txt first, then follow these rules.
1. go.mod: require github.com/mistcash/polyring and copy polyring's gnark
replace directive verbatim from its go.mod. Go ignores replace directives
in dependencies, and polyring's circuits need that gnark fork.
2. The inner circuit (BN254 or BLS12-381, Groth16) must draw no BSB22
commitments: no api.Commit, directly or through a gadget.
recursion.NewVerifyingKey(innerVK) returns an error if it does.
3. In the outer circuit struct use recursion.Proof[Fp, G2] (secret) and
recursion.PublicWitness[Fr] (public), and keep the
*recursion.VerifyingKey in an unexported field.
BN254 inner: emparams.BN254Fp, emparams.BN254Fr,
sw_bn254.G2Affine, sw_bn254.GTEl
BLS12-381 inner: emparams.BLS12381Fp, emparams.BLS12381Fr,
sw_bls12381.G2Affine, sw_bls12381.GTEl
4. recursion.NewVerifier[Fp, Fr, G2, GTEl](api, vk) must be the first
statement in Define, before anything creates an emulated field. Then
call AssertProof, then add the project's own constraints.
5. Build the compile placeholder and the assignment from the same vk. Size
the public inputs with recursion.NewBN254Circuit(vk).PublicWitness (or
NewBLS12381Circuit) and fill the assignment through that helper's Assign.
6. Compile over ecc.BN254.ScalarField() with r1cs.NewBuilder. When the proof
goes on chain, prove with gnark's
solidity.WithProverTargetSolidityVerifier(backend.GROTH16).
7. Export the contract with polyring's lib/solidity.ExportSolidity and pack
the proof with lib/solidity.MarshalSolidity. Never gnark's own exporter:
it supports one commitment and polyring proofs carry three.
8. Check the assignment with test.IsSolved before running setup.
Do not claim the EVM verifier is cheaper than gnark's: it costs about 460k
gas against 359k; the saving is 38% fewer prover constraints. Do not
attempt nested recursion, inner proofs with commitments, or a verifying key
that varies at runtime: v0.1 does not support them. polyring is unaudited.