Skip to main content

Prover and Verifier Flow

1. Why this chapter exists

Chapters 03-10 cover the arguments and commitments individually. This chapter is the integration test: it walks through what keygen_vk, keygen_pk, create_proof, and verify_proof actually do, in order, and where each previous chapter's machinery slots in. If you debug a "verifier returned OK but the proof is wrong" issue, this is the chapter you re-read.

2. Definitions

Definition (Verifying key, VK). A bundle containing the constraint system shape, the evaluation domain, the commitments to the fixed columns, and the commitments to the permutation polynomials. The VK is what circumvents the need to recommunicate the circuit shape per proof. Defined in halo2_proofs/src/plonk.rs.

Definition (Proving key, PK). A VerifyingKey plus the fixed columns themselves and the permutation polynomials in coefficient form. The PK contains everything the prover needs to recompute the FFTs; the VK contains everything the verifier needs to check openings.

Definition (Proof). A byte string consumed by TranscriptRead. Structurally it is the concatenation of: advice column commitments (one per circuit, per advice column), permutation grand-product commitments, lookup commitments, vanishing polynomial commitments, multiopen commitments, and a final IPA opening.

3. The code

3.1 Key generation

halo2_proofs/src/plonk/keygen.rs
pub fn keygen_vk<C, ConcreteCircuit>(
params: &Params<C>,
circuit: &ConcreteCircuit,
) -> Result<VerifyingKey<C>, Error>
where
C: CurveAffine,
C::Scalar: FromUniformBytes<64>,
ConcreteCircuit: Circuit<C::Scalar>,
{
let (domain, cs, config) = create_domain::<C, ConcreteCircuit>(params);

if (params.n as usize) < cs.minimum_rows() {
return Err(Error::not_enough_rows_available(params.k));
}

let mut assembly: Assembly<C::Scalar> = Assembly {
k: params.k,
fixed: vec![domain.empty_lagrange_assigned(); cs.num_fixed_columns],
permutation: permutation::keygen::Assembly::new(params.n as usize, &cs.permutation),
selectors: vec![vec![false; params.n as usize]; cs.num_selectors],
usable_rows: 0..params.n as usize - (cs.blinding_factors() + 1),
_marker: std::marker::PhantomData,
};

// Synthesize the circuit to obtain URS
ConcreteCircuit::FloorPlanner::synthesize(
&mut assembly,
circuit,
config,
cs.constants.clone(),
)?;

let mut fixed = batch_invert_assigned(assembly.fixed);
let (cs, selector_polys) = cs.compress_selectors(assembly.selectors);
fixed.extend(
selector_polys
.into_iter()
.map(|poly| domain.lagrange_from_vec(poly)),
);

let permutation_vk = assembly
.permutation
.build_vk(params, &domain, &cs.permutation);

let fixed_commitments = fixed
.iter()
.map(|poly| params.commit_lagrange(poly, Blind::default()).to_affine())
.collect();

Ok(VerifyingKey::from_parts(
domain,
fixed_commitments,
permutation_vk,
cs,
))
}

keygen_vk walks the circuit, builds the ConstraintSystem, synthesizes the fixed columns and selectors using a witness-free copy of the circuit, runs selector combining, computes the permutation polynomials, and commits to everything.

