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

Yu Jiang Tham
15 min readApr 26, 2023

--

In this two-part series, we’ll explore building a Zero Knowledge circuit using the Halo 2 zkSNARK proving system and subsequently how we can compile it to WebAssembly to be used in a web app.

A hamster-like character (you’ll have to keep reading to see why) with zero eyes, but much knowledge.

Introduction

There’s no shortage of ways in which Zero Knowledge apps that can be built these days. The past few years have brought about a rapid advancement of proving systems, libraries, domain-specific languages (DSLs), and developer interest in the space. In this two-part series, we’ll be building a very basic web app using the Halo 2 proof system.

Halo 2 was created by the Electric Coin Company (who also created Zcash) as a solution to some of the inefficiencies and security issues (such as trusted setup) of Zcash. The Halo 2 proof system is implemented via a set of Rust crates that enable the creation and verification of zkSNARK proofs without a trusted setup.

The functions that we’ll be creating to generate the circuit, proofs, proving/verifying keys, etc. can all be compiled down to WebAssembly, which can subsequently be used in a web app.

A fair warning that the topics covered in this series may be a big more difficult for beginners. If you’re just starting out, you might want to take a look at my previous blog posts on dissecting a zkSNARK to get an idea of how things work.

Background

The original Halo was created by the Electric Coin Company as a zkSNARK construction that would not require a trusted setup and be significantly more performant than Zcash. After that, Halo 2 was created by replacing the Sonic proving system with PLONK.

PLONK

PLONK is a proof system for generating zkSNARKs that uses a universal and updatable trusted setup. A computation must first be converted into an arithmetic circuit, in order for the proof system to understand it. In PLONK, all arithmetic constraints (called “gates”) are of the form:

(Q_L)a + (Q_R)b + (Q_O)c + (Q_M)ab + Q_C = 0

This allows us to come up with addition or multiplication of two inputs (a, b) plus some constant to equal an output c. Each of the Q’s are selectors:

Q_L: left input selector
Q_R: right input selector
Q_O: output selector
Q_M: multiplication selector
Q_C: constant value

Therefore, we can create an addition gate by setting the following values for the selectors:

Q_L = 1
Q_R = 1
Q_O = -1
Q_M = 0
Q_C = 0

(1)a + (1)b + (-1)c + (0)ab + 0 = 0
a + b = c

Similarly, we can create a multiplication gate:

Q_L = 0
Q_R = 0
Q_O = -1
Q_M = 1
Q_C = 0

(0)a + (0)b + (-1)c + (1)ab + 0 = 0
ab = c

Halo 2 uses PLONKish arithmetization in which there’s more flexibility in the gates.

PLONKish

PLONKish gates offer more flexibility than regular PLONK gates. They are laid out in a table and can have any number of columns and rows (although there are tradeoffs for speed, and number of rows must be a power of 2), with three different types of columns: advice, instance, and fixed.

  • advice: private inputs and intermediate values go here
  • instance: generally used for public inputs
  • fixed: constants, lookup tables, and selectors
Illustration of the different columns of a PLONKish table

The various cells in the table are able to reference other cells, giving PLONKish gates a huge amount of flexibility.

What will we be building?

We’ll be building a Hamming distance web app called Hammster. The Hamming distance of two same-length strings is the number of places at which the symbols are different. Our app takes two 8-length binary inputs, calculates their Hamming distance, and outputs a ZK proof that can be verified in a separate part of the web app.

In the first part of the series, we cover how to build the circuit in Halo 2. The second part of the series explains how to generate the Wasm files and interface with them in a Next.js app.

This app is loosely based off of the the “Simple Example” (but not really that simple) in the Halo 2 book.

A demo of the web app is available here: https://hammster.vercel.app/

The Hammster web app!

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

Prerequisites

Note that the current example runs on Apple M architectures (stable-aarch64-apple-darwin) but I have not tested with any other architecture, and some modifications will be required if you’re not running an Apple M computer.

Getting started

Let’s get started! We’ll first create a new next.js project, which we’ll use to hold the rest of our project. You can run the following command in your terminal to create a new next.js app

yarn create next-app

Call it hammster and hit enter to select all of the default settings until it starts downloading the packages. This will take a minute or two.

Once that’s done, we’ll go ahead and use cargo to create a new Rust project within our new next.js project. We’ll enter our newly-generated generated hammster folder and generate a new Rust project called circuits using the following commands:

