The Use of zk-SNARKs in Solidity#
Article Author: @BoxMrChen, welcome to reprint, please indicate the source.
Article GitHub Repository: https://github.com/nishuzumi/blog welcome to Star. If there are any errors in the article, feel free to submit a PR.
Join the discussion group: Please add personal WeChat Im3boxtech, noteJoin group
, and I will add you to the discussion group.
This article mainly discusses how to use zk-SNARKs in Solidity and how to use the ZoKrates compiler to generate proofs and verification contracts.
This article will not delve too deeply into the technical principles of zk-SNARKs; the purpose is to help readers understand what effects zk-SNARKs can achieve in the EVM, how to use them, and how to apply them in code.
Introduction to zk-SNARKs#
A brief description of zk-SNARKs is that we need to write a piece of code in the zk circuit, where the input is some public data, and the output is some private data. The verification algorithm of zk-SNARKs can verify whether the output of this code is correct, but the verification algorithm does not leak any private data. The main purpose of the Solidity contract is to verify the results of the zk-SNARKs verification algorithm; if the result of the verification algorithm is correct, then the contract will execute some operations.
In other words, on the EVM, only the result is verified, and no complex calculations are performed; these calculations are all done in the zk circuit. The zk circuit is performed off-chain, and then the results are submitted on-chain.
Using zk-SNARKs in Solidity#
First, we need to know what functions zk-SNARKs can accomplish. In fact, it is quite simple; we can simply think of zk-SNARKs as a way to verify the output of a function. For example, we have a function where the input is three numbers, and the output is one number. We can use zk-SNARKs to verify whether the output of this function is correct. However, we do not need to know what the three input numbers are; we only need to know the output of this function. This means that when a function completes its calculation, we can know that there are indeed three numbers that satisfy the function's input and can produce the correct output, but we do not know what those three numbers are.
In Solidity, we can use zk-SNARKs to verify a function, but we need to know the function's input and output. Then we can use the ZoKrates compiler to generate the zk circuit, place the zk circuit code into the Solidity contract, and then complete the verification of the zk circuit within the contract.
Installing the ZoKrates Compiler#
Install ZoKrates
curl -LSfs get.zokrat.es | sh
You can also choose other installation methods; please refer to their GitHub page for details.
Writing the zk Circuit#
From the previous chapter, we understand that the most basic requirements for a zk-SNARKs circuit are:
- A function - We need a function to perform calculations on the data, which is program C.
- Lambda - The so-called "toxic waste," which is actually a root key that we need to generate pk and vk.
With these two basic conditions, users can generate proof w using pk, target value, and input values.
Subsequently, our verification program verifies the correctness of the proof using vk, target value, and proof w.
Let’s assume there is a third party that can safely generate lambda and securely compute vk and pk using the program and lambda.
Now there are two new roles: user and project. The user is the one who actually possesses some data, and the project is the contract that needs to verify whether the user's data is correct.
A Function#
We first need a function, but I do not intend to provide simple examples, as I believe this is quite meaningless. The main purpose of zk-SNARKs is to verify some complex functions, not simple ones.
For example, we now need to generate a deposit certificate. With this certificate, we can withdraw this money anywhere, but we do not know whose money it is. We only know who deposited the money, how much was deposited, and when the deposit was made.
First, we need a deposit function. The input of this function is the deposit amount and a random number, and the output is a deposit certificate. Anyone who possesses this certificate can withdraw the funds. Therefore, in reality, we only need to write a verification function to verify this certificate.
import "hashes/sha256/512bit" as sha256;
import "utils/pack/u32/nonStrictUnpack256" as unpack256;
// deposit_amount: deposit amount
// secret: random number
// returns: commitment for withdrawal
def main(field deposit_amount, private field secret) -> u32[8] {
return sha256(unpack256(deposit_amount), unpack256(secret));
}
There is no need to elaborate on the syntax and usage of Zok here; you can refer to the official website for details. To briefly explain, the input of this function consists of two numbers: one is the deposit amount, and the other is a random number. The output is a u32[8], which is essentially uint256. Also, note that the parameter deposit_amount does not have the private keyword, indicating that this parameter is public data.
Compiling the File#
This part is described in ZoKrates as follows:
# compile
zokrates compile -i deposit.zok
# perform the setup phase
zokrates setup
# execute the program
zokrates compute-witness -a 337 113569
# generate a proof of computation
zokrates generate-proof
# export a solidity verifier
zokrates export-verifier
# or verify natively
zokrates verify
After running, a bunch of files will be generated. We need proof.json, proving.key, verification.key, verifier.sol, and out.
Most of these are template files; the only difference may be in the verifyingKey of the Verifier contract. However, reading this file is not very meaningful since it contains a lot of numbers and calculations.
In fact, the content we need to look at is these ∑
function verifyTx(
Proof memory proof, uint[8] memory input
) public view returns (bool r) {
uint[] memory inputValues = new uint[](8);
for(uint i = 0; i < input.length; i++){
inputValues[i] = input[i];
}
if (verify(inputValues, proof) == 0) {
return true;
} else {
return false;
}
}
We can see that we need two parameters, proof and input. As for what these two parameters are for, we will not delve into that for now. However, we need to note that all public parameters will be added to this array in the beginning.
For example, the automatically generated proof.json file is a valid data.
{
"scheme": "gm17",
"curve": "bn128",
"proof": {
"a": [
"0x05a83e3c3b3ff9d59bdffdcf7aa655f42b941b0063f82cf26516846056d09aa6",
"0x018039b7de92979ef6251c877971888ae049d09a6b48e5aa98c23ef91550ed36"
],
"b": [
[
"0x1e88e783456a27e4f02dde8c742610339e395eb0bbf7f7efc1113815dcf0a16f",
"0x1cc9de9e60c6519ea69c9b3a71c0809ac7ae3389a598d66fc27d378738d5de29"
],
[
"0x0715544abbc18e741620ff7c76cb2a7d3558ee157d23f275ab65c43c25357d07",
"0x0344257236ba33a3ce7ce34b8d518f7572984036db6f77fc2fc13f51c548a837"
]
],
"c": [
"0x177113e528c76661a03a8f3f072f29e684244297a62926a0000d3a7135c1441f",
"0x18cf275d0bc621473688848946584af771afca42e4f2bd0ef1e5d06e0adefd0f"
]
},
"inputs": [
"0x0000000000000000000000000000000000000000000000000000000000000151",
"0x00000000000000000000000000000000000000000000000000000000bb3eada7",
"0x000000000000000000000000000000000000000000000000000000004b704815",
"0x00000000000000000000000000000000000000000000000000000000cddda451",
"0x00000000000000000000000000000000000000000000000000000000ca701d2a",
"0x000000000000000000000000000000000000000000000000000000001f278e64",
"0x00000000000000000000000000000000000000000000000000000000ef16f074",
"0x0000000000000000000000000000000000000000000000000000000040e13298",
"0x0000000000000000000000000000000000000000000000000000000026c5da72"
]
}
At this point, we can write a simple contract.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {Verifier} from "./verifier.sol";
contract Master {
event Deposit(uint256 commitment, uint amount);
mapping(uint => uint) public proofs;
Verifier v;
constructor() {
v = new Verifier();
}
function deposit(uint commitment) public payable {
proofs[commitment] = msg.value;
emit Deposit(commitment, msg.value);
}
function withdraw(
uint commitment,
Verifier.Proof memory proof,
uint[9] memory inputs
) public {
uint amount = inputs[0];
require(v.verifyTx(proof, inputs));
require(proofs[commitment] == amount);
payable(msg.sender).transfer(amount);
}
}
It is important to note that there will be two pragma solidity
in the Verifier contract; remember to delete the one in the middle and keep the top one, otherwise the compilation will fail.
Testing#
First, we need to understand the standard process. We need to first compile, set up, then compute the witness, generate the proof, and finally export the verifier.
However, this entire process is not necessary every time, as this is a complete workflow. We need to make some distinctions.
Necessary Conditions#
- compile - Compile the zk circuit - This only needs to be executed once. This function will generate out files and abi.json files, which are the compiled program.
- setup - Generate the pk and vk of the zk circuit - This only needs to be executed once. This function will generate proving.key and verification.key files, which are the public and private keys of the zk circuit. In fact, during the setup, lambda will be produced, but we do not need to worry too much about this process.
Conditions for Submitting Proof#
- compute-witness - Generate proof - This function will generate a witness file, which is an intermediate file.
- generate-proof - Generate the proof of computation - This function will generate a proof.json file, which contains the content that needs to be submitted, generally the parameters we need to submit to the chain.
Conditions for Accepting Proof#
- export-verifier - Generate verifier.sol - This function will generate a verifier.sol file, which is a contract that we need to deploy on the chain and call this contract in our contract to verify the correctness of the proof.
- verify - Local verification - This function will verify the correctness of the proof, but it will not generate any files.
Writing the File#
Based on the above content, we can write some unit test logic for testing.
import { expect } from "chai";
import { ethers } from "hardhat";
import { Verifier } from "../typechain-types/Verifier";
import { CompilationArtifacts, ZoKratesProvider } from "zokrates-js";
import { readFileSync } from "fs";
import { Master } from "../typechain-types";
import { resolve } from 'path'
describe("Verifier", function () {
let master: Master;
let zokratesProvider: ZoKratesProvider
const zokArtifacts: CompilationArtifacts = {
program: readFileSync(resolve(__dirname, '../zok/out')),
abi: JSON.parse(readFileSync(resolve(__dirname, '../zok/abi.json'), 'utf-8'))
}
const provingKey = readFileSync(resolve(__dirname, '../zok/proving.key'))
beforeEach(async () => {
const { initialize } = await import("zokrates-js");
zokratesProvider = (await initialize()).withOptions({
backend: 'ark',
curve: 'bn128',
scheme: 'gm17'
});
const bn256 = await ethers.getContractFactory("BN256G2").then((f) => f.deploy());
master = await ethers.getContractFactory("Master", {
libraries: {
"contracts/verifier.sol:BN256G2": bn256.address,
}
}).then((f) => f.deploy());
})
it("should verify a proof", async () => {
const { witness, output } = zokratesProvider.computeWitness(
zokArtifacts,
[`${ethers.constants.WeiPerEther}`, '23'],
)
const commitment = hexListToUint256BigEndian(JSON.parse(output)).toString();
await master.deposit(
commitment,
{ value: ethers.constants.WeiPerEther }
)
const proof = zokratesProvider.generateProof(
zokArtifacts.program,
witness,
provingKey);
const sender = (await ethers.getSigners())[0];
expect(() => master.connect(sender).
withdraw(commitment, proof.proof as Verifier.ProofStruct, proof.inputs)
).to.changeEtherBalance(sender, ethers.constants.WeiPerEther);
})
});
function hexListToUint256BigEndian(hexList: string[]) {
let uint256Data = "0x";
for (const hex of hexList) {
const cleanedHex = hex.replace("0x", "");
uint256Data += cleanedHex;
}
const uint256BigNumber = ethers.BigNumber.from(uint256Data);
return uint256BigNumber;
}
The project's basic files are located at: https://github.com/nishuzumi/blog/tree/main/sources/zk, welcome to Star.