halo2_proofs/src/plonk/keygen.rs
/// Generate a `ProvingKey` from a `VerifyingKey` and an instance of `Circuit`.
pub fn keygen_pk<C, ConcreteCircuit>(
params: &Params<C>,
vk: VerifyingKey<C>,
circuit: &ConcreteCircuit,
) -> Result<ProvingKey<C>, Error>
where
C: CurveAffine,
ConcreteCircuit: Circuit<C::Scalar>,
{
let mut cs = ConstraintSystem::default();
let config = ConcreteCircuit::configure(&mut cs);

let cs = cs;

if (params.n as usize) < cs.minimum_rows() {
return Err(Error::not_enough_rows_available(params.k));
}

let mut assembly: Assembly<C::Scalar> = Assembly {
k: params.k,
fixed: vec![vk.domain.empty_lagrange_assigned(); cs.num_fixed_columns],
permutation: permutation::keygen::Assembly::new(params.n as usize, &cs.permutation),
selectors: vec![vec![false; params.n as usize]; cs.num_selectors],
usable_rows: 0..params.n as usize - (cs.blinding_factors() + 1),
_marker: std::marker::PhantomData,
};

// Synthesize the circuit to obtain URS
ConcreteCircuit::FloorPlanner::synthesize(
&mut assembly,
circuit,
config,
cs.constants.clone(),
)?;

let mut fixed = batch_invert_assigned(assembly.fixed);
let (cs, selector_polys) = cs.compress_selectors(assembly.selectors);
fixed.extend(
selector_polys
.into_iter()
.map(|poly| vk.domain.lagrange_from_vec(poly)),
);

let fixed_polys: Vec<_> = fixed
.iter()
.map(|poly| vk.domain.lagrange_to_coeff(poly.clone()))
.collect();

let fixed_cosets = fixed_polys
.iter()
.map(|poly| vk.domain.coeff_to_extended(poly.clone()))
.collect();

let permutation_pk = assembly
.permutation
.build_pk(params, &vk.domain, &cs.permutation);

// Compute l_0(X)
// TODO: this can be done more efficiently
let mut l0 = vk.domain.empty_lagrange();
l0[0] = C::Scalar::ONE;
let l0 = vk.domain.lagrange_to_coeff(l0);
let l0 = vk.domain.coeff_to_extended(l0);

// Compute l_blind(X) which evaluates to 1 for each blinding factor row
// and 0 otherwise over the domain.
let mut l_blind = vk.domain.empty_lagrange();
for evaluation in l_blind[..].iter_mut().rev().take(cs.blinding_factors()) {
*evaluation = C::Scalar::ONE;
}
let l_blind = vk.domain.lagrange_to_coeff(l_blind);
let l_blind = vk.domain.coeff_to_extended(l_blind);

// Compute l_last(X) which evaluates to 1 on the first inactive row (just
// before the blinding factors) and 0 otherwise over the domain
let mut l_last = vk.domain.empty_lagrange();
l_last[params.n as usize - cs.blinding_factors() - 1] = C::Scalar::ONE;
let l_last = vk.domain.lagrange_to_coeff(l_last);
let l_last = vk.domain.coeff_to_extended(l_last);

Ok(ProvingKey {
vk,
l0,
l_blind,
l_last,
fixed_values: fixed,
fixed_polys,
fixed_cosets,
permutation: permutation_pk,
})
}

keygen_pk takes the VerifyingKey and re-runs the same synthesis to fill in the L1 / extended-domain caches used by the prover.

3.2 The prover

