Guide to Crafting Your First Zero Knowledge Contract

Kartik Jain
14 min readAug 13, 2023

--

Buckle up and clear your thoughts, as we step into the realm of zero-knowledge.

Welcome to the world of zero knowledge

Introduction

In recent months, Zero Knowledge (ZK) cryptography has captured significant attention. With the introduction of new zkRollups and zkEVMs, the cryptocurrency community has focused on the potential of zero knowledge proofs to offer elegant answers to the challenges of privacy and scalability within blockchain technology.

Note: The primary aim of this article is not to provide a guide on writing circuits in Circom, but rather to describe the sequence of actions involved in transforming code written in Circom into a usable component for someone’s dApp.

For a better grasp of Circom refer the documentation.

What we’ll be building

We’ll be building a simple dApp that checks if a two numbers that you submit are between 0 and 5, and that they are not equal to each other. You’ll generate a ZK proof in the browser and submit only the proof in the transaction, so no one on the internet or even the contract itself will know the two numbers that were submitted, only that they matched the constraints above.

Before we start building, let’s review a few things so that we are standing on a solid foundation…

What is Zero Knowledge Proof?

Zero-knowledge proof (ZKP) is a cryptography method in which one party called the prover can prove to another party called the verifier that they know a set of information without ever showing the actual information to the verifier.

Example: Opaque Pricing

Imagine you and a competitor both buy materials from the same supplier. You want to know if you’re both paying the same price per kilogram, but you don’t trust each other enough to share your prices due to contractual obligations.

Assuming the market rate for the materials can only be 100, 200, 300 or 400 per kilogram, we can set up a zero-knowledge proof for this situation.

  1. You and a competitor want to know if you are paying the same price without revealing how much each of you are paying.
  2. We have four locked boxes, each with a small slot that can take only a piece of paper. They are labeled with a possible price: 100, 200, 300, and 400 per kilogram. These boxes are in a secure and private room.
  3. You go into the room first. Since you pay 200 per kilogram, you take the key to the locked box labeled 200, and get rid of the other keys. Then you leave.
  4. Your competitor enters the room with four pieces of paper: one with a checkmark and three with X’s. He is paying 300 per kilogram, so he put the check-marked paper into the box labeled 300 and the X-marked papers into the other boxes. He leave.
  5. After your competitor leaves, you come back with your key that only opens the box labeled 200. You find an X-marked paper inside. Now you know your competitor’s price isn’t the same as yours.
  6. Your competitor comes back and sees you found an X-marked paper. They realize your prices aren’t the same either.

In this way, without revealing the actual prices, you both use a clever method to figure out that you’re not paying the same amount for the materials. This concept of finding out information without sharing sensitive details is a bit like how zero-knowledge proofs work in cryptography.

How do zero-knowledge proofs work?

A zero-knowledge proof allows you to prove the truth of a statement without sharing the statement’s contents or revealing how you discovered the truth. To make this possible, zero-knowledge protocols rely on algorithms that take some data as input and return ‘true’ or ‘false’ as output.

A zero-knowledge protocol must satisfy the following criteria:

  1. Completeness: If the input is valid, the zero-knowledge protocol always returns ‘true’. Hence, the proof can be accepted.
  2. Soundness: If the input is invalid, it is theoretically impossible to fool the zero-knowledge protocol to return ‘true’.
  3. Zero-knowledge: The verifier learns nothing about a statement beyond its validity or falsity (they have “zero knowledge” of the statement).

To get into the more depth, you can visit here.

Lets Get Started…

Alright, let’s drop the fancy talk and dive into the code-writing adventure!

I’ll break this down into three sections, in order to make it easier to follow.

The Circuit section discusses everything about writing the ZK circuit in Circom, which we then export to the Contract section, which interacts with the Frontend section. I haven’t constructed the front end here; instead, I’ll employ test cases to try out and mimic the front-end functionality.

I will use Circom (for circuits), Solidity (for smart contracts) and Typescript for test cases.

To build the zk dapp we will use Groth16 proving system which is faster than Plonk.

Here’s a link to the repo:

https://github.com/kartikjain-sudo/zkp-multiplication

Install dependencies

These are some important dependencies that we will use:

1. Project Setup

Let’s first create the project folder. We’ll use hardhat.

Create a folder for the project using: mkdir multiplier

Go to the project directory cd multiplierand use: npx hardhat init