cd hammster

cargo new circuits

This will create a new folder called circuits within our hammster next.js project. We’ll be using a variety of packages, so we’ll need to update our Cargo.toml file to make it look like the following:

// file: circuits/Cargo.toml

[package]
name = "halo2_hammster"
version = "0.1.0"
edition = "2021"

[lib]
name = "hammster"
path = "src/lib.rs"
crate-type = ["cdylib", "rlib"]

[dependencies]
extend = "1.2.0"
halo2_proofs = { version = "0.3.0", features = ["dev-graph"] }
plotters = "0.3.4"
rand_core = "0.6.4"
serde = { version = "1.0.156", features = ["derive"] }
serde_json = "1.0.94"
getrandom = { version = "0.2", features = ["js"] }
wasm-bindgen = "0.2.84"
js-sys = "0.3.61"

We’ll first create a file called hammster.rs in the path circuits/src. We’ll add all of our imports and constants first, as seen below:

// file: circuits/src/hammster.rs

use std::{
marker::PhantomData,
};
use halo2_proofs::{
arithmetic::Field,
dev::MockProver,
circuit::{Layouter, Chip, Value, AssignedCell, Region, SimpleFloorPlanner},
plonk::{Column, Advice, Instance, Error, Selector, ConstraintSystem, Circuit, Expression, create_proof, keygen_vk, keygen_pk, ProvingKey, VerifyingKey, verify_proof, SingleVerifier},
poly::{Rotation, commitment::Params},
pasta::{Fp, EqAffine}, transcript::{Blake2bWrite, Challenge255, Blake2bRead},
};
use rand_core::OsRng;

// The length of our binary inputs
const BINARY_LENGTH: usize = 8;

Define instructions for our Chip

We need to create a set of function signatures in the trait definition of our Chip that will do the things that we need to do. In this case, our Hammster circuit needs functions to load private inputs and check that they’re binary values, apply an XOR to two numbers, add those XORed values together (to find the Hamming distance), and then expose a result as a public output.

// file: circuits/src/hammster.rs

// Traits for the chip
trait Instructions<F: Field>: Chip<F> {
type Num;

fn load_private_and_check_binary(&self, layouter: impl Layouter<F>, column: usize, value: [Value<F>; BINARY_LENGTH]) -> Result<Vec<Self::Num>, Error>;

fn xor(&self, layouter: impl Layouter<F>, a: Self::Num, b: Self::Num) -> Result<Self::Num, Error>;

fn accumulator(&self, layouter: impl Layouter<F>, values: [Self::Num; BINARY_LENGTH]) -> Result<Self::Num, Error>;

fn expose_public(&self, layouter: impl Layouter<F>, num: Self::Num) -> Result<(), Error>;
}

Great, now we have a set of function signatures for a generic Chip, of which we’ll write implementations for our HammsterChip later.

// file: circuits/src/hammster.rs

// The chip which holds the circuit config
pub struct HammsterChip<F: Field> {
config: HammsterConfig,
_marker: PhantomData<F>,
}

// Set what happens when config and loaded eq
impl<F: Field> Chip<F> for HammsterChip<F> {
type Config = HammsterConfig;
type Loaded = ();

fn config(&self) -> &Self::Config {
&self.config
}

fn loaded(&self) -> &Self::Loaded {
&()
}
}

// The configuration of the circuit
#[derive(Debug, Clone)]
pub struct HammsterConfig {
advice: [Column<Advice>; 3],
instance: Column<Instance>,
s_binary_l: Selector,
s_binary_r: Selector,
s_xor: Selector,
s_accumulator: Selector,
}

Configuring the Chip

A chip in Halo 2 is a way to organize some logic into a reusable component. For example, some chips may implement some sort of hashing function or scalar multiplication.

We now need to build out the Chip configuration. This is where we will create various types of gates that will be turned on with our selectors that we similarly define. For each of the gates that we create, the output vec! contains the polynomial expressions that constrain that gate.

The gates defined in PLONKish circuits also refer to cells via relative offsets via the Rotation input, with Rotation::cur() being the current cell, and Rotation::prev() and Rotation::next() pointing to the cell that is in the current column in the previous and next rows, respectively.

For each of the gates in our example, we only need one constraint. Inside the vec!, if the selector is nonzero, then whatever it multiplies must equal zero. For example, in the case of vec![s_accumulator * (inputs_sum — sum)], if s_accumulator is enabled (set to 1), then there is a constraint such that inputs_sum — sum = 0.