halo2_proofs/src/plonk/prover.rs
/// generated previously for the same circuit. The provided `instances`
/// are zero-padded internally.
pub fn create_proof<
C: CurveAffine,
E: EncodedChallenge<C>,
R: RngCore,
T: TranscriptWrite<C, E>,
ConcreteCircuit: Circuit<C::Scalar>,
>(
params: &Params<C>,
pk: &ProvingKey<C>,
circuits: &[ConcreteCircuit],
instances: &[&[&[C::Scalar]]],
mut rng: R,
transcript: &mut T,
) -> Result<(), Error> {
if circuits.len() != instances.len() {
return Err(Error::InvalidInstances);
}

for instance in instances.iter() {
if instance.len() != pk.vk.cs.num_instance_columns {
return Err(Error::InvalidInstances);
}
}

// Hash verification key into transcript
pk.vk.hash_into(transcript)?;

let domain = &pk.vk.domain;
let mut meta = ConstraintSystem::default();
let config = ConcreteCircuit::configure(&mut meta);

// Selector optimizations cannot be applied here; use the ConstraintSystem
// from the verification key.
let meta = &pk.vk.cs;

struct InstanceSingle<C: CurveAffine> {
pub instance_values: Vec<Polynomial<C::Scalar, LagrangeCoeff>>,
pub instance_polys: Vec<Polynomial<C::Scalar, Coeff>>,
pub instance_cosets: Vec<Polynomial<C::Scalar, ExtendedLagrangeCoeff>>,
}

let instance: Vec<InstanceSingle<C>> = instances
.iter()
.map(|instance| -> Result<InstanceSingle<C>, Error> {
let instance_values = instance
.iter()
.map(|values| {
let mut poly = domain.empty_lagrange();
assert_eq!(poly.len(), params.n as usize);
if values.len() > (poly.len() - (meta.blinding_factors() + 1)) {
return Err(Error::InstanceTooLarge);
}
for (poly, value) in poly.iter_mut().zip(values.iter()) {
*poly = *value;
}
Ok(poly)
})
.collect::<Result<Vec<_>, _>>()?;
let instance_commitments_projective: Vec<_> = instance_values
.iter()
.map(|poly| params.commit_lagrange(poly, Blind::default()))
.collect();
let mut instance_commitments =
vec![C::identity(); instance_commitments_projective.len()];
C::Curve::batch_normalize(&instance_commitments_projective, &mut instance_commitments);
let instance_commitments = instance_commitments;

The full prover flow (file is 786 lines; reading it once is worth a study session by itself):

  1. Hash the verifying key into the transcript (binds the proof to a particular circuit shape).
  2. For each circuit (when batching multiple circuits), synthesize the witness, FFT each advice column into coefficient form, blind, commit, write the commitment to the transcript.
  3. Squeeze the lookup challenge θ\theta; run the lookup prover for each lookup argument, commit aa' and ss'.
  4. Squeeze β,γ\beta, \gamma; run the permutation prover and the second half of the lookup prover (the grand product zz).
  5. Commit to vanishing polynomial commitments.
  6. Squeeze the gate-combining challenge yy.
  7. Construct the quotient polynomial hh on the extended coset domain; divide by tHt_H; split into pieces of length nn; commit to each piece.
  8. Squeeze the opening challenge xx.
  9. Evaluate every polynomial at xx (and at ωx\omega \cdot x for rotated queries); write evaluations to the transcript.
  10. Run the multiopen prover to produce a single IPA opening.

3.3 The verifier

halo2_proofs/src/plonk/verifier.rs

/// Returns a boolean indicating whether or not the proof is valid
pub fn verify_proof<
'params,
C: CurveAffine,
E: EncodedChallenge<C>,
T: TranscriptRead<C, E>,
V: VerificationStrategy<'params, C>,
>(
params: &'params Params<C>,
vk: &VerifyingKey<C>,
strategy: V,
instances: &[&[&[C::Scalar]]],
transcript: &mut T,
) -> Result<V::Output, Error> {
// Check that instances matches the expected number of instance columns
for instances in instances.iter() {
if instances.len() != vk.cs.num_instance_columns {
return Err(Error::InvalidInstances);
}
}

let instance_commitments = instances
.iter()
.map(|instance| {
instance
.iter()
.map(|instance| {
if instance.len() > params.n as usize - (vk.cs.blinding_factors() + 1) {
return Err(Error::InstanceTooLarge);
}
let mut poly = instance.to_vec();
poly.resize(params.n as usize, C::Scalar::ZERO);
let poly = vk.domain.lagrange_from_vec(poly);

Ok(params.commit_lagrange(&poly, Blind::default()).to_affine())
})
.collect::<Result<Vec<_>, _>>()
})
.collect::<Result<Vec<_>, _>>()?;

let num_proofs = instance_commitments.len();

// Hash verification key into transcript
vk.hash_into(transcript)?;

for instance_commitments in instance_commitments.iter() {
// Hash the instance (external) commitments into the transcript
for commitment in instance_commitments {
transcript.common_point(*commitment)?
}
}

let advice_commitments = (0..num_proofs)
.map(|_| -> Result<Vec<_>, _> {
// Hash the prover's advice commitments into the transcript

The verifier is structurally a mirror of the prover:

  1. Hash the verifying key.
  2. Read the advice / lookup / permutation commitments, re-squeeze each Fiat-Shamir challenge from the transcript.
  3. Read the vanishing-polynomial commitments and the quotient polynomial commitments.
  4. Read all polynomial evaluations.
  5. Re-compute the expected value of h(x)tH(x)h(x) \cdot t_H(x) from the gate-combining identity and check it against the supplied evaluations.
  6. Run the multiopen verifier, producing a Guard whose final-MSM the strategy then resolves.

The VerificationStrategy parameter (see halo2_proofs/src/plonk/verifier.rs) selects between single (default) and batched verification. SingleVerifier forces the MSM immediately; BatchVerifier accumulates the MSM across many proofs and forces them all once at the end. Batch verification can drop per-proof verifier cost from O(n)O(n) to O(logn)O(\log n) amortized.

3.4 The integration test

halo2_proofs/tests/plonk_api.rs
#![allow(clippy::many_single_char_names)]
#![allow(clippy::op_ref)]

use assert_matches::assert_matches;
use group::ff::{Field, WithSmallOrderMulGroup};
use halo2_proofs::arithmetic::CurveAffine;
use halo2_proofs::circuit::{Cell, Layouter, SimpleFloorPlanner, Value};
use halo2_proofs::dev::MockProver;
use halo2_proofs::pasta::{Eq, EqAffine, Fp};
use halo2_proofs::plonk::{
create_proof, keygen_pk, keygen_vk, verify_proof, Advice, Assigned, BatchVerifier, Circuit,
Column, ConstraintSystem, Error, Fixed, SingleVerifier, TableColumn, VerificationStrategy,
};
use halo2_proofs::poly::commitment::{Guard, MSM};
use halo2_proofs::poly::{commitment::Params, Rotation};
use halo2_proofs::transcript::{Blake2bRead, Blake2bWrite, Challenge255, EncodedChallenge};
use rand_core::OsRng;
use std::marker::PhantomData;

#[test]
fn plonk_api() {
const K: u32 = 5;

/// This represents an advice column at a certain row in the ConstraintSystem
#[derive(Copy, Clone, Debug)]
pub struct Variable(Column<Advice>, usize);

// Initialize the polynomial commitment parameters
let params: Params<EqAffine> = Params::new(K);

#[derive(Clone)]
struct PlonkConfig {
a: Column<Advice>,
b: Column<Advice>,
c: Column<Advice>,
d: Column<Advice>,
e: Column<Advice>,

sa: Column<Fixed>,
sb: Column<Fixed>,
sc: Column<Fixed>,
sm: Column<Fixed>,
sp: Column<Fixed>,
sl: TableColumn,
}

#[allow(clippy::type_complexity)]
trait StandardCs<FF: Field> {
fn raw_multiply<F>(
&self,
layouter: &mut impl Layouter<FF>,
f: F,
) -> Result<(Cell, Cell, Cell), Error>
where
F: FnMut() -> Value<(Assigned<FF>, Assigned<FF>, Assigned<FF>)>;
fn raw_add<F>(
&self,
layouter: &mut impl Layouter<FF>,
f: F,
) -> Result<(Cell, Cell, Cell), Error>

The integration test in tests/plonk_api.rs runs the full keygen-prove-verify loop on a hand-designed circuit and compares the resulting proof bytes against the checked-in fixture plonk_api_proof.bin. If you change anything that affects proof bytes (a new commitment, a reordered challenge, even an extra zero pad), this fixture must be regenerated.

4. Failure modes

  • Forgetting to hash the VK first. The first thing both prover and verifier do is vk.hash_into(transcript). Skipping this would let a malicious prover replay a proof against a different circuit. Audit any new transcript path for this.
  • Mismatched Params. Prover and verifier must use the same Params(k). Halo 2's Params::new is deterministic, so a fresh prover and verifier rederive identical params; do not introduce randomness here.
  • Proof byte drift. Changing the order of any operation that writes to the transcript (commitments, evaluations) silently changes the proof bytes. The plonk_api_proof.bin fixture catches this in CI.
  • Skipping the Guard. If you write a custom verifier that drops the Guard returned by verify_proof without calling use_challenges, the multiopen IPA check never runs and every proof passes. This is the kind of bug a security review catches.

5. Spec pointers

6. Exercises

  1. Open halo2_proofs/src/plonk/prover.rs and find every call to transcript.common_point(...), transcript.write_point(...), and transcript.write_scalar(...). In order, list each. This is the proof's structure.
  2. Repeat the exercise for the verifier in halo2_proofs/src/plonk/verifier.rs using read_point and read_scalar. Confirm the two lists are dual.
  3. Run cargo test --release -p halo2_proofs --test plonk_api and observe the test name(s). Confirm the proof fixture matches.
  4. Modify one byte of halo2_proofs/tests/plonk_api_proof.bin (use a hex editor) and rerun the test. Confirm the verifier rejects.
  5. Why does keygen_vk need a witness-free circuit, while keygen_pk accepts a witness-bearing one? Answer in two sentences after reading both functions.

Answers in the code

  • Exercise 1: the order is (roughly) vk.hash_into, advice commitments, lookup permuted commitments, permutation product commitments, vanishing commitments, quotient-poly piece commitments, evaluations, multiopen openings.
  • Exercise 5: keygen_vk only needs the shape; the witness would be redundant. keygen_pk re-synthesizes to fill the extended caches but does not include witness data in the proving key.

7. Further reading