Skip to main content

p3_uni_stark/
transcript.rs

1//! Fiat-Shamir transcript of the univariate STARK.
2//!
3//! # Overview
4//!
5//! One statement of what a uni-STARK run's transcript is, consumed by both sides.
6//!
7//! It is built from the configuration and from the AIR's shape.
8//! Both exist before any proof does, so neither side reads the shape from one.
9//!
10//! # Shape
11//!
12//! ```text
13//!     trace commitment           one opaque value
14//!     preprocessed commitment    only when the AIR has preprocessed columns
15//!     public values              num_public_values base elements
16//!     alpha                      one extension element
17//!     quotient commitment        one opaque value
18//!     randomization commitment   only when the PCS is zero-knowledge
19//!     out-of-domain grinding     only when the difficulty is positive
20//!     zeta                       one extension element
21//!     Begin  opening argument    bracket around the delegated run
22//!     End    opening argument
23//! ```
24//!
25//! # What is bound
26//!
27//! - Shape: the public-value count, the two optional commitments, the grinding difficulty.
28//! - Instance label: the two trace heights and the rest of the AIR's shape.
29//! - Nothing: the constraint polynomials the AIR evaluates.
30//!
31//! Two AIRs of identical shape whose constraints differ therefore share a seed.
32//!
33//! ```text
34//!     bound:   widths, counts, heights   ->  the frame the constraints live in
35//!     unbound: the constraints           ->  checked directly, at zeta
36//! ```
37//!
38//! The verifier evaluates the AIR it was handed at `zeta`.
39//! An AIR other than the one it holds is not a transcript question at all.
40//!
41//! # Soundness
42//!
43//! `alpha` collapses every constraint into one polynomial identity.
44//!
45//! ```text
46//!     sum_j alpha^j * C_j(x)  =  Z_H(x) * Q(x)
47//! ```
48//!
49//! A prover who learns `alpha` first picks a trace that satisfies the collapsed identity alone.
50//! The trace commitment and the public values are therefore absorbed before `alpha` is drawn.
51//!
52//! `zeta` is the single point that identity is tested at.
53//!
54//! ```text
55//!     absorb Q's commitment   ->   grind   ->   draw zeta
56//! ```
57//!
58//! A prover who learns `zeta` first picks a `Q` that matches at that one point.
59//! The grinding step in between prices each retry at `2^ood_pow_bits`.
60
61use alloc::vec::Vec;
62use core::marker::PhantomData;
63
64use p3_air::BaseAir;
65use p3_challenger::fs::{
66    DomainSeparator, FieldToFieldCodec, FieldUnit, Hierarchy, Interaction, InteractionPattern,
67    Kind, Label, Length, ProverState, VerifierState,
68};
69use p3_challenger::{CanObserve, CanSample, CanSampleBits, GrindingChallenger};
70use p3_field::{ExtensionField, PrimeField64};
71use thiserror::Error;
72
73/// Version byte bound into the transcript seed.
74const VERSION: u8 = 1;
75
76/// Protocol name bound into the transcript seed.
77const NAME: &[u8] = b"p3-uni-stark";
78
79/// Step label of the main trace commitment.
80const TRACE_COMMITMENT: &str = "trace_commitment";
81
82/// Step label of the preprocessed trace commitment.
83const PREPROCESSED_COMMITMENT: &str = "preprocessed_commitment";
84
85/// Step label of the public values.
86const PUBLIC_VALUES: &str = "public_values";
87
88/// Step label of the constraint-batching challenge.
89const ALPHA: &str = "alpha";
90
91/// Step label of the quotient-chunk commitment.
92const QUOTIENT_COMMITMENT: &str = "quotient_commitment";
93
94/// Step label of the zero-knowledge randomization commitment.
95const RANDOM_COMMITMENT: &str = "random_commitment";
96
97/// Step label of the grinding step guarding the out-of-domain point.
98const OOD_POW: &str = "ood_pow";
99
100/// Step label of the out-of-domain point.
101const ZETA: &str = "zeta";
102
103/// Step label of the bracket around the delegated opening argument.
104const OPENING_ARGUMENT: &str = "opening_argument";
105
106/// Sponge alphabet of a challenger that speaks the base field natively.
107type Alphabet<F> = FieldUnit<F>;
108
109/// Type-level name of the sub-protocol the STARK delegates its openings to.
110///
111/// Recorded on the bracket markers as a local diagnostic.
112/// It does not reach the pattern fingerprint.
113struct OpeningArgument;
114
115/// Numbers that fix the transcript of one uni-STARK run.
116///
117/// Both sides build this from their own configuration and their own AIR.
118///
119/// The prover's degree comes from the trace it holds.
120/// The verifier's comes from a proof field it has already validated against the PCS bounds.
121#[derive(Clone, Debug, PartialEq, Eq)]
122pub struct StarkShape {
123    /// Log-height of the committed trace, including any zero-knowledge extension.
124    pub log_ext_degree: usize,
125    /// Log-height of the trace before any zero-knowledge extension.
126    pub log_degree: usize,
127    /// Column count of the main trace.
128    pub main_width: usize,
129    /// Column count of the preprocessed trace, zero when the AIR declares none.
130    pub preprocessed_width: usize,
131    /// Number of public values the AIR reads.
132    pub num_public_values: usize,
133    /// Number of periodic columns the AIR declares.
134    pub num_periodic_columns: usize,
135    /// Number of chunks the quotient polynomial is split into.
136    pub num_quotient_chunks: usize,
137    /// Whether the AIR's constraints read the next row of the main trace.
138    pub opens_main_next_row: bool,
139    /// Whether the AIR's constraints read the next row of the preprocessed trace.
140    pub opens_preprocessed_next_row: bool,
141    /// Whether the PCS commits to a randomization polynomial.
142    pub has_randomization: bool,
143    /// Grinding difficulty guarding the out-of-domain point.
144    pub ood_pow_bits: usize,
145}
146
147impl StarkShape {
148    /// Derive the shape of one run from its AIR and its configuration.
149    ///
150    /// Five of the eleven numbers are read straight off the AIR.
151    ///
152    /// ```text
153    ///     main_width                   air.width()
154    ///     num_public_values            air.num_public_values()
155    ///     num_periodic_columns         air.num_periodic_columns()
156    ///     opens_main_next_row          air.main_next_row_columns()
157    ///     opens_preprocessed_next_row  air.preprocessed_next_row_columns()
158    /// ```
159    ///
160    /// # Arguments
161    ///
162    /// - `air`: the AIR being proven.
163    /// - `preprocessed_width`: the preprocessed column count in force, which a verifier key may pin.
164    /// - `log_ext_degree`: log-height of the committed trace.
165    /// - `log_degree`: log-height of the trace before any zero-knowledge extension.
166    /// - `num_quotient_chunks`: number of chunks the quotient polynomial is split into.
167    /// - `has_randomization`: whether the PCS commits to a randomization polynomial.
168    /// - `ood_pow_bits`: grinding difficulty guarding the out-of-domain point.
169    #[must_use]
170    pub fn new<F, A>(
171        air: &A,
172        preprocessed_width: usize,
173        log_ext_degree: usize,
174        log_degree: usize,
175        num_quotient_chunks: usize,
176        has_randomization: bool,
177        ood_pow_bits: usize,
178    ) -> Self
179    where
180        A: BaseAir<F> + ?Sized,
181    {
182        Self {
183            log_ext_degree,
184            log_degree,
185            main_width: air.width(),
186            preprocessed_width,
187            num_public_values: air.num_public_values(),
188            num_periodic_columns: air.num_periodic_columns(),
189            num_quotient_chunks,
190            opens_main_next_row: !air.main_next_row_columns().is_empty(),
191            opens_preprocessed_next_row: !air.preprocessed_next_row_columns().is_empty(),
192            has_randomization,
193            ood_pow_bits,
194        }
195    }
196
197    /// Describe the transcript this shape fixes.
198    ///
199    /// # Panics
200    ///
201    /// Never in practice.
202    /// The only nesting is one matched bracket, which always passes structural validation.
203    #[must_use]
204    pub fn pattern<F, EF>(&self) -> InteractionPattern
205    where
206        F: PrimeField64,
207        EF: ExtensionField<F>,
208    {
209        // Six mandatory steps, three conditional ones, and the bracket.
210        let mut steps = Vec::with_capacity(9);
211
212        // The commitment's encoding belongs to the commitment scheme.
213        steps.push(Interaction::opaque(
214            Hierarchy::Atomic,
215            Kind::Message,
216            TRACE_COMMITMENT,
217            Length::Scalar,
218        ));
219
220        // An AIR with no preprocessed columns commits to none, so the step is absent.
221        if self.preprocessed_width > 0 {
222            steps.push(Interaction::opaque(
223                Hierarchy::Atomic,
224                Kind::Message,
225                PREPROCESSED_COMMITMENT,
226                Length::Scalar,
227            ));
228        }
229
230        // Public values are the verifier's own input, so they never travel on the wire.
231        //
232        // The count is the AIR's, which makes it part of the shape rather than of the data.
233        steps.push(Interaction::algebra::<F, F>(
234            Hierarchy::Atomic,
235            Kind::Public,
236            PUBLIC_VALUES,
237            Length::Fixed(self.num_public_values),
238        ));
239
240        // One challenge collapses every constraint into a single identity.
241        steps.push(Interaction::algebra::<F, EF>(
242            Hierarchy::Atomic,
243            Kind::Challenge,
244            ALPHA,
245            Length::Scalar,
246        ));
247
248        // The quotient answers the identity `alpha` just fixed, so it is bound after it.
249        steps.push(Interaction::opaque(
250            Hierarchy::Atomic,
251            Kind::Message,
252            QUOTIENT_COMMITMENT,
253            Length::Scalar,
254        ));
255
256        // Only a zero-knowledge PCS commits to a randomization polynomial.
257        if self.has_randomization {
258            steps.push(Interaction::opaque(
259                Hierarchy::Atomic,
260                Kind::Message,
261                RANDOM_COMMITMENT,
262                Length::Scalar,
263            ));
264        }
265
266        // Grinding sits between the last commitment and the point it is tested at.
267        if self.ood_pow_bits > 0 {
268            steps.push(Interaction::algebra::<F, F>(
269                Hierarchy::Atomic,
270                Kind::Pow,
271                OOD_POW,
272                Length::Fixed(self.ood_pow_bits),
273            ));
274        }
275
276        // The out-of-domain point tests the identity away from the trace domain.
277        steps.push(Interaction::algebra::<F, EF>(
278            Hierarchy::Atomic,
279            Kind::Challenge,
280            ZETA,
281            Length::Scalar,
282        ));
283
284        // The bracket records that a sub-protocol runs here.
285        //
286        // Its steps live in the callee's own pattern, under the callee's own seed.
287        // What this pattern states is that the delegation happens, and where.
288        steps.push(Interaction::marker::<OpeningArgument>(
289            Hierarchy::Begin,
290            Kind::Protocol,
291            OPENING_ARGUMENT,
292        ));
293        steps.push(Interaction::marker::<OpeningArgument>(
294            Hierarchy::End,
295            Kind::Protocol,
296            OPENING_ARGUMENT,
297        ));
298
299        InteractionPattern::new(steps).expect("one matched bracket is always well formed")
300    }
301
302    /// Bind the protocol identity, this shape, and the rest of the AIR's shape.
303    ///
304    /// A number that changes the step sequence is covered by the fingerprint.
305    /// The rest go in the instance label.
306    #[must_use]
307    pub fn domain_separator<F, EF>(&self) -> DomainSeparator<Alphabet<F>>
308    where
309        F: PrimeField64,
310        EF: ExtensionField<F>,
311    {
312        let mut separator = DomainSeparator::new(VERSION, NAME, self.pattern::<F, EF>());
313
314        // The step sequence sees a preprocessed commitment as present or absent.
315        //
316        //     width 0   ->  no step
317        //     width 3   ->  one step
318        //     width 7   ->  one step
319        //
320        // Two nonzero widths therefore share a sequence, so the value is bound here.
321        //
322        // The remaining numbers never touch the sequence at all.
323        // They are what makes one instance of this AIR's shape distinct from another.
324        for value in [
325            self.log_ext_degree,
326            self.log_degree,
327            self.main_width,
328            self.preprocessed_width,
329            self.num_periodic_columns,
330            self.num_quotient_chunks,
331        ] {
332            separator.instance(&(value as u64).to_be_bytes());
333        }
334
335        // Which rows the AIR reads decides how many points each trace is opened at.
336        for flag in [self.opens_main_next_row, self.opens_preprocessed_next_row] {
337            separator.instance(&u64::from(flag).to_be_bytes());
338        }
339
340        separator
341    }
342}
343
344/// Prover-side transcript of one uni-STARK run.
345///
346/// Holds the only definition of what a prover writes outside the opening argument.
347///
348/// The challenger is borrowed, not consumed.
349/// The caller keeps whatever it was doing with the sponge before and after.
350pub struct StarkProverTranscript<'a, C, F: PrimeField64, EF> {
351    /// Driver walking the description and holding the borrowed sponge.
352    state: ProverState<&'a mut C, Alphabet<F>>,
353    /// The numbers this run was described with.
354    shape: StarkShape,
355    /// Marker for the extension field the challenges live in.
356    _ef: PhantomData<EF>,
357}
358
359impl<'a, C, F, EF> StarkProverTranscript<'a, C, F, EF>
360where
361    F: PrimeField64,
362    EF: ExtensionField<F>,
363    C: CanObserve<F> + CanSample<F> + CanSampleBits<usize> + GrindingChallenger<Witness = F>,
364{
365    /// Seed the transcript from the shape.
366    pub fn new(challenger: &'a mut C, shape: StarkShape) -> Self {
367        let separator = shape.domain_separator::<F, EF>();
368        Self {
369            state: ProverState::new(challenger, &separator),
370            shape,
371            _ef: PhantomData,
372        }
373    }
374
375    /// Bind the committed traces and the public values, then draw `alpha`.
376    ///
377    /// # Arguments
378    ///
379    /// - `trace_commitment`: commitment to the main trace.
380    /// - `preprocessed_commitment`: commitment to the preprocessed trace, when the AIR has one.
381    /// - `public_values`: the public values, exactly as many as the AIR reads.
382    ///
383    /// # Returns
384    ///
385    /// The challenge that batches every constraint into one identity.
386    ///
387    /// # Panics
388    ///
389    /// When the arguments do not match the shape this run was described with.
390    pub fn constraint_phase<Com>(
391        &mut self,
392        trace_commitment: Com,
393        preprocessed_commitment: Option<Com>,
394        public_values: &[F],
395    ) -> EF
396    where
397        Com: Clone,
398        C: CanObserve<Com>,
399    {
400        self.state
401            .observe_opaque(TRACE_COMMITMENT, trace_commitment);
402
403        if let Some(commitment) = preprocessed_commitment {
404            self.state
405                .observe_opaque(PREPROCESSED_COMMITMENT, commitment);
406        }
407
408        self.state
409            .add_public_scalars::<F, FieldToFieldCodec<F>>(PUBLIC_VALUES, public_values);
410
411        self.state
412            .challenge_extension::<F, EF, FieldToFieldCodec<F>>(ALPHA)
413            .into_inner()
414    }
415
416    /// Bind the quotient commitment, grind, and draw `zeta`.
417    ///
418    /// # Arguments
419    ///
420    /// - `quotient_commitment`: commitment to the quotient chunks.
421    /// - `random_commitment`: commitment to the randomization polynomial, under a hiding PCS.
422    ///
423    /// # Returns
424    ///
425    /// - The out-of-domain point.
426    /// - The grinding witness, when the difficulty is positive.
427    ///
428    /// # Panics
429    ///
430    /// When the arguments do not match the shape this run was described with.
431    pub fn ood_phase<Com>(
432        &mut self,
433        quotient_commitment: Com,
434        random_commitment: Option<Com>,
435    ) -> (EF, Option<F>)
436    where
437        Com: Clone,
438        C: CanObserve<Com>,
439    {
440        self.state
441            .observe_opaque(QUOTIENT_COMMITMENT, quotient_commitment);
442
443        if let Some(commitment) = random_commitment {
444            self.state.observe_opaque(RANDOM_COMMITMENT, commitment);
445        }
446
447        let witness = (self.shape.ood_pow_bits > 0)
448            .then(|| self.state.observe_pow(OOD_POW, self.shape.ood_pow_bits));
449
450        let zeta = self
451            .state
452            .challenge_extension::<F, EF, FieldToFieldCodec<F>>(ZETA)
453            .into_inner();
454
455        (zeta, witness)
456    }
457
458    /// Lend the sponge to the opening argument, bracketed as a sub-protocol.
459    ///
460    /// The callee seeds its own driver from the state this one has reached.
461    ///
462    /// # Returns
463    ///
464    /// Whatever the delegated run produced.
465    pub fn delegate<R>(&mut self, run: impl FnOnce(&mut C) -> R) -> R {
466        self.state
467            .begin_protocol::<OpeningArgument>(OPENING_ARGUMENT);
468        let output = run(self.state.challenger_mut());
469        self.state.end_protocol::<OpeningArgument>(OPENING_ARGUMENT);
470        output
471    }
472
473    /// Abandon proof generation after a recoverable error, releasing the drop check.
474    pub fn abort(&mut self) {
475        self.state.abort();
476    }
477
478    /// Close the transcript once every described step has been played.
479    ///
480    /// # Panics
481    ///
482    /// When the run played fewer steps than it was described with.
483    pub fn finish(self) {
484        assert!(
485            self.state.finalize().is_empty(),
486            "a uni-STARK carries every value in its own proof",
487        );
488    }
489}
490
491/// Verifier-side transcript of one uni-STARK run.
492///
493/// Mirrors the prover side call for call, over the same description.
494pub struct StarkVerifierTranscript<'a, C, F: PrimeField64, EF> {
495    /// Driver walking the description and holding the borrowed sponge.
496    ///
497    /// The proof carries every value, so the driver reads an empty wire.
498    state: VerifierState<'static, &'a mut C, Alphabet<F>>,
499    /// The numbers this run was described with.
500    shape: StarkShape,
501    /// Marker for the extension field the challenges live in.
502    _ef: PhantomData<EF>,
503}
504
505impl<'a, C, F, EF> StarkVerifierTranscript<'a, C, F, EF>
506where
507    F: PrimeField64,
508    EF: ExtensionField<F>,
509    C: CanObserve<F> + CanSample<F> + CanSampleBits<usize> + GrindingChallenger<Witness = F>,
510{
511    /// Seed the transcript from the shape.
512    pub fn new(challenger: &'a mut C, shape: StarkShape) -> Self {
513        let separator = shape.domain_separator::<F, EF>();
514        Self {
515            state: VerifierState::new(challenger, &separator, &[]),
516            shape,
517            _ef: PhantomData,
518        }
519    }
520
521    /// Replay a commitment step the shape may or may not have described.
522    ///
523    /// # Errors
524    ///
525    /// - `missing`: the step is described and no commitment arrived to replay it.
526    /// - `unexpected`: a commitment arrived for a step the run never described.
527    fn optional_commitment<Com>(
528        &mut self,
529        described: bool,
530        label: Label,
531        commitment: Option<Com>,
532        missing: StarkTranscriptFailure,
533        unexpected: StarkTranscriptFailure,
534    ) -> Result<(), StarkTranscriptFailure>
535    where
536        Com: Clone,
537        C: CanObserve<Com>,
538    {
539        match (described, commitment) {
540            (true, Some(commitment)) => {
541                self.state.observe_opaque(label, commitment);
542                Ok(())
543            }
544            (false, None) => Ok(()),
545            // Either way the run cannot continue, so the completeness check is released first.
546            (true, None) => {
547                self.state.abort();
548                Err(missing)
549            }
550            (false, Some(_)) => {
551                self.state.abort();
552                Err(unexpected)
553            }
554        }
555    }
556
557    /// Replay the committed traces and the public values, then redraw `alpha`.
558    ///
559    /// # Arguments
560    ///
561    /// - `trace_commitment`: commitment to the main trace, from the proof.
562    /// - `preprocessed_commitment`: commitment to the preprocessed trace, from the verifier key.
563    /// - `public_values`: the public values, which are this verifier's own input.
564    ///
565    /// # Errors
566    ///
567    /// - The run describes a preprocessed commitment and none arrived.
568    /// - A preprocessed commitment arrived for a run described without one.
569    ///
570    /// # Panics
571    ///
572    /// When the public values are not the count the AIR declares.
573    /// Both sides read that count off the same AIR, so a mismatch is a caller bug.
574    pub fn constraint_phase<Com>(
575        &mut self,
576        trace_commitment: Com,
577        preprocessed_commitment: Option<Com>,
578        public_values: &[F],
579    ) -> Result<EF, StarkTranscriptFailure>
580    where
581        Com: Clone,
582        C: CanObserve<Com>,
583    {
584        self.state
585            .observe_opaque(TRACE_COMMITMENT, trace_commitment);
586
587        self.optional_commitment(
588            self.shape.preprocessed_width > 0,
589            PREPROCESSED_COMMITMENT,
590            preprocessed_commitment,
591            StarkTranscriptFailure::MissingPreprocessedCommitment {
592                width: self.shape.preprocessed_width,
593            },
594            StarkTranscriptFailure::UnexpectedPreprocessedCommitment,
595        )?;
596
597        self.state
598            .observe_public_scalars::<F, FieldToFieldCodec<F>>(PUBLIC_VALUES, public_values);
599
600        Ok(self
601            .state
602            .challenge_extension::<F, EF, FieldToFieldCodec<F>>(ALPHA)
603            .into_inner())
604    }
605
606    /// Replay the quotient commitment and the grind, then redraw `zeta`.
607    ///
608    /// # Arguments
609    ///
610    /// - `quotient_commitment`: commitment to the quotient chunks, from the proof.
611    /// - `random_commitment`: commitment to the randomization polynomial, from the proof.
612    /// - `witness`: the grinding witness the proof carries.
613    ///
614    /// # Errors
615    ///
616    /// - The run describes a randomization commitment and none arrived.
617    /// - A randomization commitment arrived for a run described without one.
618    /// - The witness misses the difficulty its step requires.
619    pub fn ood_phase<Com>(
620        &mut self,
621        quotient_commitment: Com,
622        random_commitment: Option<Com>,
623        witness: F,
624    ) -> Result<EF, StarkTranscriptFailure>
625    where
626        Com: Clone,
627        C: CanObserve<Com>,
628    {
629        self.state
630            .observe_opaque(QUOTIENT_COMMITMENT, quotient_commitment);
631
632        self.optional_commitment(
633            self.shape.has_randomization,
634            RANDOM_COMMITMENT,
635            random_commitment,
636            StarkTranscriptFailure::MissingRandomCommitment,
637            StarkTranscriptFailure::UnexpectedRandomCommitment,
638        )?;
639
640        if self.shape.ood_pow_bits > 0 {
641            // A failed check poisons the driver on its way out, so this rejection travels alone.
642            self.state
643                .observe_pow(OOD_POW, self.shape.ood_pow_bits, witness)
644                .map_err(|_| StarkTranscriptFailure::OodPowWitness {
645                    bits: self.shape.ood_pow_bits,
646                })?;
647        }
648
649        Ok(self
650            .state
651            .challenge_extension::<F, EF, FieldToFieldCodec<F>>(ZETA)
652            .into_inner())
653    }
654
655    /// Lend the sponge to the opening argument, bracketed as a sub-protocol.
656    ///
657    /// The bracket closes whatever the delegated run returned.
658    /// A rejection therefore leaves this transcript replayable to the end.
659    ///
660    /// # Returns
661    ///
662    /// Whatever the delegated run produced.
663    pub fn delegate<R>(&mut self, run: impl FnOnce(&mut C) -> R) -> R {
664        self.state
665            .begin_protocol::<OpeningArgument>(OPENING_ARGUMENT);
666        let output = run(self.state.challenger_mut());
667        self.state.end_protocol::<OpeningArgument>(OPENING_ARGUMENT);
668        output
669    }
670
671    /// Close the transcript once every described step has been replayed.
672    ///
673    /// # Panics
674    ///
675    /// When the run replayed fewer steps than it was described with.
676    pub fn finish(self) {
677        self.state
678            .finalize()
679            .expect("a uni-STARK reads an empty wire, so no bytes can remain");
680    }
681}
682
683/// A transcript step the proof failed to satisfy.
684#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
685#[non_exhaustive]
686pub enum StarkTranscriptFailure {
687    /// The described preprocessed-commitment step arrived with no commitment.
688    #[error(
689        "preprocessed commitment step described for a width of {width} arrived with no commitment"
690    )]
691    MissingPreprocessedCommitment {
692        /// Preprocessed column count the run was described with.
693        width: usize,
694    },
695    /// A preprocessed commitment arrived for a run described without one.
696    #[error("preprocessed commitment arrived for a run described with a width of 0")]
697    UnexpectedPreprocessedCommitment,
698    /// The described randomization-commitment step arrived with no commitment.
699    #[error("randomization commitment step described by a zero-knowledge PCS arrived with none")]
700    MissingRandomCommitment,
701    /// A randomization commitment arrived for a run described without one.
702    #[error("randomization commitment arrived for a run described without one")]
703    UnexpectedRandomCommitment,
704    /// The grinding witness did not meet the difficulty its step requires.
705    #[error("out-of-domain PoW witness does not meet the required {bits} bits")]
706    OodPowWitness {
707        /// Grinding difficulty the step requires.
708        bits: usize,
709    },
710}
711
712#[cfg(test)]
713mod tests {
714    use alloc::vec;
715    use core::str::from_utf8;
716
717    use p3_baby_bear::{BabyBear, Poseidon2BabyBear};
718    use p3_challenger::testing::{
719        SeedDigest, assert_seeds_pairwise_distinct, pow_difficulties, seed_digest,
720    };
721    use p3_challenger::{CanSample, DuplexChallenger};
722    use p3_field::PrimeCharacteristicRing;
723    use p3_field::extension::BinomialExtensionField;
724    use p3_security::grinding::{
725        GRINDING_VOCABULARY, GrindingBudget, GrindingSite, GrindingSites, RecordedGrind,
726        ZeroBitConvention, grinding_step,
727    };
728    use rand::SeedableRng;
729    use rand::rngs::SmallRng;
730
731    use super::*;
732
733    type F = BabyBear;
734    type EF = BinomialExtensionField<F, 4>;
735    type Perm = Poseidon2BabyBear<16>;
736    type Ch = DuplexChallenger<F, Perm, 16, 8>;
737
738    /// An AIR that declares a shape and nothing else.
739    ///
740    /// Every field is one of the numbers the shape constructor reads off an AIR.
741    struct ShapeAir {
742        /// Column count of the main trace.
743        width: usize,
744        /// Column count of the preprocessed trace.
745        preprocessed_width: usize,
746        /// Number of public values the AIR reads.
747        num_public_values: usize,
748        /// Number of periodic columns the AIR declares.
749        num_periodic_columns: usize,
750    }
751
752    impl BaseAir<F> for ShapeAir {
753        fn width(&self) -> usize {
754            self.width
755        }
756
757        fn preprocessed_width(&self) -> usize {
758            self.preprocessed_width
759        }
760
761        fn num_public_values(&self) -> usize {
762            self.num_public_values
763        }
764
765        fn num_periodic_columns(&self) -> usize {
766            self.num_periodic_columns
767        }
768    }
769
770    fn fresh_challenger() -> Ch {
771        // Fixed seed so two runs differ only where the transcript makes them differ.
772        let mut rng = SmallRng::seed_from_u64(0xF21);
773        Ch::new(Perm::new_from_rng_128(&mut rng))
774    }
775
776    /// A shape with no preprocessed trace, no randomization and no grinding.
777    fn plain_shape() -> StarkShape {
778        StarkShape {
779            log_ext_degree: 5,
780            log_degree: 5,
781            main_width: 2,
782            preprocessed_width: 0,
783            num_public_values: 1,
784            num_periodic_columns: 0,
785            num_quotient_chunks: 2,
786            opens_main_next_row: true,
787            opens_preprocessed_next_row: false,
788            has_randomization: false,
789            ood_pow_bits: 0,
790        }
791    }
792
793    /// The digest of the byte stream a shape seeds its sponge with.
794    ///
795    /// Comparing seed streams, rather than a sampled challenge, keeps the sponge out of it.
796    fn seed_of(shape: &StarkShape) -> SeedDigest {
797        seed_digest(&shape.domain_separator::<F, EF>())
798    }
799
800    /// Every field of the shape, each bumped by one step away from `plain_shape`.
801    ///
802    /// One entry per configuration knob, so a knob added without a binding shows up here.
803    fn one_step_from_plain() -> Vec<(&'static str, StarkShape)> {
804        let mut mutations = Vec::new();
805
806        let mut shape = plain_shape();
807        shape.log_ext_degree += 1;
808        mutations.push(("log_ext_degree", shape));
809
810        let mut shape = plain_shape();
811        shape.log_degree += 1;
812        mutations.push(("log_degree", shape));
813
814        let mut shape = plain_shape();
815        shape.main_width += 1;
816        mutations.push(("main_width", shape));
817
818        let mut shape = plain_shape();
819        shape.preprocessed_width += 1;
820        mutations.push(("preprocessed_width", shape));
821
822        let mut shape = plain_shape();
823        shape.num_public_values += 1;
824        mutations.push(("num_public_values", shape));
825
826        let mut shape = plain_shape();
827        shape.num_periodic_columns += 1;
828        mutations.push(("num_periodic_columns", shape));
829
830        let mut shape = plain_shape();
831        shape.num_quotient_chunks += 1;
832        mutations.push(("num_quotient_chunks", shape));
833
834        let mut shape = plain_shape();
835        shape.opens_main_next_row = false;
836        mutations.push(("opens_main_next_row", shape));
837
838        let mut shape = plain_shape();
839        shape.opens_preprocessed_next_row = true;
840        mutations.push(("opens_preprocessed_next_row", shape));
841
842        let mut shape = plain_shape();
843        shape.has_randomization = true;
844        mutations.push(("has_randomization", shape));
845
846        let mut shape = plain_shape();
847        shape.ood_pow_bits += 1;
848        mutations.push(("ood_pow_bits", shape));
849
850        mutations
851    }
852
853    #[test]
854    fn no_two_configurations_of_the_shape_share_a_seed() {
855        // Invariant: the knobs are separated from each other, not merely from a baseline.
856        //
857        //     plain shape in the set  ->  every knob has to reach the seed
858        //     pairwise over the set   ->  no two knobs may land on one seed
859        //
860        // Two knobs bound as one number differ from the baseline and still agree with each other.
861        let mut seeds = vec![("plain", seed_of(&plain_shape()))];
862        seeds.extend(
863            one_step_from_plain()
864                .iter()
865                .map(|(field, shape)| (*field, seed_of(shape))),
866        );
867
868        assert_seeds_pairwise_distinct(&seeds);
869    }
870
871    #[test]
872    fn two_airs_differing_only_in_shape_seed_differently() {
873        // The AIR's own shape reaches the seed, so two AIRs cannot share one.
874        //
875        // Two AIRs proven under one configuration, at one trace height, at one chunk count.
876        // Everything the run is configured with therefore agrees between them.
877        // Only the AIR's own shape differs, one field at a time.
878        let base = ShapeAir {
879            width: 4,
880            preprocessed_width: 0,
881            num_public_values: 2,
882            num_periodic_columns: 0,
883        };
884
885        let shape_of = |air: &ShapeAir| StarkShape::new::<F, _>(air, 0, 6, 6, 2, false, 0);
886        let baseline = seed_of(&shape_of(&base));
887
888        // A wider trace.
889        let wider = ShapeAir { width: 5, ..base };
890        assert_ne!(baseline, seed_of(&shape_of(&wider)));
891
892        // One more public value.
893        let more_public = ShapeAir {
894            num_public_values: 3,
895            ..base
896        };
897        assert_ne!(baseline, seed_of(&shape_of(&more_public)));
898
899        // One periodic column instead of none.
900        let periodic = ShapeAir {
901            num_periodic_columns: 1,
902            ..base
903        };
904        assert_ne!(baseline, seed_of(&shape_of(&periodic)));
905    }
906
907    #[test]
908    fn the_shape_constructor_reads_the_air_it_is_given() {
909        // Fixture state: an AIR whose five self-reported numbers are all distinct.
910        let air = ShapeAir {
911            width: 7,
912            preprocessed_width: 3,
913            num_public_values: 5,
914            num_periodic_columns: 2,
915        };
916
917        // The other six numbers come from the caller's configuration.
918        let shape = StarkShape::new::<F, _>(&air, 3, 9, 8, 4, true, 6);
919
920        assert_eq!(
921            shape,
922            StarkShape {
923                log_ext_degree: 9,
924                log_degree: 8,
925                main_width: 7,
926                preprocessed_width: 3,
927                num_public_values: 5,
928                num_periodic_columns: 2,
929                num_quotient_chunks: 4,
930                // Both default to every column of their trace, so both are non-empty here.
931                opens_main_next_row: true,
932                opens_preprocessed_next_row: true,
933                has_randomization: true,
934                ood_pow_bits: 6,
935            }
936        );
937    }
938
939    #[test]
940    fn a_run_with_no_grinding_differs_from_one_with_a_single_bit() {
941        // At zero bits the step is absent, which is a shorter sequence, not a cheaper one.
942        let ungrounded = plain_shape();
943        let mut ground = ungrounded.clone();
944        ground.ood_pow_bits = 1;
945
946        assert_ne!(seed_of(&ungrounded), seed_of(&ground));
947    }
948
949    #[test]
950    fn the_two_sides_agree_on_every_challenge() {
951        // Fixture state: a run with a preprocessed trace, randomization, and grinding.
952        let mut shape = plain_shape();
953        shape.preprocessed_width = 3;
954        shape.has_randomization = true;
955        shape.ood_pow_bits = 1;
956
957        // The public values are the verifier's own input, so both sides absorb the same slice.
958        let public_values = [F::from_u8(9)];
959
960        // Prover: play every step, keeping the witness the grind produced.
961        let mut prover_challenger = fresh_challenger();
962        let mut prover =
963            StarkProverTranscript::<Ch, F, EF>::new(&mut prover_challenger, shape.clone());
964        let prover_alpha = prover.constraint_phase([F::ONE; 8], Some([F::TWO; 8]), &public_values);
965        let (prover_zeta, witness) = prover.ood_phase([F::from_u8(3); 8], Some([F::from_u8(4); 8]));
966        prover.delegate(|_| ());
967        prover.finish();
968
969        // Verifier: replay the same steps against the same values.
970        let mut verifier_challenger = fresh_challenger();
971        let mut verifier =
972            StarkVerifierTranscript::<Ch, F, EF>::new(&mut verifier_challenger, shape);
973        let verifier_alpha = verifier
974            .constraint_phase([F::ONE; 8], Some([F::TWO; 8]), &public_values)
975            .expect("the described preprocessed commitment is supplied");
976        let verifier_zeta = verifier
977            .ood_phase(
978                [F::from_u8(3); 8],
979                Some([F::from_u8(4); 8]),
980                witness.expect("a positive difficulty grinds a witness"),
981            )
982            .expect("the witness is the one the prover ground");
983        verifier.delegate(|_| ());
984        verifier.finish();
985
986        assert_eq!(prover_alpha, verifier_alpha);
987        assert_eq!(prover_zeta, verifier_zeta);
988
989        // Both sponges advanced identically, so they still agree on what comes next.
990        let prover_next: F = prover_challenger.sample();
991        let verifier_next: F = verifier_challenger.sample();
992        assert_eq!(prover_next, verifier_next);
993    }
994
995    #[test]
996    fn a_described_preprocessed_commitment_that_never_arrives_is_rejected() {
997        // Described run: an AIR with three preprocessed columns, so the step exists.
998        let mut shape = plain_shape();
999        shape.preprocessed_width = 3;
1000
1001        let mut challenger = fresh_challenger();
1002        let mut transcript = StarkVerifierTranscript::<Ch, F, EF>::new(&mut challenger, shape);
1003
1004        // A described commitment step cannot be replayed with nothing to feed it.
1005        let err = transcript
1006            .constraint_phase([F::ONE; 8], None, &[F::ONE])
1007            .expect_err("a described commitment step with no commitment must error");
1008
1009        assert_eq!(
1010            err,
1011            StarkTranscriptFailure::MissingPreprocessedCommitment { width: 3 }
1012        );
1013    }
1014
1015    #[test]
1016    fn a_preprocessed_commitment_nobody_described_is_rejected() {
1017        // Described run: an AIR with no preprocessed columns, so there is no step to replay.
1018        let mut challenger = fresh_challenger();
1019        let mut transcript =
1020            StarkVerifierTranscript::<Ch, F, EF>::new(&mut challenger, plain_shape());
1021
1022        let err = transcript
1023            .constraint_phase([F::ONE; 8], Some([F::TWO; 8]), &[F::ONE])
1024            .expect_err("a commitment outside the described sequence must error");
1025
1026        assert_eq!(
1027            err,
1028            StarkTranscriptFailure::UnexpectedPreprocessedCommitment
1029        );
1030    }
1031
1032    #[test]
1033    fn a_missing_randomization_commitment_is_rejected() {
1034        // Described run: a zero-knowledge PCS, so the randomization step exists.
1035        let mut shape = plain_shape();
1036        shape.has_randomization = true;
1037
1038        let mut challenger = fresh_challenger();
1039        let mut transcript = StarkVerifierTranscript::<Ch, F, EF>::new(&mut challenger, shape);
1040        let _alpha = transcript
1041            .constraint_phase([F::ONE; 8], None, &[F::ONE])
1042            .expect("the constraint phase is unaffected by the randomization step");
1043
1044        let err = transcript
1045            .ood_phase([F::TWO; 8], None, F::ZERO)
1046            .expect_err("a described commitment step with no commitment must error");
1047
1048        assert_eq!(err, StarkTranscriptFailure::MissingRandomCommitment);
1049    }
1050
1051    #[test]
1052    fn a_forged_grinding_witness_is_rejected() {
1053        // Described run: sixteen bits of grinding, which no fixed witness meets by luck.
1054        let mut shape = plain_shape();
1055        shape.ood_pow_bits = 16;
1056
1057        let mut challenger = fresh_challenger();
1058        let mut transcript = StarkVerifierTranscript::<Ch, F, EF>::new(&mut challenger, shape);
1059        let _alpha = transcript
1060            .constraint_phase([F::ONE; 8], None, &[F::ONE])
1061            .expect("the constraint phase runs before any grinding step");
1062
1063        let err = transcript
1064            .ood_phase([F::TWO; 8], None, F::ZERO)
1065            .expect_err("a witness below the difficulty must error");
1066
1067        assert_eq!(err, StarkTranscriptFailure::OodPowWitness { bits: 16 });
1068    }
1069
1070    #[test]
1071    fn the_delegation_bracket_leaves_the_sponge_untouched() {
1072        // Invariant: the markers are structural.
1073        // They record the delegation, they absorb nothing.
1074        //
1075        // Fixture state: two runs over the same shape, one bracketing an empty delegation.
1076        let shape = plain_shape();
1077        let public_values = [F::from_u8(11)];
1078
1079        let mut bracketed_challenger = fresh_challenger();
1080        let mut bracketed =
1081            StarkVerifierTranscript::<Ch, F, EF>::new(&mut bracketed_challenger, shape.clone());
1082        let _alpha = bracketed
1083            .constraint_phase([F::ONE; 8], None, &public_values)
1084            .unwrap();
1085        let bracketed_zeta = bracketed.ood_phase([F::TWO; 8], None, F::ZERO).unwrap();
1086        bracketed.delegate(|_| ());
1087        bracketed.finish();
1088
1089        // The prover side plays the same steps and must land on the same point.
1090        let mut plain_challenger = fresh_challenger();
1091        let mut plain = StarkProverTranscript::<Ch, F, EF>::new(&mut plain_challenger, shape);
1092        let _alpha = plain.constraint_phase([F::ONE; 8], None, &public_values);
1093        let (plain_zeta, witness) = plain.ood_phase([F::TWO; 8], None);
1094        plain.delegate(|_| ());
1095        plain.finish();
1096
1097        assert_eq!(bracketed_zeta, plain_zeta);
1098        assert_eq!(witness, None);
1099
1100        let bracketed_next: F = bracketed_challenger.sample();
1101        let plain_next: F = plain_challenger.sample();
1102        assert_eq!(bracketed_next, plain_next);
1103    }
1104
1105    #[test]
1106    fn a_public_value_the_verifier_holds_reaches_the_challenges() {
1107        // Public values never travel on the wire, so only the absorb binds them.
1108        //
1109        //     verifier holds [7]  ->  one alpha
1110        //     verifier holds [8]  ->  another
1111        let mut with_seven = fresh_challenger();
1112        let mut seven = StarkVerifierTranscript::<Ch, F, EF>::new(&mut with_seven, plain_shape());
1113        let seven_alpha = seven
1114            .constraint_phase([F::ONE; 8], None, &[F::from_u8(7)])
1115            .unwrap();
1116        let _zeta = seven.ood_phase([F::TWO; 8], None, F::ZERO).unwrap();
1117        seven.delegate(|_| ());
1118        seven.finish();
1119
1120        let mut with_eight = fresh_challenger();
1121        let mut eight = StarkVerifierTranscript::<Ch, F, EF>::new(&mut with_eight, plain_shape());
1122        let eight_alpha = eight
1123            .constraint_phase([F::ONE; 8], None, &[F::from_u8(8)])
1124            .unwrap();
1125        let _zeta = eight.ood_phase([F::TWO; 8], None, F::ZERO).unwrap();
1126        eight.delegate(|_| ());
1127        eight.finish();
1128
1129        assert_ne!(seven_alpha, eight_alpha);
1130    }
1131    /// This protocol's name, as the vocabulary table keys it.
1132    fn protocol() -> &'static str {
1133        from_utf8(NAME).expect("the protocol name is ASCII")
1134    }
1135
1136    #[test]
1137    fn the_grinding_vocabulary_maps_the_one_grind_this_protocol_describes() {
1138        // The security model keys its table on the name and the label bound here.
1139        let ood = grinding_step(protocol(), OOD_POW).expect("the out-of-domain grind is mapped");
1140        assert_eq!(ood.site, GrindingSite::OutOfDomain);
1141        assert_eq!(ood.zero_bits, ZeroBitConvention::Elided);
1142
1143        // A uni-STARK has no lookups, so it describes no lookup grind and owns no second row.
1144        assert_eq!(
1145            GRINDING_VOCABULARY
1146                .iter()
1147                .filter(|step| step.protocol == protocol())
1148                .count(),
1149            1,
1150        );
1151    }
1152
1153    #[test]
1154    fn the_out_of_domain_grind_carries_the_difficulty_the_model_credits() {
1155        // Invariant: one number reaches the pattern and the report by two routes.
1156        //
1157        //     ood_pow_bits  --pattern-->        Kind::Pow Length::Fixed
1158        //                   --GrindingSites-->  out_of_domain
1159        //
1160        // Elided at zero, so the sweep covers the step being absent and present.
1161        for ood_pow_bits in [0, 1, 8] {
1162            let shape = StarkShape {
1163                log_ext_degree: 5,
1164                log_degree: 5,
1165                main_width: 2,
1166                preprocessed_width: 0,
1167                num_public_values: 1,
1168                num_periodic_columns: 0,
1169                num_quotient_chunks: 2,
1170                opens_main_next_row: true,
1171                opens_preprocessed_next_row: false,
1172                has_randomization: false,
1173                ood_pow_bits,
1174            };
1175
1176            let recorded: Vec<_> = pow_difficulties(&shape.pattern::<F, EF>())
1177                .into_iter()
1178                .map(|(label, bits)| RecordedGrind::new(protocol(), label, bits))
1179                .collect();
1180
1181            GrindingBudget::from_sites(&GrindingSites {
1182                out_of_domain: ood_pow_bits,
1183                ..GrindingSites::NONE
1184            })
1185            .check(&[protocol()], &recorded)
1186            .unwrap_or_else(|mismatch| panic!("{mismatch}"));
1187        }
1188    }
1189}