Building a Zero Knowledge web app with Halo 2 and Wasm (part 2)

Yu Jiang Tham
12 min readApr 28, 2023

--

Continuing from the previous article where we built a Zero Knowledge circuit in Halo 2, we now move on to compiling it to WebAssembly and integrating it into a Next.js web app.

Part two of the hamster chronicles.

Recap

In the previous article, we laid the foundation for our Hammster app by building a ZK circuit using Halo 2 that would take in two private inputs of length-8 binary arrays and a public input of their Hamming distance and generate a zkSNARK proof. We also built a function that would take in that zkSNARK proof plus a public input of a claimed Hamming distance and output if the proof and public input were valid or not.

Please ensure that you’ve completed the Prerequisites section in part 1 before continuing.

What we’ll be building today

Today, we’ll be taking the circuit that we built, adding some ways for JavaScript to interact with the circuit through WebAssembly bindings, and building an output that we can use in our Next.js project.

Demo of the completed web app: https://hammster.vercel.app/

Working demo of the Hammster ZK web app.

Github repo: https://github.com/ytham/hammster

High-level architecture

Here’s a high-level architecture diagram of our Hammster app. As you can see, everything is done in the front end, and we don’t use the Next.js backend server for anything.

Hammster app high-level architecture

And here we have a system interaction diagram detailing the interactions between the web interface, Wasm bindings, and compiled ZK circuit.

Hammster system interaction diagram

The setup params and proof are both stored in the browser’s localStorage so that the values can be used in the verify stage later.

Adding our future Wasm module

We’ll first modify our lib.rs file to include a new wasm module that we’ll be building:

// file: circuits/src/lib.rs

pub mod hammster;
pub mod wasm;

Creating our Wasm module

We’ll need to create a new file called wasm.rs and add the following use directives:

// file: circuits/src/wasm.rs

use std::io::BufReader;
use crate::hammster::{calculate_hamming_distance, create_circuit, empty_circuit, generate_setup_params, generate_keys, generate_proof, verify};
use halo2_proofs::{
poly::commitment::Params,
pasta::{Fp, EqAffine},
plonk::keygen_vk
};
use js_sys::Uint8Array;
use wasm_bindgen::prelude::*;

Building for Wasm

In order to use wasm-pack to build a Wasm package, you’ll need to create a file circuits/.cargo/config with the following contents, replacing the target = "aarch64-apple-darwin" section with whatever architecture your computer is running.

// file: circuits/.cargo/config

[target.wasm32-unknown-unknown]
rustflags = ["-C", "target-feature=+atomics,+bulk-memory,+mutable-globals"]

[unstable]
build-std = ["panic_abort", "std"]

[build]
target = "aarch64-apple-darwin"

Additionally, we’ll want to edit our package.json file to add a script that uses wasm-pack to compile our circuit to Wasm with JS interface adapters.

// file: package.json

...
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"build:wasm": "cd circuits && wasm-pack build --target web --out-dir ../src/lib/wasm && cd .."
},
...

This will allow us to compile to Wasm by running yarn build:wasm later, but we still need to add functions in order to run our commands.

We’ll also want to add our dependencies to package.json as well:

// file: package.json

...
"dependencies": {
"@emotion/react": "^11.10.6",
"@mantine/core": "^6.0.8",
"@mantine/form": "^6.0.8",
"@mantine/hooks": "^6.0.8",
"@mantine/notifications": "^6.0.8",
"@types/node": "18.15.9",
"@types/react": "18.0.29",
"@types/react-dom": "18.0.11",
"eslint": "8.36.0",
"eslint-config-next": "13.2.4",
"next": "13.2.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"typescript": "5.0.2"
}
...

And then install the dependencies:

yarn install

Utility functions

In order to log to the Javascript console in the functions that we’ll create, we can utilize the following code that was lifted from the wasm-bindgen guide:

// file: circuits/src/wasm.rs

#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
}

This copies an input vector to an output Uint8Array:

