Falling down the ZK cave with NeoGo

Neo SPCC
13 min readOct 6, 2023

Long-awaited 3.6.0 Neo C# node release has introduced interoperability functionality for arithmetic operations with BLS12–381 elliptic curve points that is also supported by the fully compatible 0.102.0 NeoGo node version. In other words, starting from the latest release (already available on mainnet!) all Neo dApp developers have an ability to make use of the BLS12–381 compatible zero-knowledge proof systems natively on the Neo blockchain. In this article we’ll show how to build an end-to-end dApp that generates and verifies Groth16 proofs in the Neo ecosystem. It is written in Go and uses NeoGo, but the mechanisms used are language-agnostic (as all things in Neo are).

Disclaimer: this article leaves the caveats of the Groth16 algorithm out of the scope, concentrating instead on Neo implementation specifics. For those who are interested in advanced math, ZK-SNARKs and other ZK-related topics we recommend diving into the list of links prepared by matter-labs.

Content

Zero-knowledge protocol is a method by which one party (the prover) can prove to another party (the verifier) that a given statement is true while avoiding disclosure of any other information beyond the fact that statement is true to the verifier.

Given the definition above, in this article we’ll show you how to create and verify zero knowledge proofs over the BLS12–381 elliptic curve with the Groth-16 proving scheme on the Neo blockchain.

The article contains:

  1. Circuit implementation of an example cubic equation composed with the help of Consensys gnark high-level API for circuit design. Translation of the composed circuit into a set of mathematical constraints. A set of unit tests demonstrating how to check the circuit correctness.
  2. Golang Verifier smart contract generation for the compiled constraint system and verifying key based on the NeoGo zkpbinding API. Verifier smart contract compilation and deployment to the testing environment using NeoGo neotest package.
  3. Proof of knowledge generation for a set of given public inputs and corresponding secret variables with the help of Consensys gnark/backend package. Converting the resulting proof to the compatible format acceptable by the Verifier smart contract with the help of NeoGo zkpbinding API.
  4. On-chain proof of knowledge verification via deployed Verifier contract invocation with a set of public input witnesses and generated proof.
  5. Tips and tricks for production implementation.

Circuit design

As mentioned above, from a user perspective, a circuit can be described as an algorithm for which you want to prove and verify execution. This circuit can be written in any programming language, but the most common are Rust and Go. However, under the hood a circuit represents a constraint system which is effectively a set of algebraic constraints. For an example algorithm that needs to be proved and verified, we’ll use a simple cubic equation: y = x³ + x + 5. In this case the prover wants to prove that he knows the solution of the mentioned cubic equation without revealing the solution to the verifier.

To define the constraint system we’ll use gnark, an open-source fast ZK-SNARK Go library managed by Consensys: https://github.com/Consensys/gnark. It allows to compile circuits from a high-level Golang API declaration into a constraint system for a wide range of elliptic curves and proving systems: https://docs.gnark.consensys.net/HowTo/write/circuit_structure. Please, refer to the gnark basic concepts for more details on circuit definition and structure and to the gnark documentation for the implementation notes.

The circuit corresponding to the provided cubic equation has the following structure:

package cubic_circuit


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"
)


// CubicCircuit defines a simple circuit x**3 + x + 5 == y
// that checks that the prover knows the solution of the provided equation.
// The circuit must declare its public and secret inputs as frontend.Variable.
// At compile time, frontend.Compile(...) recursively parses the struct fields
// that contains frontend.Variable to build the frontend.ConstraintSystem.
// By default, a frontend.Variable has the gnark:",secret" visibility.
type CubicCircuit struct {
// Struct tags on a variable is optional.
// Default uses variable name and secret visibility.
X frontend.Variable `gnark:"x,secret"` // Secret input.
Y frontend.Variable `gnark:"y,public"` // Public input.
}


// A gnark circuit must implement the frontend.Circuit interface
// (https://docs.gnark.consensys.net/HowTo/write/circuit_structure).
var _ = frontend.Circuit(&CubicCircuit{})


// Define declares the circuit constraints
// x**3 + x + 5 == y.
func (circuit *CubicCircuit) 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
}

The circuit itself is represented by the CubicCircuit Go structure that implements frontend.Circuit interface with a single Define(frontend.API) error method, describing the set of circuit constraints. The circuit declares its public (i.e. available both to prover and verifier) and secret (i.e. available only to the prover) inputs as frontend.Variables.

The declared circuit should then be compiled into a rank-1 constraint system (R1CS) over the BLS12–381 elliptic curve scalar field using the following gnark library API. Note that here and below error handling code is omitted.

