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.
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.
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!
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() {
}
There are four steps to running a circuit:
Compile
Setup
Witness generation
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 .
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!