Guide

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.

  1. Install

    polyring needs a gnark fix that isn't upstream yet. Go ignores replace directives in dependencies, so copy polyring's into your go.mod.

    shell
    go get github.com/mistcash/polyring
    go.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-0a3abd2ee477

    Then run go mod tidy. Check polyring's go.mod for the current line.

  2. 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 call api.Commit, directly or through a gadget that does (range checks, lookup tables and emulated arithmetic can). Step 3 rejects such keys.
  3. Bake in the inner key

    The inner verifying key becomes circuit constants. NewVerifyingKey checks its points, precomputes what it can, and rejects keys with commitments.

    outer.go
    vk, err := recursion.NewVerifyingKey(innerVK)
  4. 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.
  5. Compile and set up

    The outer circuit is always over BN254, whatever the inner curve. Compile with the same vk you assign with.

    main.go
    ccs, err := frontend.Compile(ecc.BN254.ScalarField(), r1cs.NewBuilder, NewOuter(vk))
    pk, outerVK, err := groth16.Setup(ccs) // about 491k constraints

    groth16.Setup is a local trusted setup, fine for tests. Keys that secure anything need a ceremony.

  6. Assign and prove

    The circuit helper converts the native proof and public witness into circuit values. Copy them into your assignment.

    main.go
    tmp := 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.

  7. Export the Solidity verifier

    Use polyring's exporter. gnark's own supports one commitment, and polyring proofs carry three.

    main.go
    bnVK := 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.

  8. 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 checkout
    go 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
    imports
    import (
    	"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"
    )
For coding agents

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.

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