// file: circuits/src/wasm.rs

fn copy_vec_to_u8arr(v: &Vec<u8>) -> Uint8Array {
let u8_arr = Uint8Array::new_with_length(v.len() as u32);
u8_arr.copy_from(v);
u8_arr
}

Wasm interface

We’ll now be creating three functions, one that runs a setup, one for generating a proof, and one for verifying the proof.

For the setup_params function, we take in a k parameter which, if you recall, sets a bound on the max number of rows of the circuit (2^k). The tradeoff for having a higher k value is that proving and verifying times will be longer, whereas if the k value is too low then your circuit won’t compile. In our case, the k value is 6, which we’ll pass in from the web frontend side. This gives us a little extra flexibility, but we could technically merge this setup_params with our proof_generate function if we wanted to.

// file: circuits/src/wasm.rs

#[wasm_bindgen]
pub fn setup_params(k: u32) -> Uint8Array {
log("running setup");

// Generate setup params
let params = generate_setup_params(k);
let mut buf = vec![];
params.write(&mut buf).expect("Can write params");

copy_vec_to_u8arr(&buf)
}

Our proof_generate function takes in our two vectors from the JS frontend as well as the setup params byte array that was output from our setup_params function, transform the inputs, and generate the zkSNARK proof, and output it as a Uint8Array.

// file: circuits/src/wasm.rs

#[wasm_bindgen]
pub fn proof_generate(
a: &[u8],
b: &[u8],
params_bytes: &[u8]
) -> Uint8Array {
log("proving...");

let params = Params::<EqAffine>::read(&mut BufReader::new(params_bytes)).expect("params should not fail to read");

// Turn slices into vectors and calculate hamming distance
let a_vec: Vec<u64> = a.to_vec().iter().map(|x| *x as u64).collect();
let b_vec: Vec<u64> = b.to_vec().iter().map(|x| *x as u64).collect();
let hamming_dist = calculate_hamming_distance(a_vec.clone(), b_vec.clone());

// Generate proving key
let empty_circuit = empty_circuit();
let (pk, _vk) = generate_keys(&params, &empty_circuit);

// Generate proof
let hammster_circuit = create_circuit(a_vec, b_vec);
let proof = generate_proof(&params, &pk, hammster_circuit, &hamming_dist);

copy_vec_to_u8arr(&proof)
}

Finally, our proof_verify function will take in the params and proof from the frontend as well as a user-input hamming distance value. After transforming the values into the shape that the verify function requires, it will return a boolean value of whether the proof was verified or not.

// file: circuits/src/wasm.rs

#[wasm_bindgen]
pub fn proof_verify(
params_bytes: &[u8],
hamming_dist: u32,
proof: &[u8]
) -> bool {
log("verifying...");

let params = Params::<EqAffine>::read(&mut BufReader::new(params_bytes)).expect("params should not fail to read");

// Generate verifying key
let empty_circuit = empty_circuit();
let vk = keygen_vk(&params, &empty_circuit).expect("vk should not fail to generate");

// Transform params for verify function
let hamming_dist_fp = vec![Fp::from(hamming_dist as u64)];
let proof_vec = proof.to_vec();

// Verify the proof and public input
let ret_val = verify(&params, &vk, &hamming_dist_fp, proof_vec);
match ret_val {
Err(_) => false,
_ => true,
}
}

Compiling to Wasm

Now that we’ve gotten our Wasm code written, we can use wasm-pack to generate a folder that includes our Wasm file along with a JavaScript interface. Since we’ve already added our script to package.json earlier, we ensure we’re in the root of directory of our Next.js project and run:

yarn build:wasm

This will output all of the required files to src/lib/wasm.

Building out the frontend

We’re using the Mantine components library to build out a lot of the frontend components, which will save us a lot of time. Let’s start by adding the required items to our _app.tsx file:

// file: src/pages/_app.tsx

import '@/styles/globals.css'
import type { AppProps } from 'next/app'
import { MantineProvider } from '@mantine/core';
import { Notifications } from '@mantine/notifications';