Setting for Hardhat Setup

After setting up Hardhat, you’ll want to establish few folders in the main directory for Circuits.

mkdir circuits circuits/circuit circuits/scripts circuits/test

Add few other dependencies which will be needed later:

npm install — save-dev “typescript” “ts-node” “@nomicfoundation/hardhat-chai-matchers@¹.0.0” “@nomiclabs/hardhat-ethers@².0.0” “@types/chai@⁴.2.0” “@typechain/ethers-v5@¹⁰.1.0” “@typechain/hardhat@⁶.1.2” “hardhat-gas-reporter@¹.0.8” “solidity-coverage@⁰.8.1” “typechain@⁸.1.0

npm install circomlib circom_tester chai mocha @types/mocha

Here’s a sneak peek at what your project structure will resemble. Don’t worry about the files for now; we’ll be adding them in later.

2. Circuit

2. 1 Setup

Make certain your project structure looks just like the one above. If not, the scripts we’ll be adding shortly might not run properly. Confirm that you’re in the multiplier/circuits location (within the terminal), as we’ll be focusing on the contents of the circuits folder in this section.

2.2 Write circuit

As explained above, the circuit that we’ll be writing today that takes two inputs, ensures that they’re both between 0 and 5, and also ensures that they’re both not equal to each other. It will then multiply and output the two values. Inline comments have been added to the circom file below:

Note: the line that says // file: <filename>at the top is not actually part of the file, it’s only telling you where the file is located with respect to the project root.

// file: /circuits/circuit/multiplier.circom

pragma circom 2.1.3;

include "../../node_modules/circomlib/circuits/comparators.circom";

template SimpleMultiplier() {
// Private input signals
signal input in[2];

// Output signal (public)
signal output out;

// Create a constraint here saying that our two input signals cannot
// equal each other.
component isz = IsZero();
isz.in <== in[0] - in[1];

// The IsZero component returns 1 if the input is 0, or 0 otherwise.
isz.out === 0;

// Define the greater than and less than components that we'll define
// inside the for loop below.
component gte[2];
component lte[2];

// We loop through the two signals to compare them.
for (var i = 0; i < 2; i++) {
// Both the LessEqThan and GreaterEqThan components take number of
// bits as an input. In this case, we want to ensure our inputs are
// [0,5], which requires 3 bits (101).
lte[i] = LessEqThan(3);

// We put our circuit's input signal as the input signal to the
// LessEqThan component and compare it against 5.
lte[i].in[0] <== in[i];
lte[i].in[1] <== 5;

// The LessEqThan component outputs a 1 if the evaluation is true,
// 0 otherwise, so we create this equality constraint.
lte[i].out === 1;

// We do the same with GreaterEqThan, and also require 3 bits since
// the range of inputs is still [0,5].
gte[i] = GreaterEqThan(3);

// Compare our input with 0
gte[i].in[0] <== in[i];
gte[i].in[1] <== 0;

// The GreaterEqThan component outputs a 1 if the evaluation is true,
// 0 otherwise, so we create this equality constraint.
gte[i].out === 1;
}

// Write a * b into c and then constrain c to be equal to a * b.
out <== in[0] * in[1];
}

component main = SimpleMultiplier();

Add another file input.json next to multiplier.circom .

Note: Input can be anything between 0 and 5, and both the number should be different.

{
"in": [2,5]
}

2.3 Writing Scripts

Once you are done with the circuits, you need to compile the circuit and generate the intermediate files to generate proofs.

Note: All the .sh files created inside the multiplier/circuits/scripts folder, are generic, so you can use them in your circuits.

Add these scripts in the scripts folder inside circuits.

This is a prerequisite script to check if the circom has been installed properly.

To run this script use ./scripts/01_prerequisite.sh , for the first time you run the script use chmod u+x ./scripts/01_prerequisite.sh

Note: As mentioned above make sure your terminal location is multiplier/circuits

# file: /circuits/scripts/01_prerequisite.sh

#!/bin/bash

if ! command -v circom &> /dev/null
then
echo "Circom could not be found. Visit https://docs.circom.io/getting-started/installation/ and install circom2"
exit 1
else
echo "Yay, You already have Circom installed!"
exit 1
fi

This script will compile the circuit and it will generate the intermediate files in the build folder under multiplier/circuits.

