Write a zk circuit with gnark in Go

Get ready - there is a lot of zk math jargon in here which might not be relevant for every audience.

This isn’t quite a beginner’s guide, but also not an advanced guide either. This is a “somewhere in the middle” guide and is here mostly as a fun pet project to get me to learn more a bit about gnark, the ZK circuit library that powers Linea.

I do want to give a heads up that this post will not explain ZK proofs and how they work. I should probably write something up on that later.

What is gnark

gnark is a an open-source zk-SNARK library that offers a high-level API to design circuits.

As compared to other libraries like NoirJS, which enable developers to build privacy solutions, gnark development is primarily geared towards scale and for teams focused on building performance-critical applications such as rollups, bridges, and light-clients! That is, gnark is great for specialized circuits, aka running one zkSNARK per program. If you are interested in using gnark for privacy preserving dapps, there are Javascript libraries like Sindri that leverage gnark circuits. Linea uses gnark, but it also powers projects like zkBNB, Celer zkBridge.

Anyways, gnark has a few properties that really make it standout.

  • It’s a go package, which means there is no domain-specific language (DSL) required. As a result, it can also be integrated with more complex solutions like logging and serialization like any other go module. Additionally, devs can debug, document, test and benchmark circuits as well as versioned, unit-tested, and use in CI/CD workflows like any other Go program.

  • Our fine friends at gnark also built an extensive standard library on top of it to provide a better developer UX for doing more complex cryptographic applications such as hashing, checking signature validity, and merkle trees.

  • It can generate proving and verifying keys for both trusted generation and MPC generation.

  • It supports multiple elliptic curves and backends (Groth16, PlonK w/KZG).

  • It can verify via native verifiers for integrating zk proofs into traditional web services or via Ethereum smart contract verifiers for creating proofs on chain.

  • It’s the fastest SNARK library out there and has won an award for being the fastest on mobile.

If you didn’t understand most of that, that’s okay! To summarize in a sentence:

gnark is a robust go module to help you build lightning fast zkSNARKs for performance-critical applications.

Now that we’ve got a feeling for what gnark is, let’s actually just write an extremely simple circuit. You can find the full Github repo here.

Set up a go project

Before you start, make sure you’ve installed go using these instructions. Once you’ve done that, we are ready to create a project! Let’s make a directory, initialize a go project, create the file you’ll be writing the circuit in, and install the gnark module:

mkdir circuit_tutorial
cd circuit_tutorial
go mod init example/simple_circuit
touch simple_circuit.go
go get github.com/consensys/gnark@latest

Then, open up your folder in your favorite IDE. I’m using VSCode. Modify simple_circuit.go:

package main

func main() {

}

Now that we have all the scaffolding in place, let’s actually write a circuit!

The simplest circuit in the history of all circuits

There are really complex things you can do with gnark, but in this case, we’ll just be writing a circuit that proves a value has been cubed and increased by 5.

A gnark circuit must implement this interface, where Define declares the circuits constraints. In our case, we will be asserting that a value Y is 5 + a value X cubed in the Define function.

type Circuit interface {
    // Define declares the circuit's constraints
    Define(api frontend.API) error
}

Before we start writing the circuit, just paste these imports at the top of your file, which you will all need by the time we get done with this tutorial:

import (
	"github.com/consensys/gnark-crypto/ecc"
	"github.com/consensys/gnark/backend/groth16"
	"github.com/consensys/gnark/frontend"
	"github.com/consensys/gnark/frontend/cs/r1cs"
)

To create a our circuit, we’ll want a private variable X that we will cube and add 5 to, and a public variable Y, which will be the resulting value.

Add this circuit definition to your code:

type SimpleCircuit struct {
	// struct tags on a variable is optional
	// default uses variable name and secret visibility.
	X frontend.Variable `gnark:"x"`
	Y frontend.Variable `gnark:",public"`
}

The circuit must declare its private and secret inputs as frontend.Variable. By default, these variables are secret (gnark:",secret"). However, since Y is our public variable, we need to indicate as such with gnark:”,public”.

Now that we’ve defined a circuit struct, we can actually write our Define method!

// x**3 + x + 5 == y
func (circuit *SimpleCircuit) Define(api frontend.API) error {
	x3 := api.Mul(circuit.X, circuit.X, circuit.X)
	api.AssertIsEqual(circuit.Y, api.Add(x3, circuit.X, 5))
	return nil
}

Your file thus far should look like this:

package main

import (
	"github.com/consensys/gnark-crypto/ecc"
	"github.com/consensys/gnark/backend/groth16"
	"github.com/consensys/gnark/frontend"
	"github.com/consensys/gnark/frontend/cs/r1cs"
)

// SimpleCircuit defines a simple circuit
// x**3 + x + 5 == y
type SimpleCircuit struct {
	// struct tags on a variable is optional
	// default uses variable name and secret visibility.
	X frontend.Variable `gnark:"x"`
	Y frontend.Variable `gnark:",public"`
}

// Define declares the circuit constraints
// x**3 + x + 5 == y
func (circuit *SimpleCircuit) Define(api frontend.API) error {
	x3 := api.Mul(circuit.X, circuit.X, circuit.X)
	api.AssertIsEqual(circuit.Y, api.Add(x3, circuit.X, 5))
	return nil
}

func main() {
}

Run your circuit

There are four steps to running a circuit:

  1. Compile

  2. Setup

  3. Witness generation

  4. Verify

Paste this into your main function:

// compiles our circuit into a R1CS
var circuit SimpleCircuit
ccs, _ := frontend.Compile(ecc.BN254.ScalarField(), r1cs.NewBuilder, &circuit)

// groth16 zkSNARK: Setup
pk, vk, _ := groth16.Setup(ccs)

// witness definition
assignment := SimpleCircuit{X: 3, Y: 35}
witness, _ := frontend.NewWitness(&assignment, ecc.BN254.ScalarField())
publicWitness, _ := witness.Public()

// groth16: Prove & Verify
proof, _ := groth16.Prove(ccs, pk, witness)
groth16.Verify(proof, vk, publicWitness)

Now, to run it, simply call go run .

Test your circuit

Ah, yes, indeed you are a great developer, and all great developers write tests! gnark makes this easy as well, providing its own testing module to get you on your merry little way. Create a file called simple_circuit_test.go and paste in this code:

package main

import (
	"testing"
	"github.com/consensys/gnark/test"
)

func TestCubicEquation(t *testing.T) {
	assert := test.NewAssert(t)

	var simpleCircuit SimpleCircuit

	assert.ProverFailed(&simpleCircuit, &SimpleCircuit{
		X: 42,
		Y: 42,
	})

	assert.ProverSucceeded(&simpleCircuit, &SimpleCircuit{
		X: 3,
		Y: 35,
	})
}

Run go test to actually run the test and it should pass!

Anyways, that’s everything you need to know to build a very simple ZK circuit! You are now clearly up-skilled enough to go build a rollup LOL.

If you want to find more complex circuits or just play around with them in general, gnark has a great interactive interface at play.gnark.io.

Feel free to follow @_emjlin and @lineabuild on Twitter, or @emilylin and @linea on Farcaster to stay up to date with the latest news.

As always, quick plug to join /frog on Farcaster cause it’s a great channel, and if you’re interested in joining the Linea Builders Club, where you can get exclusive access to tech talks, cryptographers, educational programs, and monthly mini-hacks, you can apply here and mention that you were referred through this blog!

Subscribe to E(frog)mily Lin(ea)
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.