export default function App({ Component, pageProps }: AppProps) {
return (
<MantineProvider withNormalizeCSS withGlobalStyles>
<Notifications />
<Component {...pageProps} />
</MantineProvider>
)
}

We’ll also need to create this index.tsx file that shows the main UI:

// file: src/pages/index.tsx

import Head from 'next/head'
import { Grid, SegmentedControl, Space, Stack, Text, Title } from '@mantine/core'
import Link from 'next/link'
import { ProveForm } from '@/components/ProveForm'
import { useState } from 'react'
import { VerifyForm } from '@/components/VerifyForm'

export default function Home() {
const [page, setPage] = useState("prove");

const renderProveOrVerify = () => {
// Renders the page based on the SegmentedControl selection
if (page === "verify") {
return <VerifyForm />;
}
return <ProveForm />
}

return (
<>
<Head>
<title>Hammster</title>
<meta name="description" content="Hammster, the hamming distance ZK app." />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<Stack align='center' justify='center' mih="100vh" style={{
background: 'url(/hammster-corner.png)',
backgroundRepeat: 'no-repeat',
backgroundAttachment: 'fixed',
backgroundPosition: 'right bottom',
backgroundSize: '50%',
padding: "0 20px",
}}>
<Grid justify='center'>
<Grid.Col xs={10} sm={8} md={6}>
<Stack align='center' spacing="xl">
<Title order={1} ff={"helvetica neue"}>
Hammster
</Title>
<Text>
Hammster is written in <Link href="https://halo2.dev/">Halo2</Link>. It takes two 8-length vector inputs of
binary digits and their <Link href="https://en.wikipedia.org/wiki/Hamming_distance">Hamming distance</Link> and
generates a proof that the two inputs are the claimed hamming distance away from each other. Please note that
this currently does not work on mobile.
</Text>
<SegmentedControl
value={page}
onChange={setPage}
data={[
{ value: "prove", label: "Prove" },
{ value: "verify", label: "Verify" },
]}
/>
{ renderProveOrVerify() }
</Stack>
</Grid.Col>
</Grid>

<Space h="16vh" />

<Stack spacing={0} style={{
position: "fixed",
left: "8px",
bottom: "8px",
padding: "8px 20px",
borderRadius: "8px",
boxShadow: "0px 6px 12px rgba(0, 0, 0, 0.1)",
}}>
<Text>
<Link href="https://medium.com/@yujiangtham/building-a-zero-knowledge-web-app-with-halo-2-and-wasm-part-1-80858c8d16ee">Tutorial</Link>
</Text>
<Text>
<Link href="https://github.com/ytham/hammster">Github</Link>
</Text>
</Stack>
</Stack>
</>
)
}

Obviously, some of the includes are not yet there yet, but we’ll be building those shortly! In the meantime, we’ll also need to save this transparent .png of a hamster to be used in the bottom right corner of our index.tsx page. Save it to public/hammster-corner.png

Right click and save this as hammster-corner.png

Helper components

We need to add two components that will take care of the input forms and the Hamming distance calculations.

The BinaryInput component utilizes Mantine PinInput, which gives us separate number inputs for each digit instead of a single input box. We pass in the form object (which we’ll be creating below in the ProveForm component) into our props.

// file: src/components/BinaryInput.tsx

import { Group, PinInput, Text } from "@mantine/core"

export const BinaryInput = (props: any) => {
return (
<Group>
<Text>
Input {props.inputNum}
</Text>
<PinInput type={/^[0-1]+/} length={8} placeholder="0" styles={
{
input: {
backgroundColor: '#def',
color: '#345',
fontWeight: 400,
fontSize: '1.05rem',
},
}
}
{...props.form.getInputProps(`input${props.inputNum}`)}
/>
</Group>
)
}

Our next helper component calculates the Hamming distance from the two inputs as well. We also pass in the same form object from ProveForm below and perform some operations to check the values and calculate the Hamming distance from those values.