var circuit    CubicCircuit

// Compile our circuit into a R1CS (a constraint system).
ccs, err := frontend.Compile(ecc.BLS12_381.ScalarField(), r1cs.NewBuilder, &circuit)

After that the resulting constraint.ConstraintSystem can be used to generate proving and verifying keys and build proofs.

For simple constraint systems like our cubic circuit, the implementation is rather trivial and doesn’t require any tests. However, for more complicated circuits it would be nice to ensure that everything works as expected and even perform some testing proof verification. It can easily be done using the gnark testing engine. The following code snippet demonstrates how to create Go unit tests for the circuit and verify it against a set of various elliptic curves and proving algorithms based on certain input data. Please, refer to the NeoGo cubic circuit tests for more circuit unit test examples.

// TestCubicCircuit_Verification performs the circuit correctness testing over a
// set of all supported curves and backends and over a specified curve with a
// set of exact input and output values.
func TestCubicCircuit_Verification(t *testing.T) {
// Assert object wrapping testing.T.
assert := test.NewAssert(t)


// Declare the circuit.
var cubicCircuit CubicCircuit


// The default behavior of the assert helper is to test the circuit across
// all supported curves and backends, ensure correct serialization, and
// cross-test the constraint system solver against a big.Int test execution
// engine.
assert.ProverFailed(&cubicCircuit, &CubicCircuit{
X: 3, // Wrong value.
Y: 5,
})


// If needed, we can directly specify the desired curves or backends.
assert.ProverSucceeded(&cubicCircuit, &CubicCircuit{
X: 3, // Good value.
Y: 35,
}, test.WithCurves(ecc.BLS12_381))
}

Now, when we have our circuit properly tested and constraint system ready to be used, let’s move on to the proof verification part.

Verifier smart contract implementation

Once the constraint system is compiled, we can build a proving and verifying key pair associated with the circuit. Proving key will be used by the prover to generate input-specific proofs. Verifying key will be used by the verifier to generate a circuit-specific Neo Verifier smart contract and verify proofs of knowledge for the given circuit via the Verifier contract invocation afterwards. Here’s the example of dummy proving and verifying key pair generation via gnark API:

// One time setup (groth16 zk-SNARK), circuit-specific prover and verifier keys generation.
pk, vk, err := groth16.Setup(ccs)

Verifier smart contract represents a simple Neo contract written in Go language that can be compiled by the NeoGo compiler and deployed to the Neo network in a standard way. NeoGo offers a zkpbinding utility package that can be used to generate the circuit-specific Verifier Go smart contract and all the associated files (contract manifest, go.sum and go.mod files) needed for compilation and deployment. Verifier contract generated via the zkpbinding utility contains a single VerifyProof(a []byte, b []byte, c []byte, publicInput [][]byte) bool method that accepts proof of knowledge for the given circuit represented as three serialized compressed BLS12–381 points and the public input information represented as a list of serialized 32-bytes field elements in the LE form. VerifyProof function implements Groth-16 verification algorithm described in the neo-project/neo#2647 issue without any changes. In fact, VerifyProof performs a set of arithmetical operations over provided BLS12–381 points and the public input scalar field elements to verify the given proof. VerifyProof function returns whether the provided proof is valid for the given public input.

The following testing code snippet demonstrates how to generate Verification contract using the NeoGo zkpbinding utility package:

// Create contract file.
tmpDir := t.TempDir()
srcPath := filepath.Join(tmpDir, "verify.go")
f, err := os.Create(srcPath)


// Create contract configuration file.
cfgPath := filepath.Join(tmpDir, "verify.yml")
fCfg, err := os.Create(cfgPath)


// Create contract go.mod and go.sum files.
fMod, err := os.Create(filepath.Join(tmpDir, "go.mod"))
fSum, err := os.Create(filepath.Join(tmpDir, "go.sum"))


// Generate Verifier contract itself.
err = zkpbinding.GenerateVerifier(zkpbinding.Config{
VerifyingKey: vk,
Output: f,
CfgOutput: fCfg,
GomodOutput: fMod,
GosumOutput: fSum,
})

