explainers
We introduce the concept of the Challenge API in vanilla (PSE) Halo2 and then discuss an important application of this API to a technique around random linear combinations (RLC). This is a new feature only introduced in the Privacy Scaling Exploration's fork of Halo2.
We start by reviewing the basic idea around Halo2 with PLONKish arithmetization. The Prover supplies a bunch of witness values in a table:
(We ignore everything else besides witness assignment in this discussion.) The Prover then takes each column of the table, converts the column to a polynomial, and then commits to the polynomial via some polynomial commitment scheme.
In the interactive version of a ZK proving scheme, the Verifier would now send the Prover some random numbers, which the Prover would then use to do things like permutation arguments and proofs of polynomial openings.
However in practice we never use the interactive version. Instead, the Prover simulates the Verifier's random queries using the Fiat-Shamir transform. At first pass, this means the Prover needs to hash all witness values to produce a random challenge. In practice, we don't need to hash all the witnesses, we just need to hash commitments to all the witnesses. Since we already have polynomial commitments of each column, we can just hash those. This hash becomes the stand-in for the random challenge sent by the Verifier.
We have just described a Challenge API with a single "phase". This is already what is done whenever you generate a proof in Halo2 – it's just that the challenge generation is done on the backend and hidden from the user.
Here is a common problem: inside a ZK circuit, the Prover wants to compute witness values that depend on a (pseudo-)random number.
How do you access randomness inside a circuit? In an interactive protocol, you can ask the Verifier for a random challenge. For this to work, you must commit to all witness values you've generated so far, ask the Verifier for a random number, and then use that random number in subsequent witness computations.
In a non-interactive protocol, we use Fiat-Shamir again: you commit to all witnesses so far and hash the commitments to get a random challenge value. You can now use this random challenge as part of subsequent witness computations.
Halo2 provides an explicit API for generating these random challenges: when you design your circuit, you partition the columns into "phases".
During witness generation, the Prover first computes all witness values in columns in First Phase. Then on the backend they commit to those columns via the polynomial commitment scheme. They hash those commitments to get a challenge value
Let's look at an example in vanilla/raw Halo2:
Here's how to define a column in Second Phase during configuration.
// Second phase
let z = meta.advice_column_in(SecondPhase);
Similar to Column
s and Selector
s, your configuration will need to keep track of the Challenge
struct.
struct MyConfig {
// ..other stuff
theta: Challenge,
}
// ...
fn configure<F: Field>(meta: &mut ConstraintSystem<F>) -> Self {
let theta = meta.challenge_usable_after(FirstPhase)
// ...
You can then get an expression from the challenge theta
to use as part of a custom gate definition; see here.
In order to compute the witness values in Second Phase, you need to know the actual value of the challenge. During synthesize you can get a Value<F>
for this challenge by calling
let theta = layouter.get_challenge(config.theta);
where config.theta
has type Challenge
and theta
has type Value<F>
.
But wait! In synthesize
you can assign witness values to columns across all phases in any order you'd like. How does the API actually compute the challenge value? There is a dirty secret: the Halo2 backend will run synthesize
twice (assuming you have two phases):
layouter.get_challenge(config.theta)
returns Value::unknown()
. It assigns witnesses to all columns, but values in Second Phase columns don't matter / they can be unknown.theta
.layouter.get_challenge(config.theta)
returns the correct challenge value. All witness values are re-generated, including those in First Phase. Now witness values in Second Phase will be calculated using the actual challenge value.Pros:
Cons:
The con of calling witness generation twice (or three times if three phases 😱) is clearly undesirable. In an ideal setting, we get the best of both worlds by creating an API where all functions involving multiple phases are called concurrently asynchronously. These async functions would then all wait for a broadcast of the challenge value before continuing with witness generation. This would involve significant engineering changes, so we are not there yet.
In Axiom's fork of Halo2, we opt to solve the witness generation problem, albeit at some cost to the developer experience:
We add a function next_phase
that allows the Prover to tell the backend to commit to all columns in a Phase during witness generation. This means you can call
region.next_phase()
during synthesize
. The developer now needs to separate a two-phase function fn_name
into
fn_name_phase0(...)
fn_name_phase1(...)
where fn_name_phase{i}
only does witness generation for cells in columns of the {i+1}
th Phase.
During synthesize
, they can
fn_name_phase0()
to fill in witnesses for FirstPhase columnsregion.next_phase()
to commit to those columnsregion.get_challenge(config.theta)
to get the random challenge value equal to the hash of commitmentsfn_name_phase1()
to fill in witness for SecondPhase columnsIn this process, the entire synthesize
function only needs to be called once!
Consider the following problems:
a, b
as witnesses. You want to compute the boolean value a == b
in your circuit.a, b, c
as witnesses. You can to check that a . b == c
in your circuit, where .
denotes concatenation.While there are fun solutions to these problems (exercise!) in the single phase Halo2 API (or in any other proving system), they are still rather costly in terms of increasing overall circuit size (and hence performance).
The multi-phase challenge API provides us a very powerful new technique for these types of problems:
Let us have an in-circuit random challenge
Returning to the previous problems:
The proof of both statements is by considering both sides as polynomials in a variable
We provide an implementation of a gate for RLC computations using the Halo2 challenge API in axiom_eth::rlp::rlc
. We give an overview of the idea.
Firstly, we use our usual halo2-base
simple gate for columns in FirstPhase.
We add a new column in SecondPhase with an accompanying selector:
RLC Advice | RLC Selector |
---|---|
We create a new custom gate on these two columns imposing the constraint
Now for example we can constrain the computation of
RLC Advice | RLC Selector |
---|---|
1 | |
0 | |
1 | |
0 | |
0 |
Lastly, if we want to take GateChip
or RangeChip
, we must now use a new SecondPhase column with the halo2-base
simple gate constraint on that column. We can no longer use the FirstPhase column because any witness values involving
Just like with GateChip
and RangeChip
, we have implemented a RlcChip
with commonly used functions related to RLC computations. These functions include:
compute_rlc
compute_rlc_fixed_len
constrain_rlc_concat
constrain_rlc_concat_var
rlc_pow
To use RlcChip
within axiom-eth
, you need to create a circuit using either RlcCircuitBuilder
or KeccakCircuitBuilder
(we will not discuss keccak here).
An example of how to create such a circuit can be found here. The most notable point is that due to the fact that RlcChip
should be used in SecondPhase after FirstPhase columns have been committed to and a challenge RlcCircuitBuilder
streamlines this process by taking a Rust closure having RlcChip
as one of the closure arguments. This way you do not need to deal with creating an RlcChip
yourself, you just need to supply the function that uses it for SecondPhase witness generation:
let synthesize_phase1 = move |builder: &mut RlcThreadBuilder<F>, rlc: &RlcChip<F>| {
// the closure captures the `inputs` variable
log::info!("phase 1 synthesize begin");
let gate = GateChip::default();
let (ctx_gate, ctx_rlc) = builder.rlc_ctx_pair();
let rlc_trace = rlc.compute_rlc((ctx_gate, ctx_rlc), &gate, inputs, len);
let rlc_val = *rlc_trace.rlc_val.value();
dbg!(rlc_val);
};
Here builder.rlc_ctx_pair()
gives two Context
s, which can be thought of as memory banks storing witness values and other constraints. Then ctx_gate
corresponds to the SecondPhase column with halo2-base
simple gate, and ctx_rlc
corresponds to the SecondPhase RLC advice column above.