// file: circuits/src/hammster.rs

impl<F: Field> HammsterChip<F> {
fn construct(config: <Self as Chip<F>>::Config) -> Self {
Self {
config,
_marker: PhantomData,
}
}

fn configure(
meta: &mut ConstraintSystem<F>,
advice: [Column<Advice>; 3],
instance: Column<Instance>,
) -> <Self as Chip<F>>::Config {
// Enable checking of equality for each of the columns
meta.enable_equality(instance);
for column in &advice {
meta.enable_equality(*column);
}

// The selectors we'll be using in the circuit
let s_binary_l = meta.selector();
let s_binary_r = meta.selector();
let s_xor = meta.selector();
let s_accumulator = meta.selector();

// Gate that checks that the value in the first column's cell is 0 or 1
meta.create_gate("is binary left", |meta| {
let value = meta.query_advice(advice[0], Rotation::cur());
let s_binary_l = meta.query_selector(s_binary_l);

vec![s_binary_l * (value.clone() * (Expression::Constant(F::ONE) - value))]
});

// Gate that checks that the value in the second column's cell is 0 or 1
meta.create_gate("is binary right", |meta| {
let value = meta.query_advice(advice[1], Rotation::cur());
let s_binary_r = meta.query_selector(s_binary_r);

vec![s_binary_r * (value.clone() * (Expression::Constant(F::ONE) - value))]
});

// This gate performs and XOR operation between two cells and outputs the the result to a third cell
meta.create_gate("xor", |meta| {
let lhs = meta.query_advice(advice[0], Rotation::cur());
let rhs = meta.query_advice(advice[1], Rotation::cur());
let out = meta.query_advice(advice[2], Rotation::cur());
let s_xor = meta.query_selector(s_xor);

// The XOR constraint is defined as (a + b - 2ab - out) == 0
vec![s_xor * (lhs.clone() + rhs.clone() - Expression::Constant(F::ONE.double()) * lhs * rhs - out)]
});

// This gate accumulates all of the values from the column of results of the XOR gate above it
meta.create_gate("accumulator", |meta| {
let inputs_sum = (0..BINARY_LENGTH)
.map(|i| meta.query_advice(advice[2], Rotation((i as i32) - (BINARY_LENGTH as i32))))
.fold(Expression::Constant(F::ZERO), |acc, e| acc + e);
let sum = meta.query_advice(advice[2], Rotation::cur());
let s_accumulator = meta.query_selector(s_accumulator);

vec![s_accumulator * (inputs_sum - sum)]
});

HammsterConfig {
advice,
instance,
s_binary_l,
s_binary_r,
s_xor,
s_accumulator,
}
}
}

Describing the Layout

We implement the chip traits for our HammsterChip in this section. Utilizing a tool in Halo 2 called the Layouter, we write the logic to determine how each gate will function and assign the cells based on that logic.

// file: circuits/src/hammster.rs

// This struct represents a number in the circuit, which wraps a cell
#[derive(Clone, Debug)]
struct Number<F: Field>(AssignedCell<F, F>);

// Implement all of the chip traits. In this section, we'll be describing how Layouter will assign values to
// various cells in the circuit.
impl<F: Field> Instructions<F> for HammsterChip<F> {
type Num = Number<F>;

// Loads private inputs into two advice columns and checks if the digits are binary values
fn load_private_and_check_binary(
&self,
mut layouter: impl Layouter<F>,
column: usize,
values: [Value<F>; BINARY_LENGTH]
) -> Result<Vec<Self::Num>, Error> {
let config = self.config();

layouter.assign_region(
|| "assign private values",
|mut region| {
values
.iter()
.enumerate()
.map(|(i, value)| {
// Check that each cell of the input is a binary value
if column == 0 {
config.s_binary_l.enable(&mut region, i)?;
} else {
config.s_binary_r.enable(&mut region, i)?;
}
// Assign the private input value to an advice cell
region
.assign_advice(|| "assign private input", config.advice[column], i, || *value)
.map(Number)
}
)
.collect()
}
)
}

// Performs and XOR operation between two field elements
fn xor(
&self,
mut layouter: impl Layouter<F>,
a: Self::Num,
b: Self::Num
) -> Result<Self::Num, Error> {
let config = self.config();

layouter.assign_region(
|| "assign xor region",
|mut region: Region<'_, F>| {
config.s_xor.enable(&mut region, 0)?;

// Copy the left and right advice cell values
let a_val = a.0.copy_advice(|| "lhs", &mut region, config.advice[0], 0)?;
let b_val = b.0.copy_advice(|| "rhs", &mut region, config.advice[1], 0)?;

// Calculate the XOR result
let xor_result = a_val.value()
.zip(b_val.value())
.map(|(a, b)| if *a == *b { F::ZERO } else { F::ONE });

// Assign the result to the third advice cell
region
.assign_advice(|| "a xor b", config.advice[2], 0, || xor_result)
.map(Number)
},
)
}

// Accumulates the column of XOR results into a single number
fn accumulator(
&self,
mut layouter: impl Layouter<F>,
values: [Self::Num; BINARY_LENGTH]
) -> Result<Self::Num, Error> {
let config = self.config();

layouter.assign_region(
|| "assign accumulator region",
|mut region: Region<'_, F>| {
config.s_accumulator.enable(&mut region, BINARY_LENGTH)?;

// Copy the result of the XOR values to the advice cells in the third column
for (i, value) in values.iter().enumerate() {
(*value).0.copy_advice(|| format!("output[{}]", i), &mut region, config.advice[2], i)?;
}

// Calculate the accumulation of the XOR column results
let accumulation = values
.iter()
.map(|n| n.0.value().copied())
.fold(Value::known(F::ZERO), |acc, e| acc + e);

// Assign the accumulation result to an advice cell
region
.assign_advice(|| "accumulation result", config.advice[2], BINARY_LENGTH, || accumulation)
.map(Number)
}
)
}

// Expose output of accumulated XORs as a public value. Constrain the accumulation value from
// (column advice[2], row BINARY_LENGTH) to equal instance column value in row 0 which is the
// public input of the Hamming distance calculated outside the circuit
fn expose_public(
&self,
mut layouter: impl Layouter<F>,
num: Self::Num
) -> Result<(), Error> {
let config = self.config();
layouter.constrain_instance(num.0.cell(), config.instance, 0)
}
}

Write the Circuit

Now, we can write the circuit using what we’ve built in the previous sections! We first define the circuit as a struct that takes in two field element slices of length BINARY_LENGTH (8 in this case).

// file: circuits/src/hammster.rs

#[derive(Default)]
pub struct HammsterCircuit<F: Field> {
a: [Value<F>; BINARY_LENGTH],
b: [Value<F>; BINARY_LENGTH],
}

We then need to write out the functions in the Circuit implementation for our HammsterCircuit. The main functions that are written here are the configure and synthesize functions, which perform as named.

// file: circuits/src/hammster.rs

impl<F: Field> Circuit<F> for HammsterCircuit<F> {
type Config = HammsterConfig;
type FloorPlanner = SimpleFloorPlanner;

fn without_witnesses(&self) -> Self {
// Just outputs the default circuit if calling without witnesses
Self::default()
}

fn configure(meta: &mut ConstraintSystem<F>) -> Self::Config {
// Our configuration will include 3 advice columns (two for inputs, one for the output of the XOR operation)
let advice = [meta.advice_column(), meta.advice_column(), meta.advice_column()];

// We'll also have one instance column for the public input (calculated Hamming distance)
let instance = meta.instance_column();

HammsterChip::configure(meta, advice, instance)
}

fn synthesize(&self, config: Self::Config, mut layouter: impl Layouter<F>) -> Result<(), Error> {
let hammster_chip = HammsterChip::<F>::construct(config);

// Load private variable vectors & check if each digit is binary
let a = hammster_chip.load_private_and_check_binary(layouter.namespace(|| "load a"), 0, self.a)?;
let b = hammster_chip.load_private_and_check_binary(layouter.namespace(|| "load b"), 1, self.b)?;

// Perform XOR on each row
let xor_results: Vec<Number<F>> = (0..BINARY_LENGTH)
.map(|i| {
hammster_chip.xor(layouter.namespace(|| format!("xor[{}]", i)), a[i].clone(), b[i].clone()).unwrap()
})
.collect();
let xor_slice: [Number<F>; 8] = xor_results.clone().try_into().unwrap();

// Accumulate the results of the XOR output column
let accumulate = hammster_chip.accumulator(layouter.namespace(|| "accumulate xor results"), xor_slice)?;

// Ensure the accumulated value equals the public input (of the precalculated accumulation value)
hammster_chip.expose_public(layouter.namespace(|| "expose accumulate"), accumulate)
}
}

Writing some helper functions

It’s pretty useful to have some helper functions that we can call externally to keep the code neater. Let’s write some of those here.

// file: circuits/src/hammster.rs

// Draws the layout of the circuit. Super useful for debugging.
#[cfg(not(target_family = "wasm"))]
pub fn draw_circuit<F: Field>(k: u32, circuit: &HammsterCircuit<F>) {
use plotters::prelude::*;
let base = BitMapBackend::new("layout.png", (1600,1600)).into_drawing_area();
base.fill(&WHITE).unwrap();
let base = base.titled("Hammster Circuit", ("sans-serif", 24)).unwrap();

halo2_proofs::dev::CircuitLayout::default()
.show_equality_constraints(true)
.render(k, circuit, &base)
.unwrap();
}

The follow two functions generate circuits. One outputs an empty circuit, the other outputs a circuit from two input vectors.

// file: circuits/src/hammster.rs

// Generates an empty circuit. Useful for generating the proving/verifying keys.
pub fn empty_circuit() -> HammsterCircuit<Fp> {
HammsterCircuit {
a: [Value::unknown(); BINARY_LENGTH],
b: [Value::unknown(); BINARY_LENGTH],
}
}

// Creates a circuit from two vector inputs
pub fn create_circuit(a: Vec<u64>, b: Vec<u64>) -> HammsterCircuit<Fp> {
// Put inputs into circuit-friendly form
let a_vec: [Value<Fp>; BINARY_LENGTH] = a
.clone()
.iter()
.map(|f| Value::known(Fp::from(*f)))
.collect::<Vec<Value<Fp>>>()
.try_into()
.unwrap();

let b_vec: [Value<Fp>; BINARY_LENGTH] = b
.clone()
.iter()
.map(|f| Value::known(Fp::from(*f)))
.collect::<Vec<Value<Fp>>>()
.try_into()
.unwrap();

// Create circuit from inputs
HammsterCircuit {
a: a_vec,
b: b_vec,
}
}

It’s also useful to have a few functions that generate setup params and proving/verifying keys. For generating the proving and verifying keys, you can just pass in an empty circuit (thanks to Jonathan Wang from Axiom for help with this!):

// file: circuits/src/hammster.rs

// Generates setup parameters using k, which is the number of rows of the circuit
// can fit in and must be a power of two
pub fn generate_setup_params(
k: u32,
) -> Params<EqAffine> {
Params::<EqAffine>::new(k)
}

// Generates the verifying and proving keys. We can pass in an empty circuit to generate these
pub fn generate_keys(
params: &Params<EqAffine>,
circuit: &HammsterCircuit<Fp>,
) -> (ProvingKey<EqAffine>, VerifyingKey<EqAffine>) {
// just to emphasize that for vk, pk we don't need to know the value of `x`
let vk = keygen_vk(params, circuit).expect("vk should not fail");
let pk = keygen_pk(params, vk.clone(), circuit).expect("pk should not fail");
(pk, vk)
}

This function simply takes our two input vectors and calculates the Hamming distance, outputting a vector containing a single field element, which is a nicer format for using in our proving and verifying functions later.

// file: circuits/src/hammster.rs

// Calculates the hamming distance between two vectors
pub fn calculate_hamming_distance(a: Vec<u64>, b: Vec<u64>) -> Vec<Fp> {
let hamming_dist = a
.clone()
.iter()
.enumerate()
.map(|(i, x)| (x + b[i]) % 2)
.fold(0, |acc, x| acc + x);
vec![Fp::from(hamming_dist)]
}

The mock prover is a very useful tool that can help you identify issues in the circuit. Our run_mock_prover function will pretty-print any errors found in the circuit.

// file: circuits/src/hammster.rs

// Runs the mock prover and prints any errors
pub fn run_mock_prover(
k: u32,
circuit: &HammsterCircuit<Fp>,
pub_input: &Vec<Fp>,
) {
let prover = MockProver::run(k, circuit, vec![pub_input.clone()]).expect("Mock prover should run");
let res = prover.verify();
match res {
Ok(()) => println!("MockProver OK"),
Err(e) => println!("err {:#?}", e),
}
}

In our generate_proof function, the create_proof function takes in a mutable transcript and writes the proof to the transcript, which we then finalize and output as the proof.

// file: circuits/src/hammster.rs

// Generates a proof
pub fn generate_proof(
params: &Params<EqAffine>,
pk: &ProvingKey<EqAffine>,
circuit: HammsterCircuit<Fp>,
pub_input: &Vec<Fp>,
) -> Vec<u8> {
println!("Generating proof...");
let mut transcript = Blake2bWrite::<_, _, Challenge255<_>>::init(vec![]);
create_proof(
params,
pk,
&[circuit],
&[&[pub_input]],
OsRng,
&mut transcript
).expect("Prover should not fail");
transcript.finalize()
}

In our verify function, the output will just be the result of verify_proof.

// file: circuits/src/hammster.rs

// Verifies the proof
pub fn verify(
params: &Params<EqAffine>,
vk: &VerifyingKey<EqAffine>,
pub_input: &Vec<Fp>,
proof: Vec<u8>,
) -> Result<(), Error> {
println!("Verifying proof...");
let strategy = SingleVerifier::new(&params);
let mut transcript = Blake2bRead::<_, _, Challenge255<_>>::init(&proof[..]);
verify_proof(
params,
vk,
strategy,
&[&[pub_input]],
&mut transcript,
)
}

Putting it all together

Alright, now we have a ton of useful functions that we can use to generate a proof and verify a proof of our Hamming distance app. We’ll go ahead and create one more file named circuits/src/lib.rs and add the following to it:

// file: circuits/src/lib.rs

pub mod hammster;

Then, we’ll go to circuits/src/main.rs and delete all of the boilerplate code and replace it with the code below. We add a cfg attribute to ensure that this doesn’t compile when we’re building our Wasm module, which we will do in part 2. We then call our helper functions to take us through the steps of doing the setup, generating the proof, and verifying it.

// file: circuits/src/main.rs

#[cfg(not(target_family = "wasm"))]
fn main() {
use hammster::hammster::{calculate_hamming_distance, create_circuit, empty_circuit, draw_circuit, generate_setup_params, generate_keys, generate_proof, verify, run_mock_prover};

// Size of the circuit. Circuit must fit within 2^k rows.
let k = 6;

// Input values to generate a proof with
let a_vec = vec![1, 1, 0, 1, 0, 1, 0, 0];
let b_vec: Vec<u64> = vec![0, 1, 0, 0, 0, 1, 1, 0];
let hamming_dist = calculate_hamming_distance(a_vec.clone(), b_vec.clone());

// Create circuit
let hammster_circuit = create_circuit(a_vec, b_vec);

// Items that are useful for debugging issues
draw_circuit(k, &hammster_circuit);
run_mock_prover(k, &hammster_circuit, &hamming_dist);

// Generate setup params
let params = generate_setup_params(k);

// Generate proving and verifying keys
let empty_circuit = empty_circuit();
let (pk, vk) = generate_keys(&params, &empty_circuit);

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

// Verify proof
let verify = verify(&params, &vk, &hamming_dist, proof);
println!("Verify result: {:?}", verify);
}

Running the program

From the circuits folder, we can run the program using the following command:

cargo run

Note: if you’re following along using the Github repository and are not using an Apple M architecture machine, you will need to modify target = "aarch64-apple-darwin" in circuits/.cargo/config to whatever matches your machine.

We should see the following output in the terminal:

   Compiling halo2_hammster v0.1.0 (/Users/yujiangtham/zk/hammster/circuits)
Finished dev [unoptimized + debuginfo] target(s) in 3.32s
Running `target/aarch64-apple-darwin/debug/halo2_hammster`
MockProver OK
Generating proof...
Verifying proof...
Verify result: Ok(())

Additionally, our draw_circuit function also outputs a nice diagram of the circuit layout:

Circuit layout showing instance (white), advice (red), and fixed (purple) columns.

This circuit layout is also super useful for debugging. The layout shows instance column in white, advice columns in red, and fixed (selector) columns in purple. The red lines between different cells also show equality constraints, which was an option that we enabled.

Conclusion

Well, that’s it for today! We were able to build a Zero Knowledge circuit that generates a proof and verifies it using the Halo 2 proof system. If you enjoyed this content, don’t forget to add a clap and follow me on here and Twitter. In part 2, we then compile our circuit to Wasm and build out a Next.js app around it!

Continue to part 2.

--

--