Analysis of zk-Snark engineering principles based on Gnark
Author: 0x60018 from @OrbiterResearch
1. How to use Gnark
Example:
Hash = mimc(PreImage)
mimc is the hash algorithm, providing Hash and proof to make the verifier be sure of the prover knows the PreImage.
1.1 Define the circuit
// CubicCircuit defines a simple circuit
type CubicCircuit struct {
PreImage frontend.Variable
Hash frontend.Variable `gnark:",public"`
}
// Define declares the circuit constraints
func (circuit *CubicCircuit) Define(api frontend.API) error {
mimc, _ := mimc.NewMiMC(api)
mimc.Write(circuit.PreImage)
api.AssertIsEqual(circuit.Hash, mimc.Sum())
return nil
}
Q1: Is there a more general Turing-complete language for writing circuits?
Q2: Is that what the Cairo language does for this purpose?
1.2 Compile the circuit and generate R1CS constraints
Note: In addition to the BN254 curve, there are also curves such as BLS12–381.
Detailed comparison: https://docs.gnark.consensys.net/en/latest/Concepts/schemes_curves/
The mathematical rationale for generating R1CS constraints is complicated and still needs further research.
// Generate constraints using BN254 curves
r1cs, err := frontend.Compile(ecc.BN254, r1cs.NewBuilder, &circuit)
1.3 Generate ProvingKey, VerifyingKey
pk, vk, err := groth16.Setup(r1cs)
1.4 Generates Solidity file of Verifier using VerifyingKey
verifySolidityPath := fmt.Sprintf("..%chardhat%ccontracts%cmimc_groth16.sol", os.PathSeparator, os.PathSeparator, os.PathSeparator)
f, _ := os.OpenFile(verifySolidityPath, os.O_CREATE|os.O_WRONLY, 0666)
defer f.Close()
vk.ExportSolidity(f)
1.5 Generate proof by applying Prove to PreImage, Hash
assignment := &CubicCircuit{PreImage: preImage, Hash: hash}
witness, _ := frontend.NewWitness(assignment, ecc.BN254)
publicWitness, _ := witness.Public()
proof, err := groth16.Prove(r1cs, pk, witness)
1.6 Save proof and publicWitness
// Save publicWitness
publicWitnessPath := fmt.Sprintf("..%chardhat%ctest%cmimc_public_witness.json", os.PathSeparator, os.PathSeparator, os.PathSeparator)
publicWitnessFile, _ := os.OpenFile(publicWitnessPath, os.O_CREATE|os.O_WRONLY, 0666)
defer publicWitnessFile.Close()
publicWitnessJson, _ := publicWitness.MarshalJSON()
publicWitnessFile.Write(publicWitnessJson)
// Save proof
proofPath := fmt.Sprintf("..%chardhat%ctest%cmimc.proof", os.PathSeparator, os.PathSeparator, os.PathSeparator)
proofFile, _ := os.OpenFile(proofPath, os.O_CREATE|os.O_WRONLY, 0666)
defer proofFile.Close()
proof.WriteRawTo(proofFile)
1.7 Send proof and publicWitness to the contract for verification
import { ethers } from "hardhat";
import { Signer, BigNumber, Contract, ContractFactory } from "ethers";
// import "ethers";
import { expect } from "chai";
import fs from "fs-extra";
import path from "path";
describe("mimc", function () {
let accounts: Signer[];
let contractFactory: ContractFactory;
let verifierContract: Contract;
before(async function () {
accounts = await ethers.getSigners();
contractFactory = await ethers.getContractFactory("Verifier");
});
it("deploy", async function () {
verifierContract = await contractFactory.deploy();
});
it("verifyProof", async function () {
const fpSize = 4 * 8;
const inputBuffer = await fs.readFile(
path.resolve(__dirname, "mimc_public_witness.input")
);
const input = JSON.parse(inputBuffer.toString()).Hash;
console.warn(input);
const proofBuffer = await fs.readFile(
path.resolve(__dirname, "mimc.proof")
);
const a: Buffer[] = [];
a[0] = proofBuffer.slice(fpSize * 0, fpSize * 1);
a[1] = proofBuffer.slice(fpSize * 1, fpSize * 2);
const b: Buffer[][] = [[], []];
b[0][0] = proofBuffer.slice(fpSize * 2, fpSize * 3);
b[0][1] = proofBuffer.slice(fpSize * 3, fpSize * 4);
b[1][0] = proofBuffer.slice(fpSize * 4, fpSize * 5);
b[1][1] = proofBuffer.slice(fpSize * 5, fpSize * 6);
const c: Buffer[] = [];
c[0] = proofBuffer.slice(fpSize * 6, fpSize * 7);
c[1] = proofBuffer.slice(fpSize * 7, fpSize * 8);
const resp = await verifierContract.verifyProof(a, b, c, [input]);
expect(resp).to.be.true;
});
});
In the above example, we can see that only proof and public input (Hash value) are submitted off-chain. The contract can verify whether the value of PreImage is known off-chain, which can realize privacy proof. But there are still some issues to be resolved:
Q1: Split the proof into a, b, and c. The function is to check that the elements in a, b, and c cannot be greater than or equal to PRIME_Q (value: 21888242871839275222246405745257275088696311157297823662689037894645226208583) during contract verification. What is the mathematical principle? Rules for R1CS constraints?
Q2: Proof generation in the above example needs to be generated by go language, so the proof cannot be generated on the web client. How can this be achieved using TypeScript? Can it be imported from gnark or is there another library that implements it? Is there a reference to Tornado.cash?
A2: Currently, snarkjs and zokrates use JavaScript to implement the zk-snark function, and Tornado.cash uses snarkjs
Q3: How to implement zk-rollup with zk-snark technology? or a merkle tree proof circuit? What is the relationship between merkle and zk-rollup (or no relationship at all?)
A3: zk-rollup uses merkle tree to store user amount, public key, nonce value and other information. The Operator converts the transaction into a state and generates a zk-snark proof to submit to the L1 contract for verification and change the user state (simple version)
2. Gnark source code structure
The following structural analysis is based on Gnark v0.7.1
Q: The operation functions provided under frontend => api.go are limited, and Turing-complete zk programming cannot be provided for the time being. Is circom more complete?