Skip to main content

p3_uni_stark/
verifier.rs

1//! See [`crate::prover`] for an overview of the protocol and a more detailed soundness analysis.
2
3use alloc::string::String;
4use alloc::vec::Vec;
5use alloc::{format, vec};
6
7use itertools::Itertools;
8use p3_air::symbolic::SymbolicAirBuilder;
9use p3_air::{Air, RowWindow};
10use p3_challenger::{CanObserve, FieldChallenger};
11use p3_commit::{Pcs, PolynomialSpace};
12use p3_field::{BasedVectorSpace, Field, PrimeCharacteristicRing};
13use p3_matrix::dense::RowMajorMatrixView;
14use p3_matrix::stack::VerticalPair;
15use p3_util::zip_eq::zip_eq;
16use thiserror::Error;
17use tracing::instrument;
18
19use crate::symbolic::get_log_num_quotient_chunks;
20use crate::{
21    AirLayout, Domain, PcsError, PreprocessedVerifierKey, Proof, StarkGenericConfig, Val,
22    VerifierConstraintFolder,
23};
24
25/// Recomposes the quotient polynomial from its chunks evaluated at a point.
26///
27/// Given quotient chunks and their domains, this computes the Lagrange
28/// interpolation coefficients (zps) and reconstructs quotient(zeta).
29pub fn recompose_quotient_from_chunks<SC>(
30    quotient_chunks_domains: &[Domain<SC>],
31    quotient_chunks: &[Vec<SC::Challenge>],
32    zeta: SC::Challenge,
33) -> SC::Challenge
34where
35    SC: StarkGenericConfig,
36{
37    let zps = quotient_chunks_domains
38        .iter()
39        .enumerate()
40        .map(|(i, domain)| {
41            quotient_chunks_domains
42                .iter()
43                .enumerate()
44                .filter(|(j, _)| *j != i)
45                .map(|(_, other_domain)| {
46                    other_domain.vanishing_poly_at_point(zeta)
47                        * other_domain
48                            .vanishing_poly_at_point(domain.first_point())
49                            .inverse()
50                })
51                .product::<SC::Challenge>()
52        })
53        .collect_vec();
54
55    quotient_chunks
56        .iter()
57        .enumerate()
58        .map(|(ch_i, ch)| {
59            // We checked in valid_shape the length of "ch" is equal to
60            // <SC::Challenge as BasedVectorSpace<Val<SC>>>::DIMENSION. Hence
61            // the unwrap() will never panic.
62            zps[ch_i]
63                * ch.iter()
64                    .enumerate()
65                    .map(|(e_i, &c)| SC::Challenge::ith_basis_element(e_i).unwrap() * c)
66                    .sum::<SC::Challenge>()
67        })
68        .sum::<SC::Challenge>()
69}
70
71/// Verifies that the folded constraints match the quotient polynomial at zeta.
72///
73/// This evaluates the [`Air`] constraints at the out-of-domain point and checks
74/// that constraints(zeta) / Z_H(zeta) = quotient(zeta).
75#[allow(clippy::too_many_arguments)]
76pub fn verify_constraints<SC, A, PcsErr>(
77    air: &A,
78    trace_local: &[SC::Challenge],
79    trace_next: &[SC::Challenge],
80    preprocessed_local: Option<&[SC::Challenge]>,
81    preprocessed_next: Option<&[SC::Challenge]>,
82    public_values: &[Val<SC>],
83    trace_domain: Domain<SC>,
84    zeta: SC::Challenge,
85    alpha: SC::Challenge,
86    quotient: SC::Challenge,
87) -> Result<(), VerificationError<PcsErr>>
88where
89    SC: StarkGenericConfig,
90    A: for<'a> Air<VerifierConstraintFolder<'a, SC>>,
91    PcsErr: core::fmt::Debug,
92{
93    let sels = trace_domain.selectors_at_point(zeta);
94
95    let main = VerticalPair::new(
96        RowMajorMatrixView::new_row(trace_local),
97        RowMajorMatrixView::new_row(trace_next),
98    );
99
100    let preprocessed = match (preprocessed_local, preprocessed_next) {
101        (Some(local), Some(next)) => VerticalPair::new(
102            RowMajorMatrixView::new_row(local),
103            RowMajorMatrixView::new_row(next),
104        ),
105        _ => VerticalPair::new(
106            RowMajorMatrixView::new(&[], 0),
107            RowMajorMatrixView::new(&[], 0),
108        ),
109    };
110
111    let preprocessed_window =
112        RowWindow::from_two_rows(preprocessed.top.values, preprocessed.bottom.values);
113    let mut folder = VerifierConstraintFolder {
114        main,
115        preprocessed,
116        preprocessed_window,
117        public_values,
118        is_first_row: sels.is_first_row,
119        is_last_row: sels.is_last_row,
120        is_transition: sels.is_transition,
121        alpha,
122        accumulator: SC::Challenge::ZERO,
123    };
124    air.eval(&mut folder);
125    let folded_constraints = folder.accumulator;
126
127    // Check that constraints(zeta) / Z_H(zeta) = quotient(zeta)
128    if folded_constraints * sels.inv_vanishing != quotient {
129        return Err(VerificationError::OodEvaluationMismatch { index: None });
130    }
131
132    Ok(())
133}
134
135/// Validates and commits the preprocessed trace if present.
136/// Returns the preprocessed width and its commitment hash (available iff width > 0).
137#[allow(clippy::type_complexity)]
138fn process_preprocessed_trace<SC, A>(
139    air: &A,
140    opened_values: &crate::proof::OpenedValues<SC::Challenge>,
141    preprocessed_vk: Option<&PreprocessedVerifierKey<SC>>,
142) -> Result<
143    (
144        usize,
145        Option<<SC::Pcs as Pcs<SC::Challenge, SC::Challenger>>::Commitment>,
146    ),
147    VerificationError<PcsError<SC>>,
148>
149where
150    SC: StarkGenericConfig,
151    A: for<'a> Air<VerifierConstraintFolder<'a, SC>>,
152{
153    // Determine expected preprocessed width.
154    // - If a verifier key is provided, trust its width.
155    // - Otherwise, derive width from the AIR's preprocessed trace (if any).
156    let preprocessed_width = preprocessed_vk
157        .map(|vk| vk.width)
158        .or_else(|| air.preprocessed_trace().as_ref().map(|m| m.width))
159        .unwrap_or(0);
160
161    // Check that the proof's opened preprocessed values match the expected width.
162    let preprocessed_local_len = opened_values
163        .preprocessed_local
164        .as_ref()
165        .map_or(0, |v| v.len());
166    let preprocessed_next_len = opened_values
167        .preprocessed_next
168        .as_ref()
169        .map_or(0, |v| v.len());
170    let expected_next_len = if !air.preprocessed_next_row_columns().is_empty() {
171        preprocessed_width
172    } else {
173        0
174    };
175    if preprocessed_width != preprocessed_local_len || expected_next_len != preprocessed_next_len {
176        // Verifier expects preprocessed trace while proof does not have it, or vice versa
177        return Err(VerificationError::InvalidProofShape);
178    }
179
180    // Validate consistency between width, verifier key, and zk settings.
181    match (preprocessed_width, preprocessed_vk) {
182        // Case: No preprocessed columns.
183        //
184        // Valid only if no verifier key is provided.
185        (0, None) => Ok((0, None)),
186
187        // Case: Preprocessed columns exist.
188        //
189        // Valid only if VK exists, widths match, and we are NOT in zk mode.
190        (w, Some(vk)) if w == vk.width => Ok((w, Some(vk.commitment.clone()))),
191
192        // Catch-all for invalid states, such as:
193        // - Width is 0 but VK is provided.
194        // - Width > 0 but VK is missing.
195        // - Width > 0 but VK width mismatches the expected width.
196        _ => Err(VerificationError::InvalidProofShape),
197    }
198}
199
200#[instrument(skip_all)]
201pub fn verify<SC, A>(
202    config: &SC,
203    air: &A,
204    proof: &Proof<SC>,
205    public_values: &[Val<SC>],
206) -> Result<(), VerificationError<PcsError<SC>>>
207where
208    SC: StarkGenericConfig,
209    A: Air<SymbolicAirBuilder<Val<SC>>> + for<'a> Air<VerifierConstraintFolder<'a, SC>>,
210{
211    verify_with_preprocessed(config, air, proof, public_values, None)
212}
213
214#[instrument(skip_all)]
215pub fn verify_with_preprocessed<SC, A>(
216    config: &SC,
217    air: &A,
218    proof: &Proof<SC>,
219    public_values: &[Val<SC>],
220    preprocessed_vk: Option<&PreprocessedVerifierKey<SC>>,
221) -> Result<(), VerificationError<PcsError<SC>>>
222where
223    SC: StarkGenericConfig,
224    A: Air<SymbolicAirBuilder<Val<SC>>> + for<'a> Air<VerifierConstraintFolder<'a, SC>>,
225{
226    let Proof {
227        commitments,
228        opened_values,
229        opening_proof,
230        degree_bits,
231    } = proof;
232
233    let pcs = config.pcs();
234    let degree = 1 << degree_bits;
235    let trace_domain = pcs.natural_domain_for_degree(degree);
236    // TODO: allow moving preprocessed commitment to preprocess time, if known in advance
237    let (preprocessed_width, preprocessed_commit) =
238        process_preprocessed_trace::<SC, A>(air, opened_values, preprocessed_vk)?;
239
240    // Ensure the preprocessed trace and main trace have the same height.
241    if let Some(vk) = preprocessed_vk
242        && preprocessed_width > 0
243        && vk.degree_bits != *degree_bits
244    {
245        return Err(VerificationError::InvalidProofShape);
246    }
247
248    let layout = AirLayout {
249        preprocessed_width,
250        main_width: air.width(),
251        num_public_values: air.num_public_values(),
252        ..Default::default()
253    };
254    let log_num_quotient_chunks =
255        get_log_num_quotient_chunks::<Val<SC>, A>(air, layout, config.is_zk());
256    let num_quotient_chunks = 1 << (log_num_quotient_chunks + config.is_zk());
257    let mut challenger = config.initialise_challenger();
258    let init_trace_domain = pcs.natural_domain_for_degree(degree >> (config.is_zk()));
259
260    let quotient_domain =
261        trace_domain.create_disjoint_domain(1 << (degree_bits + log_num_quotient_chunks));
262    let quotient_chunks_domains = quotient_domain.split_domains(num_quotient_chunks);
263
264    let randomized_quotient_chunks_domains = quotient_chunks_domains
265        .iter()
266        .map(|domain| pcs.natural_domain_for_degree(domain.size() << (config.is_zk())))
267        .collect_vec();
268    // Check that the random commitments are/are not present depending on the ZK setting.
269    // - If ZK is enabled, the prover should have random commitments.
270    // - If ZK is not enabled, the prover should not have random commitments.
271    if (opened_values.random.is_some() != SC::Pcs::ZK)
272        || (commitments.random.is_some() != SC::Pcs::ZK)
273    {
274        return Err(VerificationError::RandomizationError);
275    }
276
277    let air_width = A::width(air);
278    let main_next = !air.main_next_row_columns().is_empty();
279    let pre_next = !air.preprocessed_next_row_columns().is_empty();
280    let trace_next_ok = if main_next {
281        opened_values
282            .trace_next
283            .as_ref()
284            .is_some_and(|v| v.len() == air_width)
285    } else {
286        opened_values.trace_next.is_none()
287    };
288    let valid_shape = opened_values.trace_local.len() == air_width
289        && trace_next_ok
290        && opened_values.quotient_chunks.len() == num_quotient_chunks
291        && opened_values
292            .quotient_chunks
293            .iter()
294            .all(|qc| qc.len() == SC::Challenge::DIMENSION)
295        // We've already checked that opened_values.random is present if and only if ZK is enabled.
296        && opened_values.random.as_ref().is_none_or(|r_comm| r_comm.len() == SC::Challenge::DIMENSION);
297    if !valid_shape {
298        return Err(VerificationError::InvalidProofShape);
299    }
300
301    // Observe the instance.
302    challenger.observe(Val::<SC>::from_usize(proof.degree_bits));
303    challenger.observe(Val::<SC>::from_usize(proof.degree_bits - config.is_zk()));
304    challenger.observe(Val::<SC>::from_usize(preprocessed_width));
305    // TODO: Might be best practice to include other instance data here in the transcript, like some
306    // encoding of the AIR. This protects against transcript collisions between distinct instances.
307    // Practically speaking though, the only related known attack is from failing to include public
308    // values. It's not clear if failing to include other instance data could enable a transcript
309    // collision, since most such changes would completely change the set of satisfying witnesses.
310    challenger.observe(commitments.trace.clone());
311    if preprocessed_width > 0 {
312        challenger.observe(preprocessed_commit.as_ref().unwrap().clone());
313    }
314    challenger.observe_slice(public_values);
315
316    // Get the first Fiat Shamir challenge which will be used to combine all constraint polynomials
317    // into a single polynomial.
318    //
319    // Soundness Error: n/|EF| where n is the number of constraints.
320    let alpha = challenger.sample_algebra_element();
321    challenger.observe(commitments.quotient_chunks.clone());
322
323    // We've already checked that commitments.random is present if and only if ZK is enabled.
324    // Observe the random commitment if it is present.
325    if let Some(r_commit) = commitments.random.clone() {
326        challenger.observe(r_commit);
327    }
328
329    // Get an out-of-domain point to open our values at.
330    //
331    // Soundness Error: dN/|EF| where `N` is the trace length and our constraint polynomial has degree `d`.
332    let zeta = challenger.sample_algebra_element();
333    let zeta_next = init_trace_domain
334        .next_point(zeta)
335        .ok_or(VerificationError::NextPointUnavailable)?;
336
337    // We've already checked that commitments.random and opened_values.random are present if and only if ZK is enabled.
338    let mut coms_to_verify = if let Some(random_commit) = &commitments.random {
339        let random_values = opened_values
340            .random
341            .as_ref()
342            .ok_or(VerificationError::RandomizationError)?;
343        vec![(
344            random_commit.clone(),
345            vec![(trace_domain, vec![(zeta, random_values.clone())])],
346        )]
347    } else {
348        vec![]
349    };
350    let trace_round = {
351        let mut trace_points = vec![(zeta, opened_values.trace_local.clone())];
352        if main_next {
353            trace_points.push((
354                zeta_next,
355                opened_values
356                    .trace_next
357                    .clone()
358                    .expect("checked in shape validation"),
359            ));
360        }
361        (
362            commitments.trace.clone(),
363            vec![(trace_domain, trace_points)],
364        )
365    };
366    coms_to_verify.extend(vec![
367        trace_round,
368        (
369            commitments.quotient_chunks.clone(),
370            // Check the commitment on the randomized domains.
371            zip_eq(
372                randomized_quotient_chunks_domains.iter(),
373                &opened_values.quotient_chunks,
374                VerificationError::InvalidProofShape,
375            )?
376            .map(|(domain, values)| (*domain, vec![(zeta, values.clone())]))
377            .collect_vec(),
378        ),
379    ]);
380
381    // Add preprocessed commitment verification if present
382    if preprocessed_width > 0 {
383        let mut pre_points = vec![(zeta, opened_values.preprocessed_local.clone().unwrap())];
384        if pre_next {
385            pre_points.push((zeta_next, opened_values.preprocessed_next.clone().unwrap()));
386        }
387        coms_to_verify.push((
388            preprocessed_commit.unwrap(),
389            vec![(trace_domain, pre_points)],
390        ));
391    }
392
393    pcs.verify(coms_to_verify, opening_proof, &mut challenger)
394        .map_err(VerificationError::InvalidOpeningArgument)?;
395
396    let quotient = recompose_quotient_from_chunks::<SC>(
397        &quotient_chunks_domains,
398        &opened_values.quotient_chunks,
399        zeta,
400    );
401
402    let zeros;
403    let trace_next_slice = match &opened_values.trace_next {
404        Some(v) => v.as_slice(),
405        None => {
406            zeros = vec![SC::Challenge::ZERO; air_width];
407            &zeros
408        }
409    };
410    let pre_next_zeros;
411    let preprocessed_next_for_verify = match &opened_values.preprocessed_next {
412        Some(v) => Some(v.as_slice()),
413        None if preprocessed_width > 0 => {
414            pre_next_zeros = vec![SC::Challenge::ZERO; preprocessed_width];
415            Some(pre_next_zeros.as_slice())
416        }
417        None => None,
418    };
419    verify_constraints::<SC, A, PcsError<SC>>(
420        air,
421        &opened_values.trace_local,
422        trace_next_slice,
423        opened_values.preprocessed_local.as_deref(),
424        preprocessed_next_for_verify,
425        public_values,
426        init_trace_domain,
427        zeta,
428        alpha,
429        quotient,
430    )?;
431
432    Ok(())
433}
434
435#[derive(Debug, Error)]
436pub enum VerificationError<PcsErr>
437where
438    PcsErr: core::fmt::Debug,
439{
440    #[error("invalid proof shape")]
441    InvalidProofShape,
442    /// An error occurred while verifying the claimed openings.
443    #[error("invalid opening argument: {0:?}")]
444    InvalidOpeningArgument(PcsErr),
445    /// Out-of-domain evaluation mismatch, i.e. `constraints(zeta)` did not match
446    /// `quotient(zeta) Z_H(zeta)`.
447    #[error("out-of-domain evaluation mismatch{}", .index.map(|i| format!(" at index {}", i)).unwrap_or_default())]
448    OodEvaluationMismatch { index: Option<usize> },
449    /// The FRI batch randomization does not correspond to the ZK setting.
450    #[error("randomization error: FRI batch randomization does not match ZK setting")]
451    RandomizationError,
452    /// The domain does not support computing the next point algebraically.
453    #[error(
454        "next point unavailable: domain does not support computing the next point algebraically"
455    )]
456    NextPointUnavailable,
457    /// Lookup related error
458    #[error("lookup error: {0}")]
459    LookupError(String),
460}