Note, that the usage of NeoGo zkpbinding utility package is not the only way to compose the Verifier contract. You can write it manually yourself in any other programming language supported by the Neo platform (C#, Go, Python, Java, etc.), compile, deploy and invoke it afterward. The structure of Verifier contract is not constrained by any NEP standard and doesn’t require any specific manifest. However, the verification algorithm itself is expected to remain the same from contract to contract, effectively it’s a Groth-16 implementation of proof of knowledge verification process over BLS12–381 elliptic curve described in the neo-project/neo#2647 issue. Since all the Verifier contract implementations share the same batch of code, NeoGo provides a nice tool for the contract template generation. The resulting contract can be compiled and deployed to the Neo network as is without any additional changes (if applicable) or can be used as a basis for more complicated verification contract construction.

Verification contract can be compiled and deployed to the Neo chain in a standard way, e.g. via NeoGo CLI (see the workshop for details on how to deploy and invoke the contract via NeoGo CLI) or via NeoNova NeoGo RPC client (see the workshop code snippet for details on how to deploy a contract with Actor from NeoGo). At the same time, it would be nice to test the auto-generated Verifier contract. It can be done with a little help from the NeoGo neotest framework. The following testing code snippet demonstrates how to create testing environment (similar to a private network) and deploy Verifier contract into it:

// Create testing chain and executor.
bc, committee := chain.NewSingle(t)
e := neotest.NewExecutor(t, bc, committee, committee)


// Compile verification contract and deploy the contract onto the chain.
c := neotest.CompileFile(t, e.Validator.ScriptHash(), srcPath, cfgPath)
e.DeployContract(t, c, nil)

Once the contract is deployed, the verifier can invoke it and verify a set of proofs of knowledge submitted by the prover.

Proof of knowledge generation

Once the prover has a compiled constraint system, it’s possible to generate proofs of knowledge for the corresponding circuit. A set of proofs can be generated for a set of public and private input pairs, but in the example below we’ll demonstrate a single proof generation. For the cubic circuit described above, an example of a valid solution would be the {X: 3, Y: 35} pair where Y value is a public input data known to everyone and X value is the private solution known to the prover only. The prover uses the provided pair and compiled constraint system to generate the proof via gnark API (note, that corresponding backend and a proper scalar field must be used):

// Valid solution.
var assignment = CubicCircuit{X: 3, Y: 35}


// Intermediate step: witness definition.
witness, err := frontend.NewWitness(&assignment, ecc.BLS12_381.ScalarField())


// Proof creation (groth16).
proof, err := groth16.Prove(ccs, pk, witness)

After that, generated proof can be used as an input to the VerifyProof method of the Verification smart contract. However, arguments of VerifyProof must be compatible with NeoVM and the resulting proof structure can’t be passed directly to the contract, it needs a bit of additional conversion. Based on the VerifyProof signature, things we’d like to pass as function arguments are three BLS12–381 serialized points in compressed form and a slice of public witnesses each represented as a serialized 32-bytes field element in the LE form. To convert proof to the suitable form NeoGo offers the following helper:

// Get the public part of input data.
publicWitness, err := witness.Public()


// Retrieve arguments for the Verifier smart contract call.
args, err := zkpbinding.GetVerifyProofArgs(proof, publicWitness)

The resulting args can be directly used to invoke the VerifyProof method of Verifier contract. Thus, nothing prevents the verifier from checking whether the provided proof of knowledge is correct.

Proof of knowledge verification

Given all the previous steps properly completed, by the moment of verification the verifier should have the BLS12–381 circuit-specific Verifier contract deployed to the Neo chain and a proof received from the prover in a suitable format. The verification itself comes down to a simple VerifyProof method call with provided arguments. It can either be a test invocation or a normal invocation via transaction made via any known tool capable of performing contract invocations (RPC call via CURL, NeoGo CLI contract testinvokefunction command, program call via NeoGo RPC client/Invoker/Actor or via NeoC# RPC client or via any other RPC client). Here’s the example of such Verifier contract call made in previously created testing environment using neotest package:

// Verify proof via verification contract call.
validatorInvoker := e.ValidatorInvoker(c.Hash)
validatorInvoker.Invoke(t, true, "verifyProof", args.A, args.B, args.C, args.PublicWitnesses)

As you can see, the expected true result is left on stack after the invocation which means that the provided proof for the given public input data is valid.

Please, refer to the NeoGo cubic circuit example for the full circuit code and end-to-end tests: example.

Notes and caveats for production usage

The Trusted Setup Ceremony and CRS practical usage

The example above was implemented in a testing environment and aimed to demonstrate the end-to-end pipeline of zero knowledge proof generation and verification on the Neo chain. Generation of the proving and verifying keys in the example above requires some randomness to be precomputed (it is done inside the groth16.Setup method call and known as a Common Reference String or a Structured Reference String). If the process or machine leaks this randomness, an attacker could break the ZKP protocol. Thus, for production usage we strictly do NOT recommend this approach. Instead, Groth16 ZK-SNARK circuits usually require an MPC (Multi-Party Computation) Trusted Setup ceremony to generate the parameters that can kick off ZK-SNARKs-based systems.

Any Trusted Setup is organized in two subsequent steps: Phase 1 and Phase 2. The Phase 1 which is also known as the “Powers of Tau” is universally reusable in any point of contribution as an input for any ZK-SNARK. Phase 2, in contrast, must be performed for each individual circuit and requires the response from the Phase 1 as an input. Neo project core developers have decided not to take their own Powers of Tau ceremony; usage of some external trusted source of randomness is suggested to set up the second phase, for example, the response generated by Filecoin’s Powers of Tau ceremony can be used (see the attestations for it). Note that both phases are curve-specific which means that in the case of Neo ecosystem only BLS12–381-specific results are suitable (at least, at the moment of writing). Here’s the set of steps that every dApp developer team should follow for proper CRS setup and reliable proving/verifying keys generation:

  1. Take the Phase 1 output of some external trusted Powers of Tau ceremony over BLS12–381 elliptic curve that is suitable for your circuits, i.e. contains computations of as many powers of tau as needed for the most heavy of your circuits (the circuit with the largest number of constraints). For example, we can choose from the response files generated by the Filecoin’s Powers of Tau ceremony (IPFS link, attestations and response files link) or by any other good PoT ceremony that you trust. Please note that this ceremony MUST be taken over the BLS12–381 elliptic curve for Neo interoperable functionality compatibility.
    An alternative to the way mentioned above would be to setup your own Powers of Tau ceremony with required parameters as described in the guide.
  2. Once the Phase 1 parameters are chosen, you can move on to the Phase 2 of the trusted setup which is circuit-specific. Contribute some randomness to the response file from the previous step following the test example and documentation (if working with Go stack) or using any other Phase 2 MPC implementation that is suitable for your team, organisation and stack, e.g. Filecoin’s Phase 2 implementation.
  3. Generate proving and verifying keys based on the parameters obtained from the previous step. If using Go stack, then take a look at the Groth-16 demonstration example and, in particular, at the production CRS setup in test.

For more details on CRS generation for Neo dApps in production environment see the discussion in the neo-project/neo#2647 issue. Note, that currently NeoGo doesn’t have a fully-qualified CLI utility for organizing the MPC contribution ceremony over BLS12–381 curve from scratch (like it is done for BN254 elliptic curve in https://github.com/bnb-chain/zkbnb-setup), but it’s possible to port the mentioned solution to BLS12–381 curve. Thus don’t hesitate to contact us if you need this feature (issues and patches to https://github.com/nspcc-dev/neo-go are welcome).

Circuit development toolkit

You can use any other tools to design circuits and generate proofs (circom, arkworks, etc.), gnark and NeoGo usage isn’t required for this. However, assuming that NeoSPCC provides the Neo development toolkit for writing/deploying/invoking smart contracts in Go programming language, this article demonstrates the easiest way of integrating the existing Groth16 Golang development tools with the NeoGo toolkit.

More complicated circuits and constraints

The example above has almost no practical value except that it demonstrates how to use the set of tools provided by NeoGo for ZKP dApps integration with the Neo platform. Production environments are likely to demand more complicated scenarios to prove and more complicated circuits to implement, e.g. ZK-friendly hashing algorithms, Merkle proofs verification, EdDSA signature verification, ZK-rollups assistance, etc. All of this (and not only) can be implemented via the powerful gnark circuit design API. For more elaborate circuit examples we highly recommend reading the set of Write circuits documentation articles and carefully reviewing the set of gnark circuit examples.

However, note that while circuits are programmable, they can’t (practically and efficiently) prove any algorithm. The way constraints are represented make some things more natural (“snark-friendly”) to do than others. Please, review the circuit design considerations.

Conclusion

This article is aimed to be the first step for those developers who start their journey into the ZKP universe of the Neo blockchain. Hopefully, this article gives enough information to build your own ZK application for the Neo platform. Please, note that the described NeoGo Groth-16 functionality is relatively new to the project and may be rough on the edges. Subsequent extension and polish of NeoGo ZK functionality is also possible. Thus, don’t hesitate to contact us on Discord or file an issue, any related questions, comments, suggestions and feedback are welcome. Build, test and run your applications with the NeoGo toolkit, happy proving with ZK-SNARKs!

References

--

--