// file: src/components/HammingDistance.tsx

import { Text } from "@mantine/core"

export const HammingDistance = (props: any) => {
if (props.form === undefined) {
return null;
}

const calculateHammingDistance = () => {
// Pad our inputs to 8 digits
let input0 = props.form.values.input0;
if (input0.length < 8) {
input0 = input0.padStart(8, '0');
}
let input1 = props.form.values.input1;
if (input1.length < 8) {
input1 = input1.padStart(8, '0');
}

// Parse each item in each array to binary values
const input0arr = input0.split('').map((x: string) => parseInt(x, 2));
const input1arr = input1.split('').map((x: string) => parseInt(x, 2));

// Calculate the hamming distance by comparing the two arrays
let hammingDistance = 0;
for (let i = 0; i < input0arr.length; i++) {
if (input0arr[i] !== input1arr[i]) {
hammingDistance++;
}
}
return hammingDistance;
}

return (
<Text align="center">
Hamming distance: <b>{calculateHammingDistance()}</b>
<Text fz="xs">
(remember this number!)
</Text>
</Text>
)
}

Form for generating a proof

Now that we’ve built out some of the helper components, we can get to work on the meat of the frontend. Our ProveForm component displays a form that accepts two binary inputs and calculates the Hamming distance of those inputs.

Once the user clicks the Generate & Save Proof button, we load the Wasm library, run setup, save the setup params to localStorage, generate the proof, and then save the proof to localStorage as well. The user must remember the calculated Hamming distance number for the input into the Verify section.

// file: src/components/ProveForm.tsx

import { useState } from "react"
import { Group, Input, Stack, Text } from "@mantine/core"
import { BinaryInput } from "./BinaryInput"
import { useForm } from "@mantine/form"
import { HammingDistance } from "./HammingDistance"
import { notifications } from '@mantine/notifications';
import * as hm from '../lib/wasm/hammster.js'

import { Group, Input, Stack, Text } from "@mantine/core"
import { BinaryInput } from "./BinaryInput"
import { useForm } from "@mantine/form"
import { HammingDistance } from "./HammingDistance"
import { notifications } from '@mantine/notifications';
import * as hm from '../lib/wasm/hammster.js'

export const ProveForm = () => {
const form = useForm({
initialValues: {
input0: '',
input1: '',
},
})

const submit = async (values: any) => {
console.log(values);

// Set up hammster Wasm
await hm.default();

// Run setup
const params = hm.setup_params(6);
try {
localStorage.setItem("setupParams", params.toString());
} catch (err) {
console.log(err);
notifications.show({
title: "Error",
message: "Failed to save setup params",
color: "red",
})
return;
}

// Parse inputs
const input0arr = values.input0.split('').map((x: string) => parseInt(x, 2));
const input1arr = values.input1.split('').map((x: string) => parseInt(x, 2));

// Generate the proof
console.log("generating proof");
const proof = hm.proof_generate(input0arr, input1arr, params);
if (proof.length === 0) {
notifications.show({
title: "Error",
message: "Failed to generate proof",
color: "red",
})
return;
}

// Save proof to localStorage
try {
localStorage.setItem("proof", proof.toString());
console.log("saved to localStorage");
} catch (err) {
console.log(err);
notifications.show({
title: "Error",
message: "Failed to save proof",
color: "red",
})
return;
}

// Show notification to user
notifications.show({
title: "Success!",
message: "Setup params & proof saved to localStorage",
color: "green",
});
}

return (
<form onSubmit={form.onSubmit(submit)}>
<Stack align='center' w='100%' style={{
backgroundColor: "rgba(200,230,255,0.25)",
backdropFilter: 'blur(8px)',
border: "2px solid #eee",
borderRadius: "8px",
padding: "16px",
boxShadow: "0px 12px 36px rgba(0, 0, 0, 0.2)",
}}>
<Text>
Input two 8-bit binary values below. Remember the calculated hamming distance value for the Verify section.
</Text>
<Stack align='flex-end'>
<BinaryInput inputNum={0} form={form} />
<BinaryInput inputNum={1} form={form} />
</Stack>
<HammingDistance form={form} />
<Group>
<Input
type="submit"
value="Generate & Save Proof"
disabled={form.values.input0.length !== 8 || form.values.input1.length !== 8}
/>
</Group>
</Stack>
</form>
)
}