You can use the 02_compile.sh script by running the file and passing it the name of the circuit: ./scripts/02_compile.sh multiplier. Or you can edit the CIRCUIT variable inside the 02_compile.sh file with the name of your circuit and run: ./scripts/02_compile.sh. The first time you run the script, you should run: chmod u+x ./scripts/02_compile.sh.

# file: /circuits/scripts/02_compile.sh

#!/bin/bash

# Variable to store the name of the circuit
CIRCUIT=''

FOLDER_PATH='build'

# In case there is a circuit name as input
if [ "$1" ]; then
CIRCUIT=$1
fi

# Create a build Folder
if [ ! -d "$FOLDER_PATH" ]; then
mkdir ${FOLDER_PATH}
fi

# Compile the circuit
circom ./circuit/${CIRCUIT}.circom --r1cs --wasm --sym --c -o ${FOLDER_PATH}

# Generate the witness.wtns
node ${FOLDER_PATH}/${CIRCUIT}_js/generate_witness.js ${FOLDER_PATH}/${CIRCUIT}_js/${CIRCUIT}.wasm ./circuit/input.json ${FOLDER_PATH}/${CIRCUIT}_js/witness.wtns

This script will generate witness and other intermediate keys needed. It will download the power of tau if not present in the project. Power of Tau will be chosen based on constraints. Update the PTAU value in script to download the correct PTAU.

It is a generic file which can be used with any circom project that uses Groth16 proving system.

If you want to run a circuit called circuit.circom with the ptau 12, you can run the script like this: .scripts/03_executePOT.sh circuit 12 or you can also modify the CIRCUIT and PTAU variables like this: CIRCUIT=circuit and PTAU=12.

This script will create 2 folders (keys, ptau) and will store all the generated files here in both of these folders.

# file: /circuits/scripts/03_executePOT.sh

#!/bin/bash

# Variable to store the name of the circuit
CIRCUIT=''

FOLDER_PATH='keys'

BUILD='build'

# Variable to store the number of the ptau file
PTAU=15

# In case there is a circuit name as an input
if [ "$1" ]; then
CIRCUIT=$1
fi

# In case there is a ptau file number as an input
if [ "$2" ]; then
PTAU=$2
fi

# Create a build Folder
if [ ! -d "$FOLDER_PATH" ]; then
mkdir ${FOLDER_PATH}
fi

# Check if the necessary ptau file already exists. If it does not exist, it will be downloaded from the data center
if [ -f ./ptau/powersOfTau28_hez_final_${PTAU}.ptau ]; then
echo "----- powersOfTau28_hez_final_${PTAU}.ptau already exists -----"
else
echo "----- Download powersOfTau28_hez_final_${PTAU}.ptau -----"
wget -P ./ptau https://hermez.s3-eu-west-1.amazonaws.com/powersOfTau28_hez_final_${PTAU}.ptau
fi

echo "----- Generate .zkey file (Proving key) -----"
# Generate a .zkey file that will contain the proving and verification keys together with all phase 2 contributions
snarkjs groth16 setup ${BUILD}/${CIRCUIT}.r1cs ptau/powersOfTau28_hez_final_${PTAU}.ptau ${FOLDER_PATH}/${CIRCUIT}_0000.zkey

echo "----- Contribute to the phase 2 of the ceremony -----"
# Contribute to the phase 2 of the ceremony
snarkjs zkey contribute ${FOLDER_PATH}/${CIRCUIT}_0000.zkey ${FOLDER_PATH}/${CIRCUIT}_final.zkey --name="1st Contributor Name" -v -e="some random text"

echo "----- Export the verification key -----"
# Export the verification key
snarkjs zkey export verificationkey ${FOLDER_PATH}/${CIRCUIT}_final.zkey ${FOLDER_PATH}/verification_key.json

echo "----- Generate zk-proof -----"
# Generate a zk-proof associated to the circuit and the witness. This generates proof.json and public.json
snarkjs groth16 prove ${FOLDER_PATH}/${CIRCUIT}_final.zkey ${BUILD}/${CIRCUIT}_js/witness.wtns ${FOLDER_PATH}/proof.json ${FOLDER_PATH}/public.json

echo "----- Verify the proof -----"
# Verify the proof
snarkjs groth16 verify ${FOLDER_PATH}/verification_key.json ${FOLDER_PATH}/public.json ${FOLDER_PATH}/proof.json

echo "----- Generate Solidity verifier -----"
# Generate a Solidity verifier that allows verifying proofs on Ethereum blockchain
snarkjs zkey export solidityverifier ${FOLDER_PATH}/${CIRCUIT}_final.zkey ${CIRCUIT}Verifier.sol
# Update the solidity version in the Solidity verifier
sed -i 's/0.6.11;/0.8.4;/g' ${CIRCUIT}Verifier.sol
# Update the contract name in the Solidity verifier
sed -i "s/contract Verifier/contract ${CIRCUIT^}Verifier/g" ${CIRCUIT}Verifier.sol
# Moving the verifier into the contracts folder
mv ./${CIRCUIT}Verifier.sol ../contracts

echo "----- Generate and print parameters of call -----"
# Generate and print parameters of call
cd ./${FOLDER_PATH} && snarkjs generatecall | tee parameters.txt && cd ..

Note: To learn how the above file was created, read the snarkjs documentation.

All the generated folders (build, keys and ptau should be added to gitignore files, as they are the private files for the prover, if they become, anyone can generate the proofs.)

2.4 Test Circuits

Inside the ./circuits/test folder, create a multiplier.test.ts file and add to it:

To run the file use mocha ./test/multiplier.test.ts

// file: ./circuits/test/multiplier.test.ts

const { assert } = require("chai");
const wasm_tester = require("circom_tester").wasm;

describe("Multiplier circuit", function () {
let multiplierCircuit;

before(async function () {
multiplierCircuit = await wasm_tester("circuit/multiplier.circom");
});

it("Should generate the witness successfully", async function () {
const input = {in: [1, 5]};
const witness = await multiplierCircuit.calculateWitness(input);
await multiplierCircuit.assertOut(witness, {});
});

it("Should fail because there is a number out of bounds", async function () {
const input = {in: [4, 5, 7]};
try {
await multiplierCircuit.calculateWitness(input);
} catch (err) {
// console.log(err);
assert(err.message.includes("Too many values for input signal in"));
}
});
it("Should fail because input values ar less than expected", async function () {
const input = {
in: [4]
}
try {
await multiplierCircuit.calculateWitness(input);
} catch (err) {
// console.log(err);
assert(err.message.includes("Not enough values for input signal in"));
}
});
it("Should fail because both the numbers are equal", async function () {
const input = {
in: [2, 2]
};
try {
const a = await multiplierCircuit.calculateWitness(input);
} catch (err) {
// console.log(err);
assert(err.message.includes("Assert Failed"));
}
});
it("Should fail because the number is greater than 5", async function () {
const input = {
in: [2, 7]
};
try {
const a = await multiplierCircuit.calculateWitness(input);
} catch (err) {
// console.log(err);
assert(err.message.includes("Assert Failed"));
}
});
});

3. Smart Contract

Kudos if you’ve reached this point! The circuit section is complete, and now it’s time to focus on writing the contract.

If you goto ./contracts you will notice that there will be 2 contracts.

  1. Lock.sol: This is the template contract comes with hardhat which can be deleted
  2. mulitiplierVerifier.sol: The name of the contract can vary based on the project. This contract was created while running the ./03_executePOT.sh and will be used to verify the proofs.

Delete the Lock.sol and create a new solidity file multiplier.sol

Once created, compile the contract using: npx hardhat compile

// file: /contracts/multiplier.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

// Interface to GrothVerifier.sol
interface IGrothVerifier {
function verifyProof(
uint256[2] memory a,
uint256[2][2] memory b,
uint256[2] memory c,
uint256[1] memory input
) external view returns (bool);
}

contract SimpleMultiplier {
address public s_grothVerifierAddress;

constructor(address grothVerifierAddress) {
s_grothVerifierAddress = grothVerifierAddress;
}

// ZK proof is generated in the browser and submitted as a transaction w/ the proof as bytes.
function submitProof(uint256[2] memory a,
uint256[2][2] memory b,
uint256[2] memory c,
uint256[1] memory input) public view returns (bool) {
bool result = IGrothVerifier(s_grothVerifierAddress).verifyProof(a, b, c, input);
require(result, "Invalid Proof");
return true;
}
}

4. Testing the Smart Contracts

  • Add the snarkjs library to test the generation of the proof.

Note: Make sure you have the same global and local version of snarkjs.

To check the global snarkjs version, open a console and run: snarkjs -v

To check the local snarkjs version, go to the package.json file and check the snarkjs version there.

To test the smart contract, create a file util.ts at ./test/utils/util.ts .

This is a helper file which will generate Proofs for the prover which will be verified later by the verifier using the multiplierVerifier.sol


// file: /test/utils/util.ts
// ts-node --esm scripts/generatingProofs.ts

import path from "path";
// @ts-ignore
import * as snarkjs from 'snarkjs';

export const generateProof = async (input0: number, input1: number, file: string): Promise<any> => {
console.log(`Generating vote proof with inputs: ${input0}, ${input1}`);

// We need to have the naming scheme and shape of the inputs match the .circom file
const inputs = {
in: [input0, input1],
}

// Paths to the .wasm file and proving key
const wasmPath = path.join(process.cwd(), `./circuits/build/multiplier_js/${file}.wasm`);
const provingKeyPath = path.join(process.cwd(), `./circuits/keys/${file}_final.zkey`)

try {
// Generate a proof of the circuit and create a structure for the output signals
const { proof, publicSignals } = await snarkjs.groth16.fullProve(inputs, wasmPath, provingKeyPath);

// Convert the data into Solidity calldata that can be sent as a transaction
const calldataBlob = await snarkjs.groth16.exportSolidityCallData(proof, publicSignals);

const argv = calldataBlob
.replace(/["[\]\s]/g, "")
.split(",")
.map((x: string | number | bigint | boolean) => BigInt(x).toString());

const a = [argv[0], argv[1]];
const b = [
[argv[2], argv[3]],
[argv[4], argv[5]],
];
const c = [argv[6], argv[7]];
const Input = [];

for (let i = 8; i < argv.length; i++) {
Input.push(argv[i]);
}

return { a, b, c, Input }
} catch (err) {
console.log(`Error:`, err)
return {
proof: "",
publicSignals: [],
}
}
}

// async function main() {
// const res = await generateProof(2, 5, 'multiplier');

// return res;
// }

// main().catch((error) => {
// console.error(error);
// process.exitCode = 1;
// });

Now create Multiplier.test.ts in ./test and add the below code thier.

To run the below test cases run: npx hardhat test

// file: ./test/Multiplier.test.ts

import { expect } from "chai";
import { ethers } from "hardhat";
import { generateProof } from "./utils/util";
import { Groth16Verifier } from "../typechain-types";


describe("Multiplier", function () {
let MultiplierVerifier, multiplierVerifier: Groth16Verifier, Multiplier, multiplier;

before(async function () {
MultiplierVerifier = await ethers.getContractFactory("Groth16Verifier");
multiplierVerifier = await MultiplierVerifier.deploy();
await multiplierVerifier.deployed();

Multiplier = await ethers.getContractFactory("SimpleMultiplier");
multiplier = await Multiplier.deploy(multiplierVerifier.address);
await multiplier.deployed();
});

it("Should return true for valid proof on-chain", async function () {

let dataResult = await generateProof(
1, 4,
'multiplier',
);

// Call the function.
let result = await multiplierVerifier.verifyProof(
dataResult.a,
dataResult.b,
dataResult.c,
dataResult.Input
);
expect(result).to.equal(true);
});

it("Should return false for invalid proof on-chain", async function () {
let a = [0, 0];
let b = [
[0, 0],
[0, 0],
];
let c = [0, 0];
let Input = [10];

let dataResult = { a, b, c, Input };

// Call the function.
let result = await multiplierVerifier.verifyProof(
dataResult.a,
dataResult.b,
dataResult.c,
dataResult.Input
);
expect(result).to.equal(false);
});

it("Should return true when validating with proof", async function () {

let dataResult = await generateProof(
2, 5,
'multiplier',
);

expect(
await multiplier.submitProof(
dataResult.a,
dataResult.b,
dataResult.c,
dataResult.Input
)
).to.be.true;
});
});

Conclusion

I hope this article has been helpful on your journey and has provided you with value. If you have any inquiries or need further explanation, feel free to ask in the comments section.

While we could have opted for Plonk instead of Groth16 to eliminate the need for a trusted ceremony per circuit, Plonk isn’t ideal for user experience due to its slower performance and larger zkey file size.

Refrences:

https://vivianblog.hashnode.dev/how-to-create-a-zero-knowledge-dapp-from-zero-to-production

--

--

Kartik Jain

Blockchain Developer & Rust Enthusiast 🦀 | Transforming code into innovation. Coding for the next paradigm shift!