Writing a Zero Knowledge dApp

Yu Jiang Tham
15 min readMar 16, 2023

--

We shed some light on integrating zero knowledge circuits into your smart contracts and subsequently into a dApp.

Welcome to the world of Zero Knowledge

Introduction

The world of Zero Knowledge (ZK) cryptography has been all the rage in the past few months. As new zkRollups and zkEVMs are announced, the crypto world has turned its attention the ability of zero knowledge proofs to provide elegant solutions to the problems of privacy and scalability (see my previous post for details) on blockchains.

This two-part series of posts assumes knowledge of Solidity and Typescript (react/next.js) and is here to help fill in the gaps of how to write a ZK circuit (in Circom) as well as how to integrate that into a next.js app. Basic knowledge of finite fields is also useful.

The main goal of this post is not to explain how to write in circuits in Circom, but to outline the series of steps from getting code written in Circom to something that someone can use in their dApp.

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 a ZK circuit?

A ZK circuit is a program that, given some set of inputs, outputs a proof, which can be easily verified, that every step of computation that was run inside the circuit were done correctly.

How do you write a ZK circuit?

There are a number of ways to create a ZK circuit, with some higher-level domain-specific languages such as Noir and Circom that allow you to write a program that will be compiled down to a circuit. Alternatively, there are also low-level packages like Halo2 in which you will specify exactly where values will fall in your table that represents your circuit.

Today, we’ll be using Circom with SnarkJS because they are both relatively widely-used and currently work with generating proofs in the browser.

Stuff that’s good to know

Writing in Circom and most other domain-specific languages means that you will need to get familiar with dealing with field elements and writing constraints.

Field elements

In Circom, there is only one data type, the field element. For the purposes of this tutorial, you can think of field elements as basically any number modulo a large prime number, which is based on the elliptic curve that is used. The default curve in Circom is BN128.

Constraints

Circuits can accept arbitrary inputs from the entity that is generating the proof, so these inputs must be constrained within the circuit so that the inputs, outputs, and intermediate values fall within some acceptable set of values or hold some relationship to another variable. Two example of constraints could be ensuring that one input is the square of another input, or that the input is not equal to the output. We’ll discuss how to write these in the code example.

General flow

The general flow of the process that we’ll be covering can be visualized in this diagram:

Overall flow for creating a zero knowledge dApp. Highlighted boxes are developer or user inputs.

Let’s dive in

I’m sure you’ve had enough of the intro and background, so let’s dive right in. I’ll break this down into three sections, based on the general flow diagram above 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.

The proving system we’ll use is Plonk, which allows us to have a universal trusted setup instead of having to generate additional randomness for each circuit. It’s slower than the Groth16 proving system, but is good enough for what we want right now.

Here’s a link to the repo:

https://github.com/ytham/zk_example_dapp

Setup

We’ll require the following to be installed before continuing:

Let’s first create the project folder. We’ll create a next.js app using the command with the following settings:

yarn create next-app

Settings for our new next.js repo

Once that’s all done, cd into the folder and then run the command to add the following packages:

yarn add circomlib snarkjs wagmi ethers@^5 axios @mantine/core @mantine/hooks @mantine/notifications @emotion/react

Circuit

Circuit section highlighted

Setup

Ensure that you’re in the project folder and create a folder called circuits, and then a folder inside circuits named build. We’ll be working inside the circuits folder in this section.

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. I’ve added inline comments 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/simple_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();

Compile to intermediate representation

Once the circuit has been completed, we’ll compile it down to an intermediate representation called R1CS (Rank-1 Constraint System). Additional info on R1CS can be found in a previous post. Run the following command in the circuits folder:

circom simple_multiplier.circom --r1cs --wasm --sym -o build

Which will output the R1CS, WASM, and symbols into the circuits/build folder and display the circuit data, including number of constraints.

Powers of Tau trusted setup file

There are a number of Powers of Tau trusted setups that have been generated that we can use, based on the size of our circuit (number of constraints). To reduce proving times, you’ll want to use the Powers of Tau that is closest to the size of your circuit. You can find the trusted setup files in this repository:

https://github.com/iden3/snarkjs#7-prepare-phase-2

Let’s go ahead and use the smallest one (powersOfTau28_hez_final_08.ptau), which supports up to 256 constraints, since our circuit has ~14 constraints.

Generate proving key

Now, from the circuits directory, we’ll run the command to generate the proving key that we will use to generate the proof using the R1CS and ptau files:

snarkjs plonk setup build/simple_multiplier.r1cs ptau/powersOfTau28_hez_final_08.ptau build/proving_key.zkey

Contract

Contract section highlighted

Setup

Create a new directory in the project root directory called contracts. cd contracts and then use the following Foundry command to create a new project inside the contracts folder:

forge init --no-commit

Delete the generated starter files inside the script, src, and test folders.

Add a .env file inside the contracts folder as well, in which you’ll add your private key for the wallet you want to deploy from (ensure that this wallet has some GoerliETH, which you can get from the Goerli PoW faucet). You’ll also need to have an account with Alchemy (or whichever other RPC provider of choice) and input your RPC url from the Alchemy dashboard:

// file: /contracts/.env

GOERLI_RPC_URL=https://eth-goerli.g.alchemy.com/v2/<YOUR_GOERLI_API_KEY>
PRIVATE_KEY=<YOUR_PRIVATE_KEY>

Also add the following to your foundry.toml file:

// file: /contracts/foundry.toml

[profile.default]
src = 'src'
out = 'out'
libs = ['lib']

# Add this
[rpc_endpoints]
goerli = "${GOERLI_RPC_URL}"

Export smart contract verifier

We can generate the verifier smart contract using the following SnarkJS command from the project root:

snarkjs zkey export solidityverifier circuits/build/proving_key.zkey contracts/src/PlonkVerifier.sol

Write smart contract

We’ll write the following contract that utilizes the PlonkVerifier.sol file that we exported above. The contract merely outputs a boolean true or false based on the result of the PlonkVerifier, but you can imagine writing something that mints an NFT, transfers tokens, deploys another contract, or anything else you can think of. For brevity, I’ve excluded tests.

// file: /contracts/src/SimpleMultiplier.sol

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

// Interface to PlonkVerifier.sol
interface IPlonkVerifier {
function verifyProof(bytes memory proof, uint[] memory pubSignals) external view returns (bool);
}

contract SimpleMultiplier {
address public s_plonkVerifierAddress;

event ProofResult(bool result);

constructor(address plonkVerifierAddress) {
s_plonkVerifierAddress = plonkVerifierAddress;
}

// ZK proof is generated in the browser and submitted as a transaction w/ the proof as bytes.
function submitProof(bytes memory proof, uint256[] memory pubSignals) public returns (bool) {
bool result = IPlonkVerifier(s_plonkVerifierAddress).verifyProof(proof, pubSignals);
emit ProofResult(result);
return result;
}
}

Build the contract by running the following command in the contracts folder:

forge build

Then, go back into the project root directory and then create a folder called abi in the src/lib folder and copy the json output into that folder:

mkdir -p src/lib/abi

cp contracts/out/SimpleMultiplier.sol/SimpleMultiplier.json src/lib/abi/.

Deploy contracts

We then deploy the contracts with the deploy script:

// file: /contracts/scripts/SimpleMultiplier.s.sol

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

import "forge-std/Script.sol";
import "../src/PlonkVerifier.sol";
import "../src/SimpleMultiplier.sol";

contract SimpleMultiplierScript is Script {
function setUp() public {}

function run() public {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);

PlonkVerifier pv = new PlonkVerifier();
SimpleMultiplier sm = new SimpleMultiplier(address(pv));

vm.stopBroadcast();
}
}

And run the deploy script with the following command:

forge script script/SimpleMultiplier.s.sol SimpleMultiplierScript --broadcast --verify --rpc-url goerli

You’ll see two contracts deployed. The first one is the PlonkVerifier contract, and the second is the SimpleMultiplier contract. We only need the address of the SimpleMultiplier contract. Let’s save this address to be used later in the frontend:

// file: /src/shared/addresses.ts

export const Addresses = {
SIMPLE_MULTIPLIER_ADDR: "<YOUR_DEPLOYED_CONTRACT_ADDR>" as `0x${string}`,
}

Great! Now we’ve deployed our verifier and contract and are ready to move onto building out the user interface in the browser!

Frontend

Frontend section highlighted

Setup

We’ll be deploying our dApp to Vercel via Github, so please ensure that you have an account at both places before proceeding.

Creating the frontend

We’ll build out the frontend interface by creating or modifying the following files. For the sake of brevity, I’ve globed a number of items in various pages instead of creating separate components for them. I’ve also not included every case for error handling. This is not a next.js/react tutorial, so I’ll assume the reader has cursory understanding already.

We utilize the Wagmi package to connect to the blockchain and wrap our entire app in the WagmiConfig:

// file: /src/pages/_app.tsx

import '@/styles/globals.css'
import { WagmiConfig, createClient, configureChains, goerli } from 'wagmi'
import { publicProvider } from 'wagmi/providers/public'
import type { AppProps } from 'next/app'
import { MantineProvider } from '@mantine/core'
import { Notifications } from '@mantine/notifications';

// We'll just be using Goerli testnet for now
const { chains, provider, webSocketProvider } = configureChains(
[goerli],
[publicProvider()],
)

const client = createClient({
autoConnect: true,
provider,
webSocketProvider,
})

export default function App({ Component, pageProps }: AppProps) {
// We'll be using Wagmi sending our transaction and Mantine for CSS
// and notifications
return (
<WagmiConfig client={client}>
<MantineProvider withNormalizeCSS>
<Notifications />
<Component {...pageProps} />
</MantineProvider>
</WagmiConfig>
)
}

Our index.tsx file contains a ConnectWalletButton and two input fields where a user can input their numbers [0,5]. When the user presses the Submit button for the two input fields, it sends a POST request to the backend with the inputs to generate the proof. Once the proof is generated, it takes that proof data and submits it as a transaction on the blockchain.

Ideally we’d like to separate this into a lot of different component files, but for the sake of simplicity, it’s all in one file:

// file: /src/pages/index.tsx

import Head from 'next/head'
import Link from 'next/link';
import { useState } from 'react';
import { Stack, Text, Title, Grid, Input, Button, Group, Space } from '@mantine/core'
import axios, { AxiosRequestConfig } from 'axios';
import { useAccount } from 'wagmi';
import { notifications } from "@mantine/notifications";
import { ConnectWalletButton } from '@/components/ConnectWalletButton';
import { executeTransaction } from '@/lib/executeTransaction';

export default function Home() {
const [input0, setInput0] = useState("");
const [input1, setInput1] = useState("");
const { isConnected } = useAccount();

const handleGenerateProofSendTransaction = async (e: any) => {
e.preventDefault();

// We will send an HTTP request with our inputs to our next.js backend to
// request a proof to be generated.
const data = {
input0,
input1,
}
const config: AxiosRequestConfig = {
headers: {
"Content-Type": "application/json",
}
}

// Send the HTTP request
try {
const res = await axios.post("/api/generate_proof", data, config);
notifications.show({
message: "Proof generated successfully! Submitting transaction...",
color: "green",
});

// Split out the proof and public signals from the response data
const { proof, publicSignals } = res.data;

// Write the transaction
const txResult = await executeTransaction(proof, publicSignals);
const txHash = txResult.transactionHash;

notifications.show({
message: `Transaction succeeded! Tx Hash: ${txHash}`,
color: "green",
autoClose: false,
});
} catch (err: any) {
const statusCode = err?.response?.status;
const errorMsg = err?.response?.data?.error;
notifications.show({
message: `Error ${statusCode}: ${errorMsg}`,
color: "red",
});
}
}

// Only allow submit if the user first connects their wallet
const renderSubmitButton = () => {
if (!isConnected) {
return <ConnectWalletButton />
}
return (
<Button type="submit">Generate Proof & Send Transaction</Button>
)
}

return (
<>
<Head>
<title>ZK Simple Multiplier</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Stack justify="center" align="center" w="100vw" h="100vh" spacing={0}>
<Stack align="center" spacing={0}>
<Group w="96vw" h="10vh" position="apart" align="center">
<Title order={3}>
ZK Simple Multiplier
</Title>
<ConnectWalletButton />
</Group>
<Grid align="center" justify="center" mih="80vh">
<Grid.Col sm={8} md={6} lg={4}>
<Text>
{"Input two numbers between 0 and 5, inclusive. The two numbers must \
not be equal. We'll generate a ZK proof locally in the browser, and \
only the proof will be sent to the blockchain so that no one \
watching the blockchain will know the two numbers."}
</Text>
<Space h={20} />
<form onSubmit={handleGenerateProofSendTransaction}>
<Stack spacing="sm">
<Input.Wrapper label="Input 0">
<Input
placeholder="Number between 0 and 5"
value={input0}
onChange={(e) => setInput0(e.currentTarget.value)}
/>
</Input.Wrapper>
<Input.Wrapper label="Input 1">
<Input
placeholder="Number between 0 and 5"
value={input1}
onChange={(e) => setInput1(e.currentTarget.value)}
/>
</Input.Wrapper>
<Space h={10} />
{ renderSubmitButton() }
</Stack>
</form>
</Grid.Col>
</Grid>
<Group w="96vw" h="10vh" position="center" align="center">
<Link href="https://medium.com/@yujiangtham/writing-a-zero-knowledge-dapp-fd7f936e2d43">
<Text>
Created using this tutorial!
</Text>
</Link>
</Group>
</Stack>
</Stack>
</>
)
}

For the ConnectWalletButton, we’ll be building it for Metamask since it’s the most popular, but you can use any other wallet that you’d like and utilize the appropriate Wagmi connector(s).

// file: /src/components/ConnectWalletButton.tsx

import { Button } from "@mantine/core"
import { disconnect } from "@wagmi/core";
import { useAccount, useConnect, useEnsName } from 'wagmi'
import { InjectedConnector } from 'wagmi/connectors/injected'

export const ConnectWalletButton = () => {
const { address, isConnected } = useAccount();
const { data: ensName } = useEnsName({ address });
const { connect } = useConnect({
connector: new InjectedConnector(),
});

const handleClick = () => {
if (isConnected) {
disconnect();
} else {
connect();
}
}

const renderConnectText = () => {
if (isConnected) {
const start = address?.slice(0,6);
const end = address?.slice(address.length-4, address.length);
return `${start}...${end}`;
} else {
return "Connect Wallet";
}
}

return (
<Button onClick={handleClick}>
{ renderConnectText() }
</Button>
)
}

Once we receive the inputs from the backend, we parse the inputs and then call our generateProof library function (which we’ll implement in the next section):

// file: /src/pages/api/generate_proof.ts

import { generateProof } from '@/lib/generateProof';
import type { NextApiRequest, NextApiResponse } from 'next'

export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const body = req?.body;
if (body === undefined) {
return res.status(403).json({error: "Request has no body"});
}
console.log(body);

const input0 = parseInt(body.input0);
const input1 = parseInt(body.input1);

if (input0 === undefined || Number.isNaN(input0)
|| input1 === undefined || Number.isNaN(input1)) {
return res.status(403).json({error: "Invalid inputs"});
}
const proof = await generateProof(input0, input1);

if (proof.proof === "") {
return res.status(403).json({error: "Proving failed"});
}

res.setHeader("Content-Type", "text/json");
res.status(200).json(proof);
}

Calculating the witness and generating the proof

Witness calculation and proof generation is done in a single step using snarkjs.plonk.fullProve in the the following file. The data is then converted into a Solidity calldata blob, which is further split into the proof and publicSignals.

// file: /src/lib/generateProof.ts

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

export const generateProof = async (input0: number, input1: number): 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/simple_multiplier_js/simple_multiplier.wasm');
const provingKeyPath = path.join(process.cwd(), 'circuits/build/proving_key.zkey')

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

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

console.log(calldata);

return {
proof: calldata[0],
publicSignals: JSON.parse(calldata[1]),
}
} catch (err) {
console.log(`Error:`, err)
return {
proof: "",
publicSignals: [],
}
}
}

Submitting the transaction

The transaction is submitted in this file:

// file: /src/lib/executeTransaction.ts

import { Addresses } from '@/shared/addresses';
import { TransactionReceipt } from '@ethersproject/abstract-provider';
import { prepareWriteContract, writeContract } from '@wagmi/core';

export const executeTransaction = async (proof: any, publicSignals: Array<string>): Promise<TransactionReceipt> => {
const abiPath = require('./abi/SimpleMultiplier.json');

// Prepare the transaction data
const config = await prepareWriteContract({
address: Addresses.SIMPLE_MULTIPLIER_ADDR,
abi: abiPath.abi,
functionName: 'submitProof',
args: [proof, publicSignals]
});

// Execute the transaction
const writeResult = await writeContract(config);

// Wait for the transaction block to be mined
const txResult = await writeResult.wait();
return txResult;
}

Updating the UI with the result

The transaction result is given to us at the output of await txResult.wait(). Here, we’ve just sent a notification to the user on the frontend, but you can update the UI with the information in the manner you best see fit.

Interacting with your app

You can run a local server by running yarn dev in the project root directory to try it out.

Additionally, you can also deploy to Vercel to have your dApp on the web for anyone to play with. First, create a new Github repository and commit all of the changes and push your files to it. Go to Vercel, then add a new Project:

Add a new Project in Vercel

Select the git repository that you’d like to import:

Git repository to import

Ensure that the framework preset is Next.js and then hit deploy.

Wait a couple of minutes for the project to build, and then you should have a link that you can use or send to your friends. My deployment is here:

https://zk-example-dapp.vercel.app/

Try it out!

Navigate to the page you’ve deployed on Vercel and give it a go!

Summary

Circuit

  1. Write circom circuit
  2. Compile the circuit:
    circom circuit.circom --r1cs --wasm --sym
  3. Download a powers of tau trusted setup file
  4. Run Plonk setup to get the proving key:
    snarkjs plonk setup circuit.r1cs ptau_file.ptau proving_key.zkey

Contract

  1. Export verifier smart contract
    snarkjs zkey export solidityverifier proving_key.zkey verifier.sol
  2. Integrate verifier into your Solidity project

Frontend

  1. Take user inputs
  2. Calculate witness & generate proof in one step
    await snarkjs.plonk.fullProve({ inputs }, wasmPath, provingKeyPath);
  3. Submit transaction with proof to Verifier contract

Conclusion

Hopefully, this post has been helpful on your zk dApp development journey. If you have any questions or need any clarification, please be sure in the comments. For more on ZK, follow me here on Medium and on Twitter as well! Cheers!

--

--