Form for verifying the proof

Our VerifyForm component only has one input, which is the Hamming distance that was used as the public input for the proof generated earlier. We first check if there is any proof saved to localStorage, and if not, will disable the Verify Proof button.

When the user inputs the Hamming distance and clicks the Verify Proof, we grab the setup params and proof from localStorage, run it through the verifier, and surface a notification if the proof and public params was valid or not.

// file: src/components/VerifyForm.tsx

import { useEffect, useState } from "react";
import { Input, Stack, Text, TextInput } from "@mantine/core"
import { useForm } from "@mantine/form"
import { notifications } from "@mantine/notifications";
import * as hm from '../lib/wasm/hammster.js'

export const VerifyForm = () => {
const [hasProof, setHasProof] = useState(false);

// Check if we have a proof saved to localStorage yet
useEffect(() => {
const proof = localStorage.getItem("proof");
if (proof) {
setHasProof(true);
return;
}
}, []);

const form = useForm({
initialValues: {
hammingDist: '',
},
validate: {
hammingDist: (value) => {
const parsedValue = parseInt(value);
if (isNaN(parsedValue)) {
return 'Must be a number';
}
if (parsedValue < 0 || parsedValue > 8) {
return 'Must be between 0 and 8';
}
return null;
},
},
})

const submit = async (values: any) => {
console.log(values);

// Get setup params from localStorage
const paramsString = localStorage.getItem("setupParams");
if (!paramsString) {
console.log("No params found");
return;
}
const params = Uint8Array.from((paramsString as string).split(',').map((x: string) => parseInt(x)));

// Get proof from localStorage
const proofString = localStorage.getItem("proof");
if (!proofString) {
console.log("No proof found");
return;
}
const proof = Uint8Array.from((proofString as string).split(',').map((x: string) => parseInt(x)));

// Set up hammster Wasm
await hm.default();

// Verify the proof
const result = hm.proof_verify(params, values.hammingDist, proof);

// Show a notification based on the result of the proof verification
if (result) {
notifications.show({
title: "Success!",
message: "Proof verified successfully!",
color: "green",
});
} else {
notifications.show({
title: "Error",
message: `Proof with hamming distance of ${values.hammingDist} failed to verify`,
color: "red",
});
}
}

return (
<form onSubmit={form.onSubmit(submit)}>
<Stack align='center' w='100%' style={{
backgroundColor: "rgba(200,230,255,0.25)",
backdropFilter: 'blur(8px)',
border: "2px solid #eee",
borderRadius: "8px",
padding: "16px",
boxShadow: "0px 12px 36px rgba(0, 0, 0, 0.2)",
}}>
<Text>
Input the hamming distance from the Prove section.
</Text>
<TextInput
label="Hamming Distance"
placeholder="[0,8]"
styles={
{
input: {
backgroundColor: '#def',
color: '#345',
fontWeight: 400,
fontSize: '0.95rem',
},
}
}
{...form.getInputProps('hammingDist')}
/>
<Input
type="submit"
value="Verify Proof"
disabled={!hasProof || form.values.hammingDist.length !== 1}
/>
</Stack>
</form>
)
}

Trying it out

You should now be able to, from the root of the project directory, start the web server and try out the app:

yarn dev

Open up a web browser to http://localhost:3000 in order to load the web app and give it a try!

Conclusion

Glad you made it this far! To recap, we started off with building a Halo 2 circuit, compiled it to Wasm, and then built a web app around it. If you found it informative, please clap and share so that others can also find these articles as well! Follow me on Twitter and here on Medium for more ZK content. Cheers!

--

--