Chapter 0: Halo World
More boilerplate than Java.
Setup
For this we are going to need Rust 1.67.0
for this:
$ rustup default 1.67.0
To follow along, create a new Rust project:
$ cargo init --bin halo-hero
And populate Cargo.toml
as follows:
[package]
name = "halo-hero"
version = "0.1.0"
edition = "2021"
[dependencies]
ff = "0.13"
rand = "0.8"
rand_chacha = "0.3.1"
halo2_proofs = { git = "https://github.com/privacy-scaling-explorations/halo2", tag = "v0.3.0" }
A few notes on the dependencies, there are two:
ff
provides finite field arithmetic traits.halo2_proofs
is the Halo2 library we will use, duh.
There is a sea of different versions of Halo2 out there:
- The original Halo2 by the Zcash foundation: it does not support KZG.
- The Privacy Scaling Explorations (PSE) fork which adds KZG support.
- The Scroll fork which forked from PSE.
- The Axiom fork which forked from PSE.
- And more...
However, you write circuits in the same way for all of them.
How To Do Nothing
Let's start by taking a look at the simplest possible circuit: one that does nothing.
Unfortunately, this still requires quite a bit of boilerplate, here is a minimal example:
use std::marker::PhantomData;
use halo2_proofs::{
circuit::{Layouter, SimpleFloorPlanner},
dev::MockProver,
plonk::{self, Circuit, ConstraintSystem},
};
use ff::Field;
struct TestCircuit<F: Field> {
_ph: PhantomData<F>,
}
#[derive(Clone, Debug)]
struct TestConfig<F: Field + Clone> {
_ph: PhantomData<F>,
}
impl<F: Field> Circuit<F> for TestCircuit<F> {
type Config = TestConfig<F>;
type FloorPlanner = SimpleFloorPlanner;
fn without_witnesses(&self) -> Self {
TestCircuit { _ph: PhantomData }
}
#[allow(unused_variables)]
fn configure(meta: &mut ConstraintSystem<F>) -> Self::Config {
TestConfig { _ph: PhantomData }
}
#[allow(unused_variables)]
fn synthesize(
&self,
config: Self::Config,
layouter: impl Layouter<F>,
) -> Result<(), plonk::Error> {
Ok(())
}
}
fn main() {
use halo2_proofs::halo2curves::bn256::Fr;
let circuit = TestCircuit::<Fr> { _ph: PhantomData };
let prover = MockProver::run(8, &circuit, vec![]).unwrap();
prover.verify().unwrap();
}
We will cover the different parts of this code in more detail in the next chapters.
For now we will just make a few observations:
-
The
Circuit<F>
trait represents a "top-level" circuit which may be proved or verified. -
The
Circuit<F>
trait is generic over the finite fieldF
for which the circuit is defined.
In the example above, we implementCircuit<F>
for everyTestCircuit<F>
. -
You might not always be able to make your circuit generic over every field because it relies on field-specific properties. Perhaps the field needs to be of a certain size, or you might need the ability to convert an integer to a field element -- which requires
F: PrimeField
.
In practice, every circuit is over a prime field :) -
The
Circuit<F>
trait has two associated types:Config
andFloorPlanner
.
The floor planner is not that important, but we will touch on it in the next chapter. -
The
without_witnesses
function is used by the verifier to create an instance of the circuit without any witness data. This is required to compute the verification key, which would otherwise require the verifier to know a witness for the circuit in order to generate the verification key required to check the SNARK -- which would partly defeat the purpose.
All of this brings us to the two most central concepts in Halo2.
In Halo2, there are two steps in creating a circuit:
Configuration
This is implemented by the configure
function.
The high-level task of configuration is to define the available collection of gates in the circuit.
Because Halo2 circuits are much more flexible than traditional arithmetic circuits,
consisting of only addition and multiplication gates,
we need to define the gates that we will use somewhere.
This is done in the configure
function.
The resulting Config
can then be used to create instances of the gates (or gadgets) in the circuit.
We will cover this in more detail in the next chapter.
Synthesis
After the configuration step, the circuit is synthesized.
This is implemented by the synthesize
function.
After defining the gates, we need to create a circuit out of them.
This is done in the synthesize
function.
Which, not surprisingly, takes the Config
produced by the configure
function.
It also takes a Layouter
which is used to store the state of the circuit as it is being built.
Finally, the layouter is then responsible for, well, laying out the circuit in some "physical" space
which we will cover in a lot more detail in the next chapter.