From be46afb7847e29f5b65285d750efe7a29d649094 Mon Sep 17 00:00:00 2001 From: diegokingston Date: Mon, 29 Jun 2026 12:31:27 -0300 Subject: [PATCH 01/13] =?UTF-8?q?feat(stark):=20FRI=20early=20termination?= =?UTF-8?q?=20=E2=80=94=20send=20final-poly=20coefficients?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FRI stops folding when the polynomial reaches degree < 2^k (k = ProofOptions.fri_final_poly_log_degree, default 7) instead of folding to a single constant. The prover sends the 2^k final-polynomial coefficients (fri_final_poly_coeffs); the verifier reconstructs the terminal codeword via a coset FFT (base-field twiddles) and checks each query against it, with a structural degree-bound check and a clamp for tiny traces. k is bound into the Fiat-Shamir statement (DOMAIN_TAG _V3). Impact (blowup 2, 219 queries): ~225 KB smaller proof, constant across trace size (~16% at 2^20, 25% at 2^16); verifier does ~6,132 fewer Keccak compressions and ~1,840 fewer Fp3 muls per proof. - Prover interpolates the terminal poly on the minimal 2^k sub-coset (no oversized iFFT, no zero-trim). - GPU FRI commit (cuda) supports early termination, mirroring the CPU path; validated on a CUDA server (proof verifies, gpu_fri_calls fires). - Soundness: tampered / over-length / under-length coeffs and cross-k all reject; terminal codeword<->coeffs roundtrip; single-fold + clamp cases. --- crypto/stark/benches/profile_prover.rs | 1 + crypto/stark/benches/prover_benchmark.rs | 1 + crypto/stark/src/fri/mod.rs | 91 ++++-- crypto/stark/src/fri/terminal.rs | 109 +++++++ crypto/stark/src/gpu_lde.rs | 62 ++-- crypto/stark/src/proof/options.rs | 10 + crypto/stark/src/proof/stark.rs | 4 +- crypto/stark/src/prover.rs | 15 +- crypto/stark/src/tests/fri_tests.rs | 125 ++++++++ crypto/stark/src/tests/mod.rs | 1 + crypto/stark/src/tests/proof_options_tests.rs | 15 + crypto/stark/src/tests/prover_tests.rs | 7 + crypto/stark/src/tests/small_trace_tests.rs | 270 ++++++++++++++++++ crypto/stark/src/tests/terminal_tests.rs | 45 +++ crypto/stark/src/verifier.rs | 113 +++++++- prover/src/lib.rs | 2 + prover/src/statement.rs | 12 +- prover/src/tests/statement_tests.rs | 39 ++- prover/tests/cuda_path_integration.rs | 21 ++ 19 files changed, 860 insertions(+), 83 deletions(-) create mode 100644 crypto/stark/src/fri/terminal.rs create mode 100644 crypto/stark/src/tests/terminal_tests.rs diff --git a/crypto/stark/benches/profile_prover.rs b/crypto/stark/benches/profile_prover.rs index dbff24440..f5438877e 100644 --- a/crypto/stark/benches/profile_prover.rs +++ b/crypto/stark/benches/profile_prover.rs @@ -21,6 +21,7 @@ fn main() { fri_number_of_queries: 100, coset_offset: 3, grinding_factor: 0, + fri_final_poly_log_degree: 7, }; let num_columns = 16; diff --git a/crypto/stark/benches/prover_benchmark.rs b/crypto/stark/benches/prover_benchmark.rs index 2729fff29..c152e7dbb 100644 --- a/crypto/stark/benches/prover_benchmark.rs +++ b/crypto/stark/benches/prover_benchmark.rs @@ -61,6 +61,7 @@ fn benchmark_proof_options() -> ProofOptions { fri_number_of_queries: 30, coset_offset: 3, grinding_factor: 0, + fri_final_poly_log_degree: 7, } } diff --git a/crypto/stark/src/fri/mod.rs b/crypto/stark/src/fri/mod.rs index 60ad2a398..5517657ef 100644 --- a/crypto/stark/src/fri/mod.rs +++ b/crypto/stark/src/fri/mod.rs @@ -1,6 +1,7 @@ pub mod fri_commitment; pub mod fri_decommit; pub(crate) mod fri_functions; +pub(crate) mod terminal; use crypto::fiat_shamir::is_transcript::IsStarkTranscript; use math::field::element::FieldElement; @@ -16,25 +17,30 @@ use self::fri_functions::{ }; /// FRI commit phase from pre-computed bit-reversed evaluations, skipping the -/// initial FFT. Use this when the caller already has the evaluation vector -/// (e.g. from a fused LDE pipeline). +/// initial FFT. Stops folding when the remaining codeword encodes a polynomial +/// of degree < 2^`final_poly_log_degree` with blowup 2^`blowup_log`, and +/// returns the coefficient vector of that terminal polynomial. /// /// The `T: Clone` and `F/E: 'static` bounds are required by the cuda GPU /// fast path (`try_fri_commit_gpu` snapshots the transcript and TypeId- /// checks the field types). They are present unconditionally (including /// in builds without the `cuda` feature) to keep one stable signature. +#[allow(clippy::type_complexity)] pub fn commit_phase_from_evaluations< F: IsFFTField + IsSubFieldOf + 'static, - E: IsField + 'static, + E: IsField + 'static + Send + Sync, T: IsStarkTranscript + Clone, >( - number_layers: usize, + // `_number_layers`: retained for signature stability with the cuda fast path; termination is now driven by blowup_log + final_poly_log_degree. + _number_layers: usize, mut evals: Vec>, transcript: &mut T, coset_offset: &FieldElement, domain_size: usize, + blowup_log: u32, + final_poly_log_degree: u32, ) -> ( - FieldElement, + Vec>, Vec>>, ) where @@ -50,27 +56,39 @@ where // had never been tried. #[cfg(feature = "cuda")] { + // GPU FRI commit is disabled unconditionally (see `try_fri_commit_gpu` + // in gpu_lde.rs for the full explanation). The CPU fallback below + // handles all cases correctly, including early termination. if let Some(result) = crate::gpu_lde::try_fri_commit_gpu::( - number_layers, + _number_layers, &evals, transcript, coset_offset, domain_size, + blowup_log, + final_poly_log_degree, ) { return result; } } + // Determine how many total folds are needed to reach the terminal codeword. + // terminal_len = 2^(blowup_log + k), clamped to initial_len for tiny inputs. + let initial_len = evals.len(); + let k = final_poly_log_degree as usize; + let terminal_len = ((1usize << blowup_log) << k).min(initial_len); + let total_folds = (initial_len / terminal_len).trailing_zeros() as usize; + let num_committed = total_folds.saturating_sub(1); + // Inverse twiddle factors for evaluation-form folding. let mut inv_twiddles = compute_coset_twiddles_inv(coset_offset, domain_size); + let mut fri_layer_list = Vec::with_capacity(num_committed); + // Track the coset offset as it squares with each fold (needed for iFFT in terminal). + let mut terminal_offset = coset_offset.clone(); - // The loop commits `number_layers - 1` folded layers; the final fold below - // produces the (uncommitted) last value. - let num_committed_layers = number_layers.saturating_sub(1); - let mut fri_layer_list = Vec::with_capacity(num_committed_layers); - - for _ in 0..num_committed_layers { - // <<<< Receive challenge ๐œโ‚–โ‚‹โ‚ + // Commit `num_committed` folded layers to the transcript. + for _ in 0..num_committed { + // <<<< Receive challenge ๐œโ‚– let zeta = transcript.sample_field_element(); // Fold evaluations in-place (no FFT needed). @@ -89,25 +107,42 @@ where // >>>> Send commitment: [pโ‚–] transcript.append_bytes(&root); - // Update twiddles for the next level. + // Update twiddles and offset for the next level. update_twiddles_in_place(&mut inv_twiddles); + terminal_offset = terminal_offset.square(); } - // <<<< Receive challenge: ๐œโ‚™โ‚‹โ‚ - let zeta = transcript.sample_field_element(); - - // Final fold. - fold_evaluations_in_place(&mut evals, &zeta, &inv_twiddles); - - let last_value = evals - .first() - .expect("FRI evals are non-empty after folding") - .clone(); - - // >>>> Send value: pโ‚™ - transcript.append_field_element(&last_value); + // One final fold to reach the terminal codeword (size terminal_len), unless + // already there (total_folds == 0 means initial_len == terminal_len). + if total_folds > 0 { + // <<<< Receive challenge: ๐œ_final + let zeta = transcript.sample_field_element(); + fold_evaluations_in_place(&mut evals, &zeta, &inv_twiddles); + terminal_offset = terminal_offset.square(); + } + debug_assert_eq!(evals.len(), terminal_len, "terminal codeword size mismatch"); + + // Recover the low-degree polynomial coefficients from the terminal codeword + // and send them to the verifier. + // + // The number of coefficients is determined by the *actual* terminal codeword, + // not the requested `final_poly_log_degree`: for tiny inputs `terminal_len` + // is clamped to `initial_len`, so the terminal polynomial has degree + // < terminal_len / 2^blowup_log = 2^(log2(terminal_len) - blowup_log). Using + // this clamped exponent keeps the coefficient count in lockstep with what the + // verifier reconstructs (`expected_k = min(k, trace_bits)`); passing the raw + // `final_poly_log_degree` would over-pad with zeros and break the round-trip. + let effective_log_degree = terminal_len.trailing_zeros() - blowup_log; + let final_poly_coeffs = crate::fri::terminal::coeffs_from_terminal_codeword::( + &evals, + &terminal_offset, + effective_log_degree, + ); + for c in &final_poly_coeffs { + transcript.append_field_element(c); + } - (last_value, fri_layer_list) + (final_poly_coeffs, fri_layer_list) } pub fn query_phase( diff --git a/crypto/stark/src/fri/terminal.rs b/crypto/stark/src/fri/terminal.rs new file mode 100644 index 000000000..e3c663e49 --- /dev/null +++ b/crypto/stark/src/fri/terminal.rs @@ -0,0 +1,109 @@ +//! Conversion helpers between a FRI terminal codeword and the coefficients of +//! the low-degree polynomial it encodes. +//! +//! These are pure, self-contained helpers โ€” no transcript, no FRI logic. +//! They are used by the prover (`commit_phase_from_evaluations`) and verifier FRI step. + +use math::fft::bit_reversing::in_place_bit_reverse_permute; +use math::field::element::FieldElement; +use math::field::traits::{IsFFTField, IsField, IsSubFieldOf}; +use math::polynomial::Polynomial; + +/// Prover side: given a FRI terminal codeword in **bit-reversed** order, +/// recover the `2^final_poly_log_degree` coefficients of the underlying +/// low-degree polynomial. +/// +/// The codeword is a coset evaluation of a polynomial of degree less than +/// `2^final_poly_log_degree` on the coset `terminal_offsetยทโŸจฯ‰โŸฉ` of size +/// `blowupยท2^k`. +/// +/// Algorithm: +/// 1. Bit-reverse permute to convert from FRI order to natural (DFT) order. +/// 2. Decimate: extract the size-`2^k` sub-coset +/// `terminal_offsetยทโŸจฯ‰^blowupโŸฉ` = every `blowup`-th natural-order point. +/// 3. Coset iFFT on the small (`2^k`-point) sub-domain โ€” a `blowupร—`-smaller +/// transform that recovers the `2^k` coefficients directly (no oversized +/// transform and no wasteful truncation). +pub(crate) fn coeffs_from_terminal_codeword( + codeword_bitrev: &[FieldElement], + terminal_offset: &FieldElement, + final_poly_log_degree: u32, +) -> Vec> +where + F: IsFFTField + IsSubFieldOf, + E: IsField + Send + Sync, +{ + // Bit-reversed -> natural order. + let mut natural = codeword_bitrev.to_vec(); + in_place_bit_reverse_permute(&mut natural); + + // A degree-<2^k poly is determined by 2^k points: take the size-2^k sub-coset + // terminal_offset* = every `blowup`-th natural-order evaluation. + let keep = 1usize << final_poly_log_degree; + let blowup = natural.len() / keep; + let sub_coset: Vec> = natural.into_iter().step_by(blowup).collect(); + debug_assert_eq!(sub_coset.len(), keep); + + // Coset iFFT on the small domain -> the 2^k coefficients directly (no oversized trim). + let poly = Polynomial::interpolate_offset_fft::(&sub_coset, terminal_offset) + .expect("terminal sub-coset must have power-of-two length and non-zero offset"); + + // Pad with zeros only if interpolation dropped trailing-zero coeffs, so the + // proof always carries exactly 2^k coefficients (the verifier length-checks). + let mut coeffs = poly.coefficients().to_vec(); + coeffs.resize(keep, FieldElement::::zero()); + coeffs +} + +/// Verifier side: given `2^k` coefficients of the low-degree polynomial, +/// reconstruct the full FRI terminal codeword in **bit-reversed** order. +/// +/// Algorithm: +/// 1. FFT (coset): evaluate the polynomial on the full coset of size +/// `codeword_len` with shift `terminal_offset` to get natural order. +/// 2. Bit-reverse permute to convert natural order to FRI order. +/// +/// # Panics +/// +/// Panics if any of the following preconditions are violated: +/// - `coeffs` is non-empty, +/// - `coeffs.len()` is a power of two, +/// - `codeword_len` is a power of two, +/// - `coeffs.len() <= codeword_len`, and +/// - `codeword_len` is divisible by `coeffs.len()`. +/// +/// In the normal verifier flow these conditions are guaranteed by the +/// final-polynomial length check that the verifier performs before calling +/// this helper, so the assert should never fire in production. +pub(crate) fn terminal_codeword_from_coeffs( + coeffs: &[FieldElement], + terminal_offset: &FieldElement, + codeword_len: usize, +) -> Vec> +where + F: IsFFTField + IsSubFieldOf, + E: IsField + Send + Sync, +{ + assert!( + !coeffs.is_empty() + && coeffs.len().is_power_of_two() + && codeword_len.is_power_of_two() + && coeffs.len() <= codeword_len + && codeword_len.is_multiple_of(coeffs.len()), + "terminal_codeword_from_coeffs: coeffs.len() ({}) must be a non-zero power of two dividing codeword_len ({}); the verifier must length-check coeffs before calling", + coeffs.len(), + codeword_len, + ); + + let poly = Polynomial::new(coeffs); + let blowup = codeword_len / coeffs.len(); + + // Step 1: coset FFT to get natural-order evaluations. + let mut natural = + Polynomial::evaluate_offset_fft::(&poly, blowup, Some(coeffs.len()), terminal_offset) + .expect("terminal coset size must be a power of two within the field's two-adicity"); + + // Step 2: convert natural order to bit-reversed (FRI) order. + in_place_bit_reverse_permute(&mut natural); + natural +} diff --git a/crypto/stark/src/gpu_lde.rs b/crypto/stark/src/gpu_lde.rs index 36756b40b..ccb43f333 100644 --- a/crypto/stark/src/gpu_lde.rs +++ b/crypto/stark/src/gpu_lde.rs @@ -1519,22 +1519,28 @@ where /// concrete transcript type to support snapshot semantics via `Clone`. #[allow(clippy::type_complexity)] pub(crate) fn try_fri_commit_gpu( - number_layers: usize, + _number_layers: usize, evals: &[FieldElement], transcript: &mut T, coset_offset: &FieldElement, domain_size: usize, + blowup_log: u32, + final_poly_log_degree: u32, ) -> Option<( - FieldElement, + Vec>, Vec>>, )> where F: IsFFTField + IsField + IsSubFieldOf + 'static, - E: IsField + 'static, + E: IsField + 'static + Send + Sync, FieldElement: AsBytes, FieldElement: AsBytes, T: IsStarkTranscript + Clone, { + // GPU drives the early-termination FRI commit phase, mirroring + // `commit_phase_from_evaluations`: for each committed layer (sample zeta, + // fold, append root); then one final fold to the terminal codeword whose + // coefficients are emitted (not a single value). if TypeId::of::() != TypeId::of::() { return None; } @@ -1576,11 +1582,21 @@ where // produced had this dispatch never been called. let transcript_snapshot = transcript.clone(); - let num_committed_layers = number_layers.saturating_sub(1); + // Early-termination schedule (mirrors commit_phase_from_evaluations): + // terminal_len = 2^(blowup_log + final_poly_log_degree), clamped to n0. + let k = final_poly_log_degree as usize; + let terminal_len = ((1usize << blowup_log) << k).min(n0); + let total_folds = (n0 / terminal_len).trailing_zeros() as usize; + // The GPU path only runs above gpu_lde_threshold(); tiny clamped traces + // (total_folds == 0) are handled by the CPU fallback. + if total_folds == 0 { + return None; + } + let num_committed = total_folds - 1; let mut fri_layer_list: Vec>> = - Vec::with_capacity(num_committed_layers); + Vec::with_capacity(num_committed); - for _ in 0..num_committed_layers { + for _ in 0..num_committed { // <<<< Receive challenge zeta_k let zeta: FieldElement = transcript.sample_field_element(); // SAFETY: E == Ext3. @@ -1614,27 +1630,37 @@ where transcript.append_bytes(&root_arr); } - // <<<< Receive challenge zeta_{n-1} - let zeta_last: FieldElement = transcript.sample_field_element(); - let zeta_ptr = &zeta_last as *const FieldElement as *const u64; + // Final (uncommitted) fold to the terminal codeword. n_out == terminal_len + // >= 2, so reuse fold_and_commit_layer and keep only its evaluations; the + // Merkle root/nodes are discarded (the terminal layer is sent as coeffs). + let zeta_final: FieldElement = transcript.sample_field_element(); + let zeta_ptr = &zeta_final as *const FieldElement as *const u64; let zeta_raw: [u64; 3] = unsafe { [*zeta_ptr, *zeta_ptr.add(1), *zeta_ptr.add(2)] }; - let last_raw = match state.fold_final(zeta_raw) { + let (_root, terminal_evals_u64, _nodes) = match state.fold_and_commit_layer(zeta_raw) { Ok(v) => v, Err(_) => { *transcript = transcript_snapshot; return None; } }; - let last_vec = u64_to_ext3_vec::(&last_raw); - let last_value = last_vec - .into_iter() - .next() - .expect("fold_final returns 1 elt"); + debug_assert_eq!(terminal_evals_u64.len(), terminal_len * 3); + let terminal_codeword = u64_to_ext3_vec::(&terminal_evals_u64); + + // CPU-side coefficient extraction, identical to commit_phase_from_evaluations. + let terminal_offset = coset_offset.pow(1u64 << total_folds); + let effective_log_degree = terminal_len.trailing_zeros() - blowup_log; + let final_poly_coeffs = crate::fri::terminal::coeffs_from_terminal_codeword::( + &terminal_codeword, + &terminal_offset, + effective_log_degree, + ); - // >>>> Send value: p_n - transcript.append_field_element(&last_value); + // >>>> Send the final polynomial coefficients. + for c in &final_poly_coeffs { + transcript.append_field_element(c); + } GPU_FRI_CALLS.fetch_add(1, Ordering::Relaxed); - Some((last_value, fri_layer_list)) + Some((final_poly_coeffs, fri_layer_list)) } diff --git a/crypto/stark/src/proof/options.rs b/crypto/stark/src/proof/options.rs index 70976b993..4624943e8 100644 --- a/crypto/stark/src/proof/options.rs +++ b/crypto/stark/src/proof/options.rs @@ -38,6 +38,7 @@ impl fmt::Display for ProofOptionsError { /// - `fri_number_of_queries`: the number of queries for the FRI layer /// - `coset_offset`: the offset for the coset /// - `grinding_factor`: the number of leading zeros that we want for the Hash(hash || nonce) +/// - `fri_final_poly_log_degree`: log2 degree bound at which FRI terminates folding #[cfg_attr(feature = "wasm", wasm_bindgen)] #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct ProofOptions { @@ -45,6 +46,10 @@ pub struct ProofOptions { pub fri_number_of_queries: usize, pub coset_offset: u64, pub grinding_factor: u8, + /// Log2 of the FRI final-polynomial degree bound. FRI stops folding when the + /// polynomial has degree < 2^fri_final_poly_log_degree; the prover sends those + /// 2^k coefficients instead of folding to a constant. + pub fri_final_poly_log_degree: u8, } impl ProofOptions { @@ -56,6 +61,7 @@ impl ProofOptions { fri_number_of_queries: 3, coset_offset: 3, grinding_factor: 1, + fri_final_poly_log_degree: DEFAULT_FRI_FINAL_POLY_LOG_DEGREE, } } } @@ -75,6 +81,9 @@ impl ProofOptions { /// security bottleneck โ€” field size is not. pub struct GoldilocksCubicProofOptions; +// Shared by both ProofOptions::default_test_options and GoldilocksCubicProofOptions::with_params. +const DEFAULT_FRI_FINAL_POLY_LOG_DEGREE: u8 = 7; + impl GoldilocksCubicProofOptions { const DEFAULT_GRINDING: u8 = 20; @@ -112,6 +121,7 @@ impl GoldilocksCubicProofOptions { fri_number_of_queries, coset_offset: 3, grinding_factor, + fri_final_poly_log_degree: DEFAULT_FRI_FINAL_POLY_LOG_DEGREE, }) } } diff --git a/crypto/stark/src/proof/stark.rs b/crypto/stark/src/proof/stark.rs index 1751d60fe..0271bd918 100644 --- a/crypto/stark/src/proof/stark.rs +++ b/crypto/stark/src/proof/stark.rs @@ -52,8 +52,8 @@ pub struct StarkProof, E: IsField, PI> { pub composition_poly_parts_ood_evaluation: Vec>, // [pโ‚–] pub fri_layers_merkle_roots: Vec, - // pโ‚™ - pub fri_last_value: FieldElement, + /// Coefficients of the FRI final polynomial (degree < 2^k). + pub fri_final_poly_coeffs: Vec>, // Open(pโ‚–(Dโ‚–), โˆ’๐œโ‚›^(2แต)) pub query_list: Vec>, // Open(Hโ‚(D_LDE, ๐œแตข), Open(Hโ‚‚(D_LDE, ๐œแตข), Open(tโฑผ(D_LDE), ๐œแตข) diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index eed0e512a..1fc732bf1 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -440,8 +440,9 @@ pub(crate) struct Round3 { /// A container for the results of the fourth round of the STARK Prove protocol. pub(crate) struct Round4, E: IsField> { - /// The final value resulting from folding the Deep composition polynomial all the way down to a constant value. - fri_last_value: FieldElement, + /// Coefficients of the FRI final polynomial (degree < 2^k), emitted once + /// folding reaches the terminal codeword. + fri_final_poly_coeffs: Vec>, /// The commitments to the fold polynomials of the inner layers of FRI. fri_layers_merkle_roots: Vec, /// The values and proofs of validity of the evaluations of the trace polynomials and the composition polynomials @@ -1520,12 +1521,14 @@ pub trait IsStarkProver< // FRI commit phase from pre-computed evaluations #[cfg(feature = "instruments")] let t_sub = Instant::now(); - let (fri_last_value, fri_layers) = fri::commit_phase_from_evaluations( + let (fri_final_poly_coeffs, fri_layers) = fri::commit_phase_from_evaluations( domain.root_order as usize, lde_evals, transcript, &coset_offset, domain_size, + domain.blowup_factor.trailing_zeros(), + air.options().fri_final_poly_log_degree as u32, ); #[cfg(feature = "instruments")] let r4_merkle_dur = t_sub.elapsed(); @@ -1562,7 +1565,7 @@ pub trait IsStarkProver< } Round4 { - fri_last_value, + fri_final_poly_coeffs, fri_layers_merkle_roots, deep_poly_openings, query_list, @@ -2660,8 +2663,8 @@ pub trait IsStarkProver< .composition_poly_parts_ood_evaluation, // [pโ‚–] fri_layers_merkle_roots: round_4_result.fri_layers_merkle_roots, - // pโ‚™ - fri_last_value: round_4_result.fri_last_value, + // FRI final polynomial coefficients + fri_final_poly_coeffs: round_4_result.fri_final_poly_coeffs, // Open(pโ‚€(Dโ‚€), ๐œโ‚›), Open(pโ‚–(Dโ‚–), โˆ’๐œโ‚›^(2แต)) query_list: round_4_result.query_list, // Open(Hโ‚(D_LDE, ๐œโ‚€), Open(Hโ‚‚(D_LDE, ๐œโ‚€), Open(tโฑผ(D_LDE), ๐œโ‚€) diff --git a/crypto/stark/src/tests/fri_tests.rs b/crypto/stark/src/tests/fri_tests.rs index 503d0946a..cd1239255 100644 --- a/crypto/stark/src/tests/fri_tests.rs +++ b/crypto/stark/src/tests/fri_tests.rs @@ -131,3 +131,128 @@ fn test_eval_fold_matches_coeff_fold() { assert_eq!(path_a_evals, path_b_evals); } + +/// FRI commit-phase early-termination roundtrip. +/// +/// Builds a known low-degree FRI codeword, runs `commit_phase_from_evaluations` +/// with `blowup_log = 1`, `final_poly_log_degree = 2`, and checks: +/// * the emitted final polynomial has exactly `2^final_poly_log_degree` coeffs, +/// * the number of committed FRI layers equals `total_folds - 1`, +/// * folding each queried evaluation through the committed layers reaches the +/// reconstructed terminal codeword at the query's terminal-layer position. +#[test] +fn test_commit_phase_early_termination_roundtrip() { + use crate::fri::fri_functions::update_twiddles_in_place; + use crate::fri::terminal::terminal_codeword_from_coeffs; + use crate::fri::{commit_phase_from_evaluations, query_phase}; + use crypto::fiat_shamir::default_transcript::DefaultTranscript; + use crypto::fiat_shamir::is_transcript::IsTranscript; + use math::fft::bit_reversing::reverse_index; + use math::field::traits::IsFFTField; + + type F = GoldilocksField; + + let blowup_log: u32 = 1; + let final_poly_log_degree: u32 = 2; + let initial_len = 64usize; + let root_order = initial_len.trailing_zeros(); // 6 + let total_folds = (root_order - (blowup_log + final_poly_log_degree)) as usize; // 3 + let num_committed = total_folds - 1; // 2 + + let offset = FE::from(3u64); + + // Degree-<32 polynomial; with blowup 2 its terminal poly has degree < 2^2 = 4, + // so the emitted 2^2 coefficients capture it exactly. + let coeffs_in: Vec = (1u64..=32).map(FE::new).collect(); + let poly = Polynomial::new(&coeffs_in); + + // Coset LDE (blowup 2) -> natural order -> bit-reverse -> FRI-order codeword. + let mut codeword = + Polynomial::evaluate_offset_fft::(&poly, 2, Some(32), &offset).expect("LDE FFT"); + in_place_bit_reverse_permute(&mut codeword); + assert_eq!(codeword.len(), initial_len); + + // ---- Commit phase with early termination ---- + let mut transcript = DefaultTranscript::::new(&[]); + let (final_poly_coeffs, fri_layers) = commit_phase_from_evaluations::( + root_order as usize, + codeword.clone(), + &mut transcript, + &offset, + initial_len, + blowup_log, + final_poly_log_degree, + ); + + assert_eq!( + final_poly_coeffs.len(), + 1 << final_poly_log_degree, + "final poly must have 2^k coefficients" + ); + assert_eq!( + fri_layers.len(), + num_committed, + "committed layers must equal total_folds - 1" + ); + + // query_phase must still work against the committed layers. + let iotas = vec![0usize, 1, 5, 17, 30]; + let _decommitments = query_phase(&fri_layers, &iotas); + + // ---- Reconstruct terminal codeword from the emitted coefficients ---- + let terminal_len = (1usize << blowup_log) << final_poly_log_degree; // 8 + let terminal_offset = offset.pow(1u64 << total_folds); // offset^(2^3) + let terminal_codeword = + terminal_codeword_from_coeffs::(&final_poly_coeffs, &terminal_offset, terminal_len); + assert_eq!(terminal_codeword.len(), terminal_len); + + // Re-derive the prover's folding challenges by replaying the transcript. + let mut replay = DefaultTranscript::::new(&[]); + let mut zetas: Vec = Vec::with_capacity(total_folds); + for layer in &fri_layers { + zetas.push(replay.sample_field_element()); + replay.append_bytes(&layer.merkle_tree.root); + } + zetas.push(replay.sample_field_element()); // final-fold challenge + assert_eq!(zetas.len(), total_folds); + + // Strong check: folding the whole codeword with those challenges reproduces + // the reconstructed terminal codeword. + let mut refold = codeword.clone(); + let mut inv_tw = compute_coset_twiddles_inv::(&offset, initial_len); + for zeta in zetas.iter().take(total_folds) { + fold_evaluations_in_place(&mut refold, zeta, &inv_tw); + update_twiddles_in_place(&mut inv_tw); + } + assert_eq!( + refold, terminal_codeword, + "full re-fold must match reconstructed terminal codeword" + ); + + // Per-query check: replicate the verifier's fold path and land on + // terminal_codeword[index] at the terminal-layer position. + let omega = F::get_primitive_root_of_unity(root_order as u64).expect("root of unity"); + for &iota in &iotas { + // p0(nu) and p0(-nu) live at FRI-order positions 2*iota and 2*iota+1. + let p0 = codeword[2 * iota]; + let p0_sym = codeword[2 * iota + 1]; + // nu = offset * omega^reverse_index(2*iota, initial_len) + let nu = &offset * omega.pow(reverse_index(2 * iota, initial_len as u64) as u64); + let nu_inv = nu.inv().expect("evaluation point is non-zero"); + + // Fold layer 0 -> 1 using the first challenge. + let mut v = (&p0 + &p0_sym) + &nu_inv * &zetas[0] * (&p0 - &p0_sym); + let mut index = iota; + let mut ep_inv = nu_inv.square(); // nu^{-2} for the first committed layer + for (i, layer) in fri_layers.iter().enumerate() { + let sym = layer.evaluation[index ^ 1]; + v = (&v + &sym) + &ep_inv * &zetas[i + 1] * (&v - &sym); + index >>= 1; + ep_inv = ep_inv.square(); + } + assert_eq!( + v, terminal_codeword[index], + "query {iota}: folded value must equal terminal_codeword[{index}]" + ); + } +} diff --git a/crypto/stark/src/tests/mod.rs b/crypto/stark/src/tests/mod.rs index 8c0897ac1..60241fd02 100644 --- a/crypto/stark/src/tests/mod.rs +++ b/crypto/stark/src/tests/mod.rs @@ -11,5 +11,6 @@ pub mod prover_tests; pub mod small_trace_tests; #[cfg(feature = "disk-spill")] pub mod table_disk_spill_tests; +pub mod terminal_tests; pub mod trace_test_helpers; pub mod transition_tests; diff --git a/crypto/stark/src/tests/proof_options_tests.rs b/crypto/stark/src/tests/proof_options_tests.rs index ff7c7cc87..8e934eb7c 100644 --- a/crypto/stark/src/tests/proof_options_tests.rs +++ b/crypto/stark/src/tests/proof_options_tests.rs @@ -122,4 +122,19 @@ fn test_options_unchanged() { assert_eq!(opts.blowup_factor, 2); assert_eq!(opts.fri_number_of_queries, 3); assert_eq!(opts.grinding_factor, 1); + assert_eq!(opts.fri_final_poly_log_degree, 7); +} + +#[test] +fn with_blowup_sets_default_final_poly_log_degree() { + let opts = GoldilocksCubicProofOptions::with_blowup(2).expect("valid blowup"); + assert_eq!(opts.fri_final_poly_log_degree, 7); +} + +#[test] +fn default_test_options_sets_final_poly_log_degree() { + assert_eq!( + ProofOptions::default_test_options().fri_final_poly_log_degree, + 7 + ); } diff --git a/crypto/stark/src/tests/prover_tests.rs b/crypto/stark/src/tests/prover_tests.rs index 318dacb81..821a900c8 100644 --- a/crypto/stark/src/tests/prover_tests.rs +++ b/crypto/stark/src/tests/prover_tests.rs @@ -71,6 +71,7 @@ fn test_domain_constructor() { fri_number_of_queries: 1, coset_offset, grinding_factor, + fri_final_poly_log_degree: 7, }; let domain = Domain::new( @@ -162,6 +163,7 @@ fn barycentric_trace_eval_matches_horner_trace_eval() { fri_number_of_queries: 1, coset_offset, grinding_factor: 0, + fri_final_poly_log_degree: 7, }; let air = simple_fibonacci::FibonacciAIR::::new(&proof_options); @@ -233,6 +235,7 @@ fn test_decompose_and_extend_d2_matches_original() { fri_number_of_queries: 1, coset_offset: 3, grinding_factor: 0, + fri_final_poly_log_degree: 7, }; // We need an AIR with composition_poly_degree_bound = 2 * trace_length. @@ -298,12 +301,14 @@ fn test_multi_prove_mixed_coset_offsets() { fri_number_of_queries: 3, coset_offset: 3, grinding_factor: 1, + fri_final_poly_log_degree: 7, }; let proof_options_7 = ProofOptions { blowup_factor: 2, fri_number_of_queries: 3, coset_offset: 7, grinding_factor: 1, + fri_final_poly_log_degree: 7, }; // Both AIRs have the same trace length and blowup, but different coset offsets. @@ -368,6 +373,7 @@ fn test_multi_prove_dedups_shared_domain_params() { fri_number_of_queries: 3, coset_offset: 3, grinding_factor: 1, + fri_final_poly_log_degree: 7, }; let mut trace_1 = simple_fibonacci::fibonacci_trace([Felt::from(1), Felt::from(1)], 8); @@ -458,6 +464,7 @@ fn test_deep_poly_direct_2n_matches_interpolate_fft_extend() { fri_number_of_queries: 1, coset_offset: 3, grinding_factor: 0, + fri_final_poly_log_degree: 7, }; let air = QuadraticAIR::::new(&proof_options); diff --git a/crypto/stark/src/tests/small_trace_tests.rs b/crypto/stark/src/tests/small_trace_tests.rs index 8373ae9d6..844ab1d9b 100644 --- a/crypto/stark/src/tests/small_trace_tests.rs +++ b/crypto/stark/src/tests/small_trace_tests.rs @@ -92,6 +92,104 @@ fn test_prove_verify_two_rows() { ); } +/// Prove + verify with DEFAULT options (K=7) and a trace large enough that FRI +/// actually folds (trace_bits = 10 > 7). This exercises the full early-termination +/// path: committed FRI layers, a final fold, and terminal-codeword reconstruction +/// from the emitted final-polynomial coefficients. +#[test_log::test] +fn test_prove_verify_folding_default_options() { + let mut trace = simple_addition_trace::(1024); + let proof_options = ProofOptions::default_test_options(); + let pub_inputs = SimpleAdditionPublicInputs { + a: Felt::from(1u64), + b: Felt::from(2u64), + }; + let air = SimpleAdditionAIR::::new(&proof_options); + + let proof = Prover::prove( + &air, + &mut trace, + &pub_inputs, + &mut DefaultTranscript::::new(&[]), + ) + .unwrap(); + + assert!( + Verifier::verify( + &proof, + &air, + &mut DefaultTranscript::::new(&[]) + ), + "Verification failed for a folding trace under default options (K=7)" + ); +} + +/// Prove + verify with DEFAULT options (K=7) and a tiny trace (trace_bits = 3 <= 7) +/// so the FRI final-polynomial degree is clamped (`expected_k = min(k, trace_bits)`) +/// and no folding happens (`total_folds == 0`). The terminal codeword is the deep +/// composition codeword itself and the verifier checks the deep evaluations against +/// it directly. +#[test_log::test] +fn test_prove_verify_tiny_trace_clamp() { + let mut trace = simple_addition_trace::(8); + let proof_options = ProofOptions::default_test_options(); + let pub_inputs = SimpleAdditionPublicInputs { + a: Felt::from(1u64), + b: Felt::from(2u64), + }; + let air = SimpleAdditionAIR::::new(&proof_options); + + let proof = Prover::prove( + &air, + &mut trace, + &pub_inputs, + &mut DefaultTranscript::::new(&[]), + ) + .unwrap(); + + assert!( + Verifier::verify( + &proof, + &air, + &mut DefaultTranscript::::new(&[]) + ), + "Verification failed for a clamped tiny trace under default options (K=7)" + ); +} + +/// Prove + verify with DEFAULT options (K=7) and a 256-row trace (trace_bits=8). +/// With blowup=2 (blowup_log=1): expected_k = min(7,8) = 7, total_folds = 8-7 = 1. +/// This exercises the single-fold path: zero committed FRI layers, one final fold, +/// and the `fri_layers_merkle_roots.is_empty() && !zetas.is_empty()` branch in +/// `verify_query_and_sym_openings`. +#[test_log::test] +fn test_prove_verify_single_fold() { + let mut trace = simple_addition_trace::(256); + let proof_options = ProofOptions::default_test_options(); + let pub_inputs = SimpleAdditionPublicInputs { + a: Felt::from(1u64), + b: Felt::from(2u64), + }; + let air = SimpleAdditionAIR::::new(&proof_options); + + let proof = Prover::prove( + &air, + &mut trace, + &pub_inputs, + &mut DefaultTranscript::::new(&[]), + ) + .expect("Failed to generate proof for single-fold trace"); + + assert!( + Verifier::verify( + &proof, + &air, + &mut DefaultTranscript::::new(&[]) + ), + "Verification failed for single-fold trace (256 rows, total_folds=1)" + ); +} + /// Test that verification fails when using wrong public inputs. /// This ensures the boundary constraints are actually enforced. #[test_log::test] @@ -172,3 +270,175 @@ fn test_verify_rejects_opening_column_count_mismatch() { "Verifier must reject when an opening's column count does not match the OOD table width" ); } + +// --------------------------------------------------------------------------- +// Helpers shared by the FRI early-termination soundness tests below. +// --------------------------------------------------------------------------- + +/// Build a valid proof over a 1024-row trace (trace_bits=10) using the +/// default options (k=7, blowup=2). With these parameters: +/// expected_k = min(7, 10) = 7 +/// total_folds = 10 - 7 = 3 +/// fri_final_poly_coeffs.len() = 2^7 = 128 +/// fri_layers_merkle_roots.len() = total_folds - 1 = 2 +fn make_valid_folding_proof() -> ( + SimpleAdditionAIR, + crate::proof::stark::StarkProof< + GoldilocksField, + GoldilocksField, + SimpleAdditionPublicInputs, + >, +) { + let mut trace = simple_addition_trace::(1024); + let proof_options = ProofOptions::default_test_options(); + let pub_inputs = SimpleAdditionPublicInputs { + a: Felt::from(1u64), + b: Felt::from(2u64), + }; + let air = SimpleAdditionAIR::::new(&proof_options); + let proof = Prover::prove( + &air, + &mut trace, + &pub_inputs, + &mut DefaultTranscript::::new(&[]), + ) + .expect("Prover failed to generate 1024-row folding proof"); + (air, proof) +} + +// --------------------------------------------------------------------------- +// FRI early-termination soundness negative tests (Task 9) +// --------------------------------------------------------------------------- + +/// Soundness: mutating one element of `fri_final_poly_coeffs` must cause +/// verification to fail. The verifier absorbs every coefficient into the +/// Fiat-Shamir transcript before sampling query indices, so any modification +/// shifts all query challenges and invalidates the FRI openings. +#[test_log::test] +fn tampered_final_coeff_is_rejected() { + let (air, mut proof) = make_valid_folding_proof(); + + // Sanity: the unmodified proof must verify first. + assert!( + Verifier::verify( + &proof, + &air, + &mut DefaultTranscript::::new(&[]) + ), + "precondition: valid folding proof must verify" + ); + + // Corrupt the first coefficient by adding 1. + proof.fri_final_poly_coeffs[0] += Felt::one(); + + assert!( + !Verifier::verify( + &proof, + &air, + &mut DefaultTranscript::::new(&[]) + ), + "Verifier must reject a proof with a tampered FRI final-poly coefficient" + ); +} + +/// Soundness: pushing an extra element so `fri_final_poly_coeffs.len() > 2^k` +/// must be rejected by the structural degree check and must NOT panic. +/// The length check `len != 1 << expected_k` fires before the helper that +/// asserts a power-of-two length, so no assert is reachable. +#[test_log::test] +fn over_length_final_poly_is_rejected() { + let (air, mut proof) = make_valid_folding_proof(); + + // Sanity: the unmodified proof must verify first. + assert!( + Verifier::verify( + &proof, + &air, + &mut DefaultTranscript::::new(&[]) + ), + "precondition: valid folding proof must verify" + ); + + // Extend to length 129 (not equal to 128 = 2^7). + proof.fri_final_poly_coeffs.push(Felt::zero()); + + assert!( + !Verifier::verify( + &proof, + &air, + &mut DefaultTranscript::::new(&[]) + ), + "Verifier must reject when fri_final_poly_coeffs is longer than 2^k (over-length)" + ); +} + +/// Soundness: removing one element so `fri_final_poly_coeffs.len() < 2^k` +/// must be rejected and must NOT panic. The verifier's length check +/// (`len != 1 << expected_k`) fires before `terminal_codeword_from_coeffs` +/// (which asserts power-of-two length), so no assert is triggered. +/// If this test panics instead of returning false, that is a real verifier bug. +#[test_log::test] +fn truncated_final_poly_is_rejected() { + let (air, mut proof) = make_valid_folding_proof(); + + // Sanity: the unmodified proof must verify first. + assert!( + Verifier::verify( + &proof, + &air, + &mut DefaultTranscript::::new(&[]) + ), + "precondition: valid folding proof must verify" + ); + + // Shorten to length 127 (not equal to 128 = 2^7). + proof.fri_final_poly_coeffs.pop(); + + assert!( + !Verifier::verify( + &proof, + &air, + &mut DefaultTranscript::::new(&[]) + ), + "Verifier must reject when fri_final_poly_coeffs is shorter than 2^k (truncated)" + ); +} + +/// Soundness: a proof generated under k=7 must NOT verify when the verifier +/// uses k=6. The verifier reads `fri_final_poly_log_degree` from the AIR it +/// is given, so constructing a fresh AIR with k=6 is sufficient to switch the +/// expected degree. +/// +/// With a 1024-row trace (trace_bits=10): +/// Prover (k=7): expected_k=7, total_folds=3, merkle_roots.len()=2 +/// Verifier (k=6): expected_k=6, total_folds=4, expects merkle_roots.len()=3 +/// The committed-layer count mismatch (2 vs 3) causes `step_3_verify_fri` to +/// return false immediately, before any transcript-dependent checks. +#[test_log::test] +fn cross_k_proof_does_not_verify() { + let (air_k7, proof) = make_valid_folding_proof(); + + // Sanity: the proof verifies under the matching k=7 AIR. + assert!( + Verifier::verify( + &proof, + &air_k7, + &mut DefaultTranscript::::new(&[]) + ), + "precondition: valid folding proof must verify with k=7" + ); + + // Build a verifier AIR that expects k=6. + let mut options_k6 = ProofOptions::default_test_options(); + options_k6.fri_final_poly_log_degree = 6; + let air_k6 = SimpleAdditionAIR::::new(&options_k6); + + assert!( + !Verifier::verify( + &proof, + &air_k6, + &mut DefaultTranscript::::new(&[]) + ), + "Verifier with k=6 must reject a proof generated with k=7 (cross-k mismatch)" + ); +} diff --git a/crypto/stark/src/tests/terminal_tests.rs b/crypto/stark/src/tests/terminal_tests.rs new file mode 100644 index 000000000..563500995 --- /dev/null +++ b/crypto/stark/src/tests/terminal_tests.rs @@ -0,0 +1,45 @@ +use math::fft::bit_reversing::in_place_bit_reverse_permute; +use math::field::element::FieldElement; +use math::field::goldilocks::GoldilocksField; +use math::polynomial::Polynomial; + +use crate::fri::terminal::{coeffs_from_terminal_codeword, terminal_codeword_from_coeffs}; + +type F = GoldilocksField; +type FE = FieldElement; + +/// Roundtrip test: a degree-<8 polynomial survives +/// coeffs -> codeword (FRI bit-reversed) -> coeffs_from_terminal_codeword +/// and +/// recovered_coeffs -> terminal_codeword_from_coeffs -> original codeword. +#[test] +fn test_terminal_roundtrip() { + // k=3: poly has 8 coefficients, degree < 8. + // blowup=2: terminal codeword length = 8*2 = 16. + let final_poly_log_degree: u32 = 3; + let coeffs: Vec = (1u64..=8).map(FE::new).collect(); + let offset = FE::new(3); + + // Build the reference FRI-order codeword: + // evaluate_offset_fft returns natural order -> bit-reverse -> FRI order. + let poly = Polynomial::new(&coeffs); + let mut codeword = Polynomial::evaluate_offset_fft::(&poly, 2, Some(8), &offset) + .expect("evaluate_offset_fft failed"); + in_place_bit_reverse_permute(&mut codeword); + assert_eq!(codeword.len(), 16); + + // --- prover direction --- + let recovered_coeffs = + coeffs_from_terminal_codeword::(&codeword, &offset, final_poly_log_degree); + assert_eq!( + recovered_coeffs, coeffs, + "coeffs_from_terminal_codeword did not recover the original coefficients" + ); + + // --- verifier direction --- + let rebuilt_codeword = terminal_codeword_from_coeffs::(&recovered_coeffs, &offset, 16); + assert_eq!( + rebuilt_codeword, codeword, + "terminal_codeword_from_coeffs did not rebuild the original codeword" + ); +} diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index 68819c76b..9e54f2ee3 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -238,10 +238,31 @@ pub trait IsStarkVerifier< composition_poly_claimed_ood_evaluation == composition_poly_ood_evaluation } + /// FRI termination params derived from options + domain: `(blowup_log, expected_k, total_folds)`. + /// + /// * `blowup_log` - log2 of the LDE blowup factor. + /// * `expected_k` - clamped final-poly log-degree: `min(k, trace_bits)`. + /// * `total_folds` - number of FRI folds performed: `trace_bits - expected_k`. + /// + /// Both the commit phase (prover) and the Fiat-Shamir replay (verifier) must use + /// the same computation; having it in one place prevents silent drift between the two + /// callers that would break all proofs. + fn fri_termination_params( + air: &dyn AIR, + domain: &VerifierDomain, + ) -> (u32, u32, u32) { + let k = air.options().fri_final_poly_log_degree as u32; + let blowup_log = (domain.lde_length / domain.trace_length).trailing_zeros(); + let expected_k = k.min(domain.root_order); + let total_folds = domain.root_order - expected_k; + (blowup_log, expected_k, total_folds) + } + /// Reconstructs the Deep composition polynomial evaluations at the challenge indices values using the provided /// openings of the trace polynomials and the composition polynomial parts. It then uses these to verify that the /// FRI decommitments are valid and correspond to the Deep composition polynomial. fn step_3_verify_fri( + air: &dyn AIR, proof: &StarkProof, domain: &VerifierDomain, challenges: &Challenges, @@ -258,6 +279,34 @@ pub trait IsStarkVerifier< None => return false, }; + // ---- Reconstruct the FRI terminal codeword from the final-poly coeffs ---- + // The prover folds the deep composition codeword down to a terminal + // codeword of length `terminal_len = 2^(blowup_log + expected_k)` and sends the + // `2^expected_k` coefficients of the low-degree polynomial it encodes. + // `VerifierDomain.root_order` is `log2(trace_length)` (trace bits), and the + // LDE blowup adds `blowup_log` bits. + let (blowup_log, expected_k, total_folds) = Self::fri_termination_params(air, domain); + + // Structural check: number of committed FRI layers must equal + // `total_folds - 1` (zero when no fold or a single final fold happened). + if proof.fri_layers_merkle_roots.len() != total_folds.saturating_sub(1) as usize { + return false; + } + // Structural check: the final polynomial must have exactly `2^expected_k` + // coefficients; otherwise the reconstruction below is ill-defined. + if proof.fri_final_poly_coeffs.len() != (1usize << expected_k) { + return false; + } + + let terminal_len = (1usize << blowup_log) << expected_k; + let terminal_offset = domain.coset_offset.pow(1u64 << total_folds); + let terminal_codeword = crate::fri::terminal::terminal_codeword_from_coeffs::< + Field, + FieldExtension, + >( + &proof.fri_final_poly_coeffs, &terminal_offset, terminal_len + ); + // verify FRI let mut evaluation_point_inverse = challenges .iotas @@ -284,6 +333,7 @@ pub trait IsStarkVerifier< eval, &deep_poly_evaluations[i], &deep_poly_evaluations_sym[i], + &terminal_codeword, ) }) } @@ -466,6 +516,7 @@ pub trait IsStarkVerifier< /// `evaluation_point_inv`: precomputed value of ๐œโปยน. /// `deep_composition_evaluation`: precomputed value of pโ‚€(๐œ), where pโ‚€ is the deep composition polynomial. /// `deep_composition_evaluation_sym`: precomputed value of pโ‚€(-๐œ), where pโ‚€ is the deep composition polynomial. + #[allow(clippy::too_many_arguments)] fn verify_query_and_sym_openings( proof: &StarkProof, zetas: &[FieldElement], @@ -474,12 +525,30 @@ pub trait IsStarkVerifier< evaluation_point_inv: FieldElement, deep_composition_evaluation: &FieldElement, deep_composition_evaluation_sym: &FieldElement, + terminal_codeword: &[FieldElement], ) -> bool where FieldElement: AsBytes + Sync + Send, FieldElement: AsBytes + Sync + Send, { let fri_layers_merkle_roots = &proof.fri_layers_merkle_roots; + + let p0_eval = deep_composition_evaluation; + let p0_eval_sym = deep_composition_evaluation_sym; + + // No-fold (clamp) case: the codeword never folds (`total_folds == 0`), so + // no folding challenges were drawn and the terminal codeword *is* the deep + // composition codeword pโ‚€ itself. The query's two points ๐œ and -๐œ sit at + // FRI-order positions `iota*2` and `iota*2 + 1` of the terminal codeword. + if zetas.is_empty() { + return terminal_codeword + .get(iota * 2) + .is_some_and(|t| p0_eval == t) + && terminal_codeword + .get(iota * 2 + 1) + .is_some_and(|t| p0_eval_sym == t); + } + let evaluation_point_vec: Vec> = core::iter::successors(Some(evaluation_point_inv.square()), |evaluation_point| { Some(evaluation_point.square()) @@ -487,19 +556,17 @@ pub trait IsStarkVerifier< .take(fri_layers_merkle_roots.len()) .collect(); - let p0_eval = deep_composition_evaluation; - let p0_eval_sym = deep_composition_evaluation_sym; - // Reconstruct pโ‚(๐œยฒ) let mut v = (p0_eval + p0_eval_sym) + evaluation_point_inv * &zetas[0] * (p0_eval - p0_eval_sym); let mut index = iota; - // Handle case with 0 FRI layers (trace_length <= 2) - // In this case, the fold loop below doesn't iterate, so we need to verify - // the final value directly here. + // Handle case with 0 committed FRI layers but a single final fold + // (`total_folds == 1`). The fold loop below doesn't iterate, so we compare + // the folded value `v` against the reconstructed terminal codeword at the + // query's terminal-layer position (`index == iota`) directly. if fri_layers_merkle_roots.is_empty() { - return v == proof.fri_last_value; + return terminal_codeword.get(index).is_some_and(|t| &v == t); } // For each FRI layer, starting from the layer 1: use the proof to verify the validity of values pแตข(โˆ’๐œ^(2โฑ)) (given by the prover) and @@ -540,8 +607,12 @@ pub trait IsStarkVerifier< if i < fri_decommitment.layers_evaluations_sym.len() - 1 { result & openings_ok } else { - // Check that final value is the given by the prover - result & (v == proof.fri_last_value) & openings_ok + // Last committed layer: `v` is now the folded value at the + // terminal layer and `index` (after the final `index >>= 1`) + // is its FRI-order position there. Check it against the + // reconstructed terminal codeword. + let terminal_ok = terminal_codeword.get(index).is_some_and(|t| &v == t); + result & terminal_ok & openings_ok } }, ) @@ -1019,11 +1090,25 @@ pub trait IsStarkVerifier< }) .collect::>>(); - // >>>> Send challenge ๐œโ‚™โ‚‹โ‚ - zetas.push(transcript.sample_field_element()); + // The prover only samples the final-fold challenge when the codeword + // actually folds past the committed layers. For tiny traces (the clamp + // case) no fold happens, so no challenge is drawn. This must mirror the + // prover's `commit_phase_from_evaluations` exactly. + // + // `VerifierDomain.root_order` is `log2(trace_length)` (trace bits). The + // number of total folds equals `trace_bits - min(k, trace_bits)`. + let (_, _, total_folds) = Self::fri_termination_params(air, domain); + + // >>>> Send final-fold challenge ๐œ_final (only when folding occurs) + if total_folds > 0 { + zetas.push(transcript.sample_field_element()); + } - // <<<< Receive value: pโ‚™ - transcript.append_field_element(&proof.fri_last_value); + // <<<< Receive the FRI final-polynomial coefficients (same Vec, same + // order the prover appended them in `commit_phase_from_evaluations`). + for c in &proof.fri_final_poly_coeffs { + transcript.append_field_element(c); + } // Receive grinding value let security_bits = air.context().proof_options.grinding_factor; @@ -1118,7 +1203,7 @@ pub trait IsStarkVerifier< #[cfg(feature = "instruments")] let timer3 = Instant::now(); - if !Self::step_3_verify_fri(proof, &domain, &challenges) { + if !Self::step_3_verify_fri(air, proof, &domain, &challenges) { #[cfg(not(feature = "test_fiat_shamir"))] error!("FRI verification failed"); return false; diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 81233d39f..8b814df18 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -798,6 +798,7 @@ pub fn prove_with_options_and_inputs( &table_counts, num_private_input_pages, &runtime_page_ranges, + proof_options.fri_final_poly_log_degree, ); // Phase 4: Prove (multi_prove) @@ -949,6 +950,7 @@ pub fn verify_with_options( &vm_proof.table_counts, vm_proof.num_private_input_pages, &vm_proof.runtime_page_ranges, + proof_options.fri_final_poly_log_degree, ); // Fork the post-absorb state: the replay helper advances through Phase A diff --git a/prover/src/statement.rs b/prover/src/statement.rs index 7935abe66..63140a7b5 100644 --- a/prover/src/statement.rs +++ b/prover/src/statement.rs @@ -5,9 +5,9 @@ //! (`DefaultTranscript`), so a single hash suffices โ€” no external digest //! needed beyond the ELF. //! -//! All three call sites (prove, verify, bus-balance replay) must absorb -//! identical bytes; any divergence makes every derived challenge differ and -//! verification reject. +//! Both call sites (prove, verify) must absorb identical bytes; the bus-balance +//! replay inherits the post-absorb transcript via clone(). Any divergence makes +//! every derived challenge differ and verification reject. use crypto::fiat_shamir::is_transcript::IsTranscript; use sha3::{Digest, Keccak256}; @@ -16,7 +16,7 @@ use crate::test_utils::E; use crate::{RuntimePageRange, TableCounts}; /// Domain-separation tag. Bump the suffix (`_V2`, ...) on any encoding change. -const DOMAIN_TAG: &[u8] = b"LAMBDAVM_STARK_STATEMENT_V2"; +const DOMAIN_TAG: &[u8] = b"LAMBDAVM_STARK_STATEMENT_V3"; fn elf_digest(elf: &[u8]) -> [u8; 32] { let mut h = Keccak256::new(); @@ -31,6 +31,7 @@ pub(crate) fn absorb_statement( table_counts: &TableCounts, num_private_input_pages: usize, runtime_page_ranges: &[RuntimePageRange], + fri_final_poly_log_degree: u8, ) { t.append_bytes(DOMAIN_TAG); @@ -81,6 +82,9 @@ pub(crate) fn absorb_statement( t.append_bytes(&(num_private_input_pages as u64).to_le_bytes()); + // fri_final_poly_log_degree: single byte, no endianness concern. + t.append_bytes(&[fri_final_poly_log_degree]); + // runtime_page_ranges: count-prefixed; each entry fixed width. t.append_bytes(&(runtime_page_ranges.len() as u64).to_le_bytes()); for r in runtime_page_ranges { diff --git a/prover/src/tests/statement_tests.rs b/prover/src/tests/statement_tests.rs index 55ac5a15b..e0d079580 100644 --- a/prover/src/tests/statement_tests.rs +++ b/prover/src/tests/statement_tests.rs @@ -45,22 +45,31 @@ fn state_after_absorb( counts: &TableCounts, priv_pages: usize, ranges: &[RuntimePageRange], + fri_final_poly_log_degree: u8, ) -> [u8; 32] { let mut t = DefaultTranscript::::new(&[]); - absorb_statement(&mut t, elf, out, counts, priv_pages, ranges); + absorb_statement( + &mut t, + elf, + out, + counts, + priv_pages, + ranges, + fri_final_poly_log_degree, + ); t.state() } #[test] fn state_is_deterministic() { - let a = state_after_absorb(b"elf", b"out", &sample_counts(), 3, &sample_ranges()); - let b = state_after_absorb(b"elf", b"out", &sample_counts(), 3, &sample_ranges()); + let a = state_after_absorb(b"elf", b"out", &sample_counts(), 3, &sample_ranges(), 7); + let b = state_after_absorb(b"elf", b"out", &sample_counts(), 3, &sample_ranges(), 7); assert_eq!(a, b); } #[test] fn state_depends_on_every_field() { - let baseline = state_after_absorb(b"elf", b"out", &sample_counts(), 1, &sample_ranges()); + let baseline = state_after_absorb(b"elf", b"out", &sample_counts(), 1, &sample_ranges(), 7); assert_ne!( baseline, @@ -69,7 +78,8 @@ fn state_depends_on_every_field() { b"out", &sample_counts(), 1, - &sample_ranges() + &sample_ranges(), + 7, ), "state must depend on elf", ); @@ -80,7 +90,8 @@ fn state_depends_on_every_field() { b"different-output", &sample_counts(), 1, - &sample_ranges() + &sample_ranges(), + 7, ), "state must depend on public_output", ); @@ -89,21 +100,27 @@ fn state_depends_on_every_field() { counts2.branch += 1; assert_ne!( baseline, - state_after_absorb(b"elf", b"out", &counts2, 1, &sample_ranges()), + state_after_absorb(b"elf", b"out", &counts2, 1, &sample_ranges(), 7), "state must depend on table_counts", ); assert_ne!( baseline, - state_after_absorb(b"elf", b"out", &sample_counts(), 2, &sample_ranges()), + state_after_absorb(b"elf", b"out", &sample_counts(), 2, &sample_ranges(), 7), "state must depend on num_private_input_pages", ); assert_ne!( baseline, - state_after_absorb(b"elf", b"out", &sample_counts(), 1, &[]), + state_after_absorb(b"elf", b"out", &sample_counts(), 1, &[], 7), "state must depend on runtime_page_ranges", ); + + assert_ne!( + baseline, + state_after_absorb(b"elf", b"out", &sample_counts(), 1, &sample_ranges(), 8), + "state must depend on fri_final_poly_log_degree", + ); } #[test] @@ -116,7 +133,7 @@ fn public_output_length_prefix_prevents_collision() { let mut counts_b = sample_counts(); counts_b.cpu = 0; assert_ne!( - state_after_absorb(b"elf", b"", &counts_a, 0, &[]), - state_after_absorb(b"elf", b"\x41", &counts_b, 0, &[]), + state_after_absorb(b"elf", b"", &counts_a, 0, &[], 7), + state_after_absorb(b"elf", b"\x41", &counts_b, 0, &[], 7), ); } diff --git a/prover/tests/cuda_path_integration.rs b/prover/tests/cuda_path_integration.rs index 0f7c1f3c7..867396b83 100644 --- a/prover/tests/cuda_path_integration.rs +++ b/prover/tests/cuda_path_integration.rs @@ -66,3 +66,24 @@ fn gpu_path_fires_end_to_end() { let ok = verify(&proof, &elf).expect("verify"); assert!(ok, "GPU-produced proof failed verification"); } + +/// Focused validation of the GPU FRI early-termination commit: proves a large +/// trace (which exceeds the GPU FRI threshold), confirms the GPU FRI commit +/// path fired, and verifies the resulting proof. Independent of the per-round +/// counter assertions in `gpu_path_fires_end_to_end` (some of which are +/// sensitive to AIR/LDE shape and may bit-rot across LDE reworks). +#[test] +#[ignore = "requires GPU; run with --ignored --nocapture"] +fn gpu_fri_commit_produces_verifiable_proof() { + let elf = asm_elf_bytes("fib_iterative_1M"); + reset_all_gpu_call_counters(); + let proof = prove(&elf).expect("prove"); + assert!( + gpu_fri_calls() > 0, + "GPU FRI commit path did not fire on a 1M-row trace" + ); + assert!( + verify(&proof, &elf).expect("verify"), + "GPU-produced proof (early-termination FRI) failed verification" + ); +} From aa266ddb26877d8b02354d3c4b67e18ff407412c Mon Sep 17 00:00:00 2001 From: diegokingston Date: Tue, 30 Jun 2026 18:58:05 -0300 Subject: [PATCH 02/13] fix(stark): set fri_final_poly_log_degree in recursion smoke test ProofOptions The main merge brought in MIN_PROOF_OPTIONS (recursion_smoke_test.rs), which constructs ProofOptions without the fri_final_poly_log_degree field this branch adds, breaking compilation of the prover lib tests (Lint, Build prover tests, Disk-spill). Set it to 7 (the default used by every other test ProofOptions). --- prover/src/tests/recursion_smoke_test.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/prover/src/tests/recursion_smoke_test.rs b/prover/src/tests/recursion_smoke_test.rs index a4f9fb7b0..b2dcd5701 100644 --- a/prover/src/tests/recursion_smoke_test.rs +++ b/prover/src/tests/recursion_smoke_test.rs @@ -47,6 +47,7 @@ const MIN_PROOF_OPTIONS: stark::proof::options::ProofOptions = fri_number_of_queries: 1, coset_offset: 3, grinding_factor: 1, + fri_final_poly_log_degree: 7, }; /// Prove `inner_elf` (fed `inner_input`) under `opts`, then package From 9f6ee958c4cb2c15f25579d74e27aea93186d853 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 2 Jul 2026 15:02:11 -0300 Subject: [PATCH 03/13] Validate FRI decommitment layer count --- crypto/stark/src/tests/small_trace_tests.rs | 83 +++++++++++++++++++++ crypto/stark/src/verifier.rs | 19 ++++- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/crypto/stark/src/tests/small_trace_tests.rs b/crypto/stark/src/tests/small_trace_tests.rs index aaf8a7acd..fca60e4d9 100644 --- a/crypto/stark/src/tests/small_trace_tests.rs +++ b/crypto/stark/src/tests/small_trace_tests.rs @@ -380,6 +380,89 @@ fn truncated_final_poly_is_rejected() { ); } +/// Soundness: emptying every per-query FRI decommitment must be rejected. +/// +/// In the multi-fold path, `verify_query_and_sym_openings` folds the query value +/// through a loop that `zip`s the (trusted-length) committed layer roots against +/// the per-query `layers_auth_paths` / `layers_evaluations_sym`. Those vecs come +/// from the untrusted proof and are NOT absorbed into the Fiat-Shamir transcript, +/// so emptying them (`zip` truncates to 0) would make the fold run zero iterations +/// and return `true` โ€” no Merkle openings, no terminal low-degree check โ€” bypassing +/// FRI. The per-query decommitment length check in `step_3_verify_fri` must reject +/// this before the fold loop runs. +#[test_log::test] +fn empty_fri_decommitment_is_rejected() { + let (air, mut proof) = make_valid_folding_proof(); + + // Sanity: the unmodified multi-fold proof must verify first. + assert!( + Verifier::verify( + &proof, + &air, + &mut DefaultTranscript::::new(&[]) + ), + "precondition: valid folding proof must verify" + ); + + // Sanity: this is genuinely the multi-fold regime (num_committed >= 1). + assert!( + !proof.query_list[0].layers_evaluations_sym.is_empty(), + "precondition: multi-fold proof must have at least one committed layer" + ); + + // Drop every per-query decommitment layer. + for decommitment in proof.query_list.iter_mut() { + decommitment.layers_auth_paths.clear(); + decommitment.layers_evaluations_sym.clear(); + } + + assert!( + !Verifier::verify( + &proof, + &air, + &mut DefaultTranscript::::new(&[]) + ), + "Verifier must reject a proof whose per-query FRI decommitment layers are empty" + ); +} + +/// Soundness: padding every per-query FRI decommitment by one layer must be +/// rejected. With `layers_evaluations_sym.len() == num_committed + 1`, the fold +/// loop's last-iteration guard `i < layers_evaluations_sym.len() - 1` stays true, +/// so the terminal low-degree check in the `else` branch is never executed. The +/// per-query decommitment length check must reject this before the loop runs. +#[test_log::test] +fn padded_fri_decommitment_is_rejected() { + let (air, mut proof) = make_valid_folding_proof(); + + // Sanity: the unmodified multi-fold proof must verify first. + assert!( + Verifier::verify( + &proof, + &air, + &mut DefaultTranscript::::new(&[]) + ), + "precondition: valid folding proof must verify" + ); + + // Append one junk layer (copied from the first real layer) to every query. + let junk_eval = proof.query_list[0].layers_evaluations_sym[0]; + let junk_path = proof.query_list[0].layers_auth_paths[0].clone(); + for decommitment in proof.query_list.iter_mut() { + decommitment.layers_evaluations_sym.push(junk_eval); + decommitment.layers_auth_paths.push(junk_path.clone()); + } + + assert!( + !Verifier::verify( + &proof, + &air, + &mut DefaultTranscript::::new(&[]) + ), + "Verifier must reject a proof whose per-query FRI decommitment layers are padded" + ); +} + /// Soundness: a proof generated under k=7 must NOT verify when the verifier /// uses k=6. The verifier reads `fri_final_poly_log_degree` from the AIR it /// is given, so constructing a fresh AIR with k=6 is sufficient to switch the diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index 2d8a2009e..3b23b665b 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -286,10 +286,11 @@ pub trait IsStarkVerifier< // `VerifierDomain.root_order` is `log2(trace_length)` (trace bits), and the // LDE blowup adds `blowup_log` bits. let (blowup_log, expected_k, total_folds) = Self::fri_termination_params(air, domain); + let num_committed = total_folds.saturating_sub(1) as usize; // Structural check: number of committed FRI layers must equal - // `total_folds - 1` (zero when no fold or a single final fold happened). - if proof.fri_layers_merkle_roots.len() != total_folds.saturating_sub(1) as usize { + // `num_committed` (zero when no fold or a single final fold happened). + if proof.fri_layers_merkle_roots.len() != num_committed { return false; } // Structural check: the final polynomial must have exactly `2^expected_k` @@ -297,6 +298,20 @@ pub trait IsStarkVerifier< if proof.fri_final_poly_coeffs.len() != (1usize << expected_k) { return false; } + // Structural check: every per-query FRI decommitment must carry exactly + // `num_committed` layers. The fold loop in `verify_query_and_sym_openings` + // zips these untrusted, variable-length vecs against the committed layer + // roots, and they are NOT bound into the Fiat-Shamir transcript. Without + // this check a prover could send them empty (making the fold run zero + // iterations and accept the query vacuously) or padded (making the loop + // skip the terminal low-degree check), bypassing FRI entirely. This length + // check is the only thing that pins them, so it must run before the loop. + if proof.query_list.iter().any(|decommitment| { + decommitment.layers_auth_paths.len() != num_committed + || decommitment.layers_evaluations_sym.len() != num_committed + }) { + return false; + } let terminal_len = (1usize << blowup_log) << expected_k; let terminal_offset = domain.coset_offset.pow(1u64 << total_folds); From 0a88cbdc482856e263b7dc72e09202beeaecf747 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 2 Jul 2026 15:27:21 -0300 Subject: [PATCH 04/13] fix gpu fri terminal fold tuple destructure --- crypto/stark/src/gpu_lde.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crypto/stark/src/gpu_lde.rs b/crypto/stark/src/gpu_lde.rs index 53d20e1c4..3dfd3a15d 100644 --- a/crypto/stark/src/gpu_lde.rs +++ b/crypto/stark/src/gpu_lde.rs @@ -1680,7 +1680,7 @@ where let zeta_ptr = &zeta_final as *const FieldElement as *const u64; let zeta_raw: [u64; 3] = unsafe { [*zeta_ptr, *zeta_ptr.add(1), *zeta_ptr.add(2)] }; - let (_root, terminal_evals_u64, _nodes) = match state.fold_and_commit_layer(zeta_raw) { + let (terminal_evals_u64, _tree) = match state.fold_and_commit_layer(zeta_raw) { Ok(v) => v, Err(_) => { *transcript = transcript_snapshot; From a99f81757591dfafe4780ab8fc06bf479229997c Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 2 Jul 2026 15:38:11 -0300 Subject: [PATCH 05/13] correct gpu fri comment --- crypto/stark/src/fri/mod.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crypto/stark/src/fri/mod.rs b/crypto/stark/src/fri/mod.rs index 4eebd48e1..646a95c5d 100644 --- a/crypto/stark/src/fri/mod.rs +++ b/crypto/stark/src/fri/mod.rs @@ -56,9 +56,12 @@ where // had never been tried. #[cfg(feature = "cuda")] { - // GPU FRI commit is disabled unconditionally (see `try_fri_commit_gpu` - // in gpu_lde.rs for the full explanation). The CPU fallback below - // handles all cases correctly, including early termination. + // Try the GPU early-termination FRI commit first. `try_fri_commit_gpu` + // drives the same commit phase on-device (Goldilocks + Ext3, above the + // LDE size threshold, and only when folding actually happens) and returns + // `Some` with the final-polynomial coefficients. It returns `None` on any + // precondition miss or cudarc error โ€” restoring the transcript first โ€” so + // the CPU path below then runs as if the GPU had never been tried. if let Some(result) = crate::gpu_lde::try_fri_commit_gpu::( _number_layers, &evals, From 7622f21a0296c1b09755c343a3c6f1fa5694d0e7 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 2 Jul 2026 15:38:26 -0300 Subject: [PATCH 06/13] Bump continuation epoch tag for absorbed fri poly degree --- prover/src/statement.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prover/src/statement.rs b/prover/src/statement.rs index 8c8897a35..b28596dad 100644 --- a/prover/src/statement.rs +++ b/prover/src/statement.rs @@ -124,7 +124,7 @@ pub(crate) fn absorb_statement( /// Continuation domain tags. Distinct from the monolithic `DOMAIN_TAG` so a /// monolithic proof and a continuation proof can never share a transcript prefix. -const CONTINUATION_EPOCH_TAG: &[u8] = b"LAMBDAVM_CONTINUATION_EPOCH_V1"; +const CONTINUATION_EPOCH_TAG: &[u8] = b"LAMBDAVM_CONTINUATION_EPOCH_V2"; const CONTINUATION_GLOBAL_TAG: &[u8] = b"LAMBDAVM_CONTINUATION_GLOBAL_V1"; /// Statement bound into the cross-epoch **global** proof's transcript before From cac842dea2066ee92e72be1e3392d7d2c7a0e783 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 2 Jul 2026 16:08:17 -0300 Subject: [PATCH 07/13] Remove dead _number_layers param and Domain.root_order field --- crypto/stark/src/domain.rs | 2 -- crypto/stark/src/fri/mod.rs | 3 --- crypto/stark/src/gpu_lde.rs | 1 - crypto/stark/src/prover.rs | 1 - crypto/stark/src/tests/fri_tests.rs | 1 - crypto/stark/src/tests/prover_tests.rs | 1 - 6 files changed, 9 deletions(-) diff --git a/crypto/stark/src/domain.rs b/crypto/stark/src/domain.rs index e858c502c..9b9be3af2 100644 --- a/crypto/stark/src/domain.rs +++ b/crypto/stark/src/domain.rs @@ -49,7 +49,6 @@ use super::traits::AIR; /// Full domain with pre-computed roots of unity. Used by the prover which needs /// all elements for FFT operations. pub struct Domain { - pub(crate) root_order: u32, pub(crate) lde_roots_of_unity_coset: Vec>, pub(crate) trace_primitive_root: FieldElement, pub(crate) trace_roots_of_unity: Vec>, @@ -88,7 +87,6 @@ impl Domain { .unwrap(); Self { - root_order, lde_roots_of_unity_coset, trace_primitive_root, trace_roots_of_unity, diff --git a/crypto/stark/src/fri/mod.rs b/crypto/stark/src/fri/mod.rs index 646a95c5d..835a2af06 100644 --- a/crypto/stark/src/fri/mod.rs +++ b/crypto/stark/src/fri/mod.rs @@ -31,8 +31,6 @@ pub fn commit_phase_from_evaluations< E: IsField + 'static + Send + Sync, T: IsStarkTranscript + Clone, >( - // `_number_layers`: retained for signature stability with the cuda fast path; termination is now driven by blowup_log + final_poly_log_degree. - _number_layers: usize, mut evals: Vec>, transcript: &mut T, coset_offset: &FieldElement, @@ -63,7 +61,6 @@ where // precondition miss or cudarc error โ€” restoring the transcript first โ€” so // the CPU path below then runs as if the GPU had never been tried. if let Some(result) = crate::gpu_lde::try_fri_commit_gpu::( - _number_layers, &evals, transcript, coset_offset, diff --git a/crypto/stark/src/gpu_lde.rs b/crypto/stark/src/gpu_lde.rs index 3dfd3a15d..fb30aac96 100644 --- a/crypto/stark/src/gpu_lde.rs +++ b/crypto/stark/src/gpu_lde.rs @@ -1567,7 +1567,6 @@ where /// concrete transcript type to support snapshot semantics via `Clone`. #[allow(clippy::type_complexity)] pub(crate) fn try_fri_commit_gpu( - _number_layers: usize, evals: &[FieldElement], transcript: &mut T, coset_offset: &FieldElement, diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index 35d7f44d4..ef06e0f2b 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -1427,7 +1427,6 @@ pub trait IsStarkProver< #[cfg(feature = "instruments")] let t_sub = Instant::now(); let (fri_final_poly_coeffs, fri_layers) = fri::commit_phase_from_evaluations( - domain.root_order as usize, lde_evals, transcript, &coset_offset, diff --git a/crypto/stark/src/tests/fri_tests.rs b/crypto/stark/src/tests/fri_tests.rs index cd1239255..10b34afbb 100644 --- a/crypto/stark/src/tests/fri_tests.rs +++ b/crypto/stark/src/tests/fri_tests.rs @@ -175,7 +175,6 @@ fn test_commit_phase_early_termination_roundtrip() { // ---- Commit phase with early termination ---- let mut transcript = DefaultTranscript::::new(&[]); let (final_poly_coeffs, fri_layers) = commit_phase_from_evaluations::( - root_order as usize, codeword.clone(), &mut transcript, &offset, diff --git a/crypto/stark/src/tests/prover_tests.rs b/crypto/stark/src/tests/prover_tests.rs index 8f0e88fd9..a536a206a 100644 --- a/crypto/stark/src/tests/prover_tests.rs +++ b/crypto/stark/src/tests/prover_tests.rs @@ -80,7 +80,6 @@ fn test_domain_constructor() { ); assert_eq!(domain.blowup_factor, 2); assert_eq!(domain.interpolation_domain_size, trace_length); - assert_eq!(domain.root_order, trace_length.trailing_zeros()); assert_eq!(domain.coset_offset, FieldElement::from(coset_offset)); let primitive_root = GoldilocksField::get_primitive_root_of_unity( From 3da6250a9b3c6937684424a42da81683a1dc44e3 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 2 Jul 2026 16:09:32 -0300 Subject: [PATCH 08/13] Add FRI early-termination edge-case tests --- crypto/stark/src/tests/small_trace_tests.rs | 163 ++++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/crypto/stark/src/tests/small_trace_tests.rs b/crypto/stark/src/tests/small_trace_tests.rs index fca60e4d9..8bf699a89 100644 --- a/crypto/stark/src/tests/small_trace_tests.rs +++ b/crypto/stark/src/tests/small_trace_tests.rs @@ -166,6 +166,76 @@ fn test_prove_verify_single_fold() { ); } +/// Prove + verify with k=0: FRI folds all the way down to a single-coefficient +/// terminal polynomial (the closest analog to the old fold-to-constant behavior). +/// With a 1024-row trace: expected_k=0, total_folds=10, fri_final_poly_coeffs.len()=1, +/// fri_layers_merkle_roots.len()=9. Exercises the maximal-fold path. +#[test_log::test] +fn test_prove_verify_k0() { + let mut trace = simple_addition_trace::(1024); + let mut proof_options = ProofOptions::default_test_options(); + proof_options.fri_final_poly_log_degree = 0; + let pub_inputs = SimpleAdditionPublicInputs { + a: Felt::from(1u64), + b: Felt::from(2u64), + }; + let air = SimpleAdditionAIR::::new(&proof_options); + + let proof = Prover::prove( + &air, + &mut trace, + &pub_inputs, + &mut DefaultTranscript::::new(&[]), + ) + .expect("prover must succeed with k=0"); + + assert_eq!( + proof.fri_final_poly_coeffs.len(), + 1, + "k=0 must emit a single terminal coefficient" + ); + assert!( + Verifier::verify( + &proof, + &air, + &mut DefaultTranscript::::new(&[]) + ), + "k=0 proof must verify" + ); +} + +/// Prove + verify with `blowup_factor = 4` (blowup_log = 2). This is the only +/// test exercising a blowup > 2, so the terminal-codeword decimation +/// (`step_by(blowup)`) and the coset FFT run with a non-trivial blowup factor. +#[test_log::test] +fn test_prove_verify_blowup4() { + let mut trace = simple_addition_trace::(1024); + let mut proof_options = ProofOptions::default_test_options(); + proof_options.blowup_factor = 4; + let pub_inputs = SimpleAdditionPublicInputs { + a: Felt::from(1u64), + b: Felt::from(2u64), + }; + let air = SimpleAdditionAIR::::new(&proof_options); + + let proof = Prover::prove( + &air, + &mut trace, + &pub_inputs, + &mut DefaultTranscript::::new(&[]), + ) + .expect("prover must succeed with blowup_factor=4"); + + assert!( + Verifier::verify( + &proof, + &air, + &mut DefaultTranscript::::new(&[]) + ), + "blowup_factor=4 proof must verify" + ); +} + /// Test that verification fails when using wrong public inputs. /// This ensures the boundary constraints are actually enforced. #[test_log::test] @@ -463,6 +533,99 @@ fn padded_fri_decommitment_is_rejected() { ); } +/// Soundness (single-fold regime, total_folds=1 โ‡’ num_committed=0): the honest +/// decommitment carries zero layers. Padding it must be rejected by the per-query +/// decommitment length check, which runs for every regime โ€” not only multi-fold. +#[test_log::test] +fn padded_decommitment_rejected_single_fold() { + let mut trace = simple_addition_trace::(256); + let proof_options = ProofOptions::default_test_options(); + let pub_inputs = SimpleAdditionPublicInputs { + a: Felt::from(1u64), + b: Felt::from(2u64), + }; + let air = SimpleAdditionAIR::::new(&proof_options); + let mut proof = Prover::prove( + &air, + &mut trace, + &pub_inputs, + &mut DefaultTranscript::::new(&[]), + ) + .expect("prover must succeed (single-fold)"); + + assert!( + Verifier::verify( + &proof, + &air, + &mut DefaultTranscript::::new(&[]) + ), + "precondition: single-fold proof must verify" + ); + assert!( + proof.query_list[0].layers_evaluations_sym.is_empty(), + "precondition: single-fold has zero committed layers" + ); + + for decommitment in proof.query_list.iter_mut() { + decommitment.layers_evaluations_sym.push(Felt::zero()); + } + + assert!( + !Verifier::verify( + &proof, + &air, + &mut DefaultTranscript::::new(&[]) + ), + "Verifier must reject a padded decommitment in the single-fold regime" + ); +} + +/// Soundness (no-fold/clamp regime, total_folds=0 โ‡’ num_committed=0): same as +/// above but on the clamped tiny-trace path, which also has zero committed layers. +#[test_log::test] +fn padded_decommitment_rejected_no_fold() { + let mut trace = simple_addition_trace::(8); + let proof_options = ProofOptions::default_test_options(); + let pub_inputs = SimpleAdditionPublicInputs { + a: Felt::from(1u64), + b: Felt::from(2u64), + }; + let air = SimpleAdditionAIR::::new(&proof_options); + let mut proof = Prover::prove( + &air, + &mut trace, + &pub_inputs, + &mut DefaultTranscript::::new(&[]), + ) + .expect("prover must succeed (clamp/no-fold)"); + + assert!( + Verifier::verify( + &proof, + &air, + &mut DefaultTranscript::::new(&[]) + ), + "precondition: clamped proof must verify" + ); + assert!( + proof.query_list[0].layers_evaluations_sym.is_empty(), + "precondition: no-fold has zero committed layers" + ); + + for decommitment in proof.query_list.iter_mut() { + decommitment.layers_evaluations_sym.push(Felt::zero()); + } + + assert!( + !Verifier::verify( + &proof, + &air, + &mut DefaultTranscript::::new(&[]) + ), + "Verifier must reject a padded decommitment in the no-fold regime" + ); +} + /// Soundness: a proof generated under k=7 must NOT verify when the verifier /// uses k=6. The verifier reads `fri_final_poly_log_degree` from the AIR it /// is given, so constructing a fresh AIR with k=6 is sufficient to switch the From 39943b6b9c707bd2e02d34ba08c26719f38b4e16 Mon Sep 17 00:00:00 2001 From: Nicole Date: Thu, 2 Jul 2026 17:30:26 -0300 Subject: [PATCH 09/13] Clamp FRI terminal length overflow-safe so an out-of-range fri_final_poly_log_degree can't divide-by-zero the prover --- crypto/stark/src/fri/mod.rs | 13 +++++++- crypto/stark/src/gpu_lde.rs | 9 ++++- crypto/stark/src/tests/small_trace_tests.rs | 37 +++++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/crypto/stark/src/fri/mod.rs b/crypto/stark/src/fri/mod.rs index 835a2af06..0655363d2 100644 --- a/crypto/stark/src/fri/mod.rs +++ b/crypto/stark/src/fri/mod.rs @@ -76,7 +76,18 @@ where // terminal_len = 2^(blowup_log + k), clamped to initial_len for tiny inputs. let initial_len = evals.len(); let k = final_poly_log_degree as usize; - let terminal_len = ((1usize << blowup_log) << k).min(initial_len); + // Compute `min(2^(blowup_log + k), initial_len)` without materializing the + // shift: an out-of-range `k` (prover self-misconfiguration) would make + // `2^(blowup_log + k)` overflow to 0, and the `initial_len / terminal_len` + // below would then divide by zero. Clamping to `initial_len` first degrades + // gracefully to no early termination, mirroring the verifier's own + // `expected_k = min(k, root_order)` clamp. + let terminal_shift = blowup_log as usize + k; + let terminal_len = if terminal_shift >= initial_len.trailing_zeros() as usize { + initial_len + } else { + 1usize << terminal_shift + }; let total_folds = (initial_len / terminal_len).trailing_zeros() as usize; let num_committed = total_folds.saturating_sub(1); diff --git a/crypto/stark/src/gpu_lde.rs b/crypto/stark/src/gpu_lde.rs index fb30aac96..28e188d1b 100644 --- a/crypto/stark/src/gpu_lde.rs +++ b/crypto/stark/src/gpu_lde.rs @@ -1631,8 +1631,15 @@ where // Early-termination schedule (mirrors commit_phase_from_evaluations): // terminal_len = 2^(blowup_log + final_poly_log_degree), clamped to n0. + // Clamp without materializing the shift so an out-of-range `k` cannot + // overflow `terminal_len` to 0 (see `commit_phase_from_evaluations`). let k = final_poly_log_degree as usize; - let terminal_len = ((1usize << blowup_log) << k).min(n0); + let terminal_shift = blowup_log as usize + k; + let terminal_len = if terminal_shift >= n0.trailing_zeros() as usize { + n0 + } else { + 1usize << terminal_shift + }; let total_folds = (n0 / terminal_len).trailing_zeros() as usize; // The GPU path only runs above gpu_lde_threshold(); tiny clamped traces // (total_folds == 0) are handled by the CPU fallback. diff --git a/crypto/stark/src/tests/small_trace_tests.rs b/crypto/stark/src/tests/small_trace_tests.rs index 8bf699a89..f7c60b904 100644 --- a/crypto/stark/src/tests/small_trace_tests.rs +++ b/crypto/stark/src/tests/small_trace_tests.rs @@ -204,6 +204,43 @@ fn test_prove_verify_k0() { ); } +/// Prove + verify with an oversized `fri_final_poly_log_degree`. A `k` this large +/// used to overflow `2^(blowup_log + k)` to 0 in the prover, dividing by zero. +/// It must instead clamp to no early termination (terminal_len == initial_len, +/// total_folds == 0) and still verify, mirroring the verifier's `min(k, root_order)`. +#[test_log::test] +fn test_prove_verify_oversized_k_clamps() { + let mut trace = simple_addition_trace::(1024); + let mut proof_options = ProofOptions::default_test_options(); + proof_options.fri_final_poly_log_degree = 63; + let pub_inputs = SimpleAdditionPublicInputs { + a: Felt::from(1u64), + b: Felt::from(2u64), + }; + let air = SimpleAdditionAIR::::new(&proof_options); + + let proof = Prover::prove( + &air, + &mut trace, + &pub_inputs, + &mut DefaultTranscript::::new(&[]), + ) + .expect("prover must clamp an oversized k instead of overflowing"); + + assert!( + proof.fri_layers_merkle_roots.is_empty(), + "an oversized k must clamp to no early termination (no committed layers)" + ); + assert!( + Verifier::verify( + &proof, + &air, + &mut DefaultTranscript::::new(&[]) + ), + "clamped oversized-k proof must verify" + ); +} + /// Prove + verify with `blowup_factor = 4` (blowup_log = 2). This is the only /// test exercising a blowup > 2, so the terminal-codeword decimation /// (`step_by(blowup)`) and the coset FFT run with a non-trivial blowup factor. From 7315b49087a75314180e0cc0d7a644ca178e6fc8 Mon Sep 17 00:00:00 2001 From: Mauro Toscano <12560266+MauroToscano@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:09:18 -0300 Subject: [PATCH 10/13] FRI early-termination review follow-ups (1/3): terminal-fold cleanup (#785) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(stark): fall back to CPU when GPU FRI terminal_len == 1 The GPU final fold reuses `fold_and_commit_layer`, whose `assert!(n_out >= 2)` fires when the terminal codeword has length 1 (blowup_log + k == 0). That config only arises from a raw `ProofOptions` literal with `blowup_factor: 1` and `fri_final_poly_log_degree: 0` (every validated constructor rejects blowup 1), but when it does the assert aborts the prover mid-transcript instead of `try_fri_commit_gpu` returning None and letting the CPU fallback (which handles terminal_len == 1) produce the proof โ€” violating the function's documented return-None-on-any-failure contract. Extend the early-return guard to also bail when terminal_len < 2, so the final fold below is always n_out >= 2. * refactor(math-cuda): remove dead fold_final `fold_final` supported the old fold-to-a-constant terminal step (n_out == 1). After the FRI early-termination switch, the GPU final fold goes through `fold_and_commit_layer` and `fold_final` has no callers anywhere. Remove it, and fix the two stale references that still pointed at it (the fault-injection doc and `fold_and_commit_layer`'s assert message). * perf(stark): gather FRI terminal sub-coset via reverse_index `coeffs_from_terminal_codeword` cloned the whole terminal codeword and ran a full O(n) bit-reverse permute only to keep every blowup-th element. Since the codeword is already in bit-reversed order, gather the size-2^k sub-coset directly with reverse_index โ€” no clone, no full permute, and only 2^k of the blowup*2^k evaluations are read. Behaviour is identical (verified by the terminal roundtrip and FRI early-termination tests). --- crypto/math-cuda/src/fri.rs | 59 ++++---------------------------- crypto/stark/src/fri/terminal.rs | 22 ++++++------ crypto/stark/src/gpu_lde.rs | 9 +++-- 3 files changed, 24 insertions(+), 66 deletions(-) diff --git a/crypto/math-cuda/src/fri.rs b/crypto/math-cuda/src/fri.rs index a2f96c07a..fb854d0a4 100644 --- a/crypto/math-cuda/src/fri.rs +++ b/crypto/math-cuda/src/fri.rs @@ -17,9 +17,9 @@ use crate::device::backend; use crate::merkle::build_inner_tree_levels; /// Test-only fault injection. When the `test-faults` feature is on, setting -/// this to a finite value forces the next `fold_and_commit_layer` / -/// `fold_final` call to return Err and decrement the counter. Tests use -/// this to exercise the CPU-fallback path in `try_fri_commit_gpu`. +/// this to a finite value forces the next `fold_and_commit_layer` call to +/// return Err and decrement the counter. Tests use this to exercise the +/// CPU-fallback path in `try_fri_commit_gpu`. #[cfg(feature = "test-faults")] pub static FAULT_FOLDS_REMAINING_UNTIL_ERR: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(-1); @@ -104,10 +104,11 @@ impl FriCommitState { let be = backend()?; let n_in = self.current_n; let n_out = n_in / 2; - // fold_final handles the n_out == 1 last layer (no Merkle commit). + // n_out == 1 (terminal_len < 2) never reaches this path: `try_fri_commit_gpu` + // filters it out and returns None so the CPU fallback handles it. assert!( n_out >= 2, - "fold_and_commit_layer requires n_out >= 2; use fold_final" + "fold_and_commit_layer requires n_out >= 2 (n_out == 1 falls back to the CPU path)" ); // Row-pair leaves: each leaf hashes two consecutive ext3 evals. @@ -231,52 +232,4 @@ impl FriCommitState { }; Ok((layer_evals, tree)) } - - /// Final fold, no Merkle commit. Returns the single ext3 output - /// element (the FRI last_value). - pub fn fold_final(&mut self, zeta_raw: [u64; 3]) -> Result<[u64; 3]> { - #[cfg(feature = "test-faults")] - check_fault_injection()?; - let be = backend()?; - let n_in = self.current_n; - let n_out = n_in / 2; - assert!(n_out >= 1); - - let zeta_dev = self.stream.clone_htod(&zeta_raw)?; - let cfg = LaunchConfig { - grid_dim: ((n_out as u32).div_ceil(128), 1, 1), - block_dim: (128, 1, 1), - shared_mem_bytes: 0, - }; - let n_out_u64 = n_out as u64; - - let (input_evals, output_evals): (&CudaSlice, &mut CudaSlice) = if self.a_is_input - { - (&self.evals_a, &mut self.evals_b) - } else { - (&self.evals_b, &mut self.evals_a) - }; - unsafe { - self.stream - .launch_builder(&be.fri_fold_ext3) - .arg(input_evals) - .arg(&n_out_u64) - .arg(&self.inv_tw) - .arg(&zeta_dev) - .arg(output_evals) - .launch(cfg)?; - } - - self.stream.synchronize()?; - let out_first: Vec = if self.a_is_input { - let view = self.evals_b.slice(0..3); - self.stream.clone_dtoh(&view)? - } else { - let view = self.evals_a.slice(0..3); - self.stream.clone_dtoh(&view)? - }; - self.a_is_input = !self.a_is_input; - self.current_n = n_out; - Ok([out_first[0], out_first[1], out_first[2]]) - } } diff --git a/crypto/stark/src/fri/terminal.rs b/crypto/stark/src/fri/terminal.rs index e3c663e49..f47875556 100644 --- a/crypto/stark/src/fri/terminal.rs +++ b/crypto/stark/src/fri/terminal.rs @@ -4,7 +4,7 @@ //! These are pure, self-contained helpers โ€” no transcript, no FRI logic. //! They are used by the prover (`commit_phase_from_evaluations`) and verifier FRI step. -use math::fft::bit_reversing::in_place_bit_reverse_permute; +use math::fft::bit_reversing::{in_place_bit_reverse_permute, reverse_index}; use math::field::element::FieldElement; use math::field::traits::{IsFFTField, IsField, IsSubFieldOf}; use math::polynomial::Polynomial; @@ -33,16 +33,18 @@ where F: IsFFTField + IsSubFieldOf, E: IsField + Send + Sync, { - // Bit-reversed -> natural order. - let mut natural = codeword_bitrev.to_vec(); - in_place_bit_reverse_permute(&mut natural); - - // A degree-<2^k poly is determined by 2^k points: take the size-2^k sub-coset - // terminal_offset* = every `blowup`-th natural-order evaluation. + // A degree-<2^k poly is determined by 2^k points: the size-2^k sub-coset + // terminal_offset* = every `blowup`-th natural-order evaluation, + // i.e. natural-order index m*blowup for m in 0..2^k. The codeword is in + // bit-reversed order, so gather those points straight from it via + // reverse_index โ€” no full-codeword clone or O(n) permute (only 2^k of the + // blowup*2^k evaluations are ever read). + let len = codeword_bitrev.len(); let keep = 1usize << final_poly_log_degree; - let blowup = natural.len() / keep; - let sub_coset: Vec> = natural.into_iter().step_by(blowup).collect(); - debug_assert_eq!(sub_coset.len(), keep); + let blowup = len / keep; + let sub_coset: Vec> = (0..keep) + .map(|m| codeword_bitrev[reverse_index(m * blowup, len as u64)].clone()) + .collect(); // Coset iFFT on the small domain -> the 2^k coefficients directly (no oversized trim). let poly = Polynomial::interpolate_offset_fft::(&sub_coset, terminal_offset) diff --git a/crypto/stark/src/gpu_lde.rs b/crypto/stark/src/gpu_lde.rs index 28e188d1b..ac11b63dc 100644 --- a/crypto/stark/src/gpu_lde.rs +++ b/crypto/stark/src/gpu_lde.rs @@ -1641,9 +1641,12 @@ where 1usize << terminal_shift }; let total_folds = (n0 / terminal_len).trailing_zeros() as usize; - // The GPU path only runs above gpu_lde_threshold(); tiny clamped traces - // (total_folds == 0) are handled by the CPU fallback. - if total_folds == 0 { + // The GPU path only runs above gpu_lde_threshold(). Two cases fall back to + // the CPU path (which handles both correctly): tiny clamped traces + // (total_folds == 0), and terminal_len == 1 (blowup_log + k == 0), whose + // final fold would reach n_out == 1 and trip `fold_and_commit_layer`'s + // `n_out >= 2` assert. The final fold below is therefore always n_out >= 2. + if total_folds == 0 || terminal_len < 2 { return None; } let num_committed = total_folds - 1; From 525bab2d621b70bba22f6dad0893b3e5796f23ef Mon Sep 17 00:00:00 2001 From: MauroFab Date: Mon, 6 Jul 2026 16:18:50 -0300 Subject: [PATCH 11/13] refactor(stark): derive the FRI fold layout in one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The early-termination fold layout (clamp, total_folds, num_committed, terminal_len, effective_k) was computed independently in three places โ€” `commit_phase_from_evaluations` (CPU prover, from LDE size), `try_fri_commit_gpu` (GPU prover, from n0), and `fri_termination_params` (verifier, from trace bits) โ€” with two different parameterizations whose equivalence lived only in comments. The verifier's own doc comment claimed the arithmetic was centralized "to prevent silent drift", but it was not: an edit to the clamp in one copy would break all proofs, or (worse) only GPU-produced ones, whose parity is not exercised in CI. Introduce `FriFoldLayout::new(lde_log, blowup_log, k)` in fri/terminal.rs and have all three callers derive the layout from it. The single formulation (`terminal_log = min(blowup_log + k, lde_log)`) is also overflow-safe by construction, removing the hand-rolled shift-overflow guards. As part of the same cleanup, the CPU prover now derives the terminal coset offset once as `coset_offset^(2^total_folds)` (matching the GPU and verifier) instead of tracking it by incremental squaring through the fold loop. Wire-identical: full stark prove/verify suite, FRI early-termination soundness tests, and multi-table roundtrip all pass; GPU path compiles under --features cuda. --- crypto/stark/src/fri/mod.rs | 57 +++++++++++++------------------- crypto/stark/src/fri/terminal.rs | 55 +++++++++++++++++++++++++++--- crypto/stark/src/gpu_lde.rs | 29 ++++++---------- crypto/stark/src/verifier.rs | 57 ++++++++++++++------------------ 4 files changed, 109 insertions(+), 89 deletions(-) diff --git a/crypto/stark/src/fri/mod.rs b/crypto/stark/src/fri/mod.rs index 0655363d2..8f1172524 100644 --- a/crypto/stark/src/fri/mod.rs +++ b/crypto/stark/src/fri/mod.rs @@ -72,30 +72,17 @@ where } } - // Determine how many total folds are needed to reach the terminal codeword. - // terminal_len = 2^(blowup_log + k), clamped to initial_len for tiny inputs. - let initial_len = evals.len(); - let k = final_poly_log_degree as usize; - // Compute `min(2^(blowup_log + k), initial_len)` without materializing the - // shift: an out-of-range `k` (prover self-misconfiguration) would make - // `2^(blowup_log + k)` overflow to 0, and the `initial_len / terminal_len` - // below would then divide by zero. Clamping to `initial_len` first degrades - // gracefully to no early termination, mirroring the verifier's own - // `expected_k = min(k, root_order)` clamp. - let terminal_shift = blowup_log as usize + k; - let terminal_len = if terminal_shift >= initial_len.trailing_zeros() as usize { - initial_len - } else { - 1usize << terminal_shift - }; - let total_folds = (initial_len / terminal_len).trailing_zeros() as usize; - let num_committed = total_folds.saturating_sub(1); + // Fold layout, shared with the GPU prover and the verifier โ€” see `FriFoldLayout`. + let layout = crate::fri::terminal::FriFoldLayout::new( + evals.len().trailing_zeros(), + blowup_log, + final_poly_log_degree, + ); + let num_committed = layout.num_committed; // Inverse twiddle factors for evaluation-form folding. let mut inv_twiddles = compute_coset_twiddles_inv(coset_offset, domain_size); let mut fri_layer_list = Vec::with_capacity(num_committed); - // Track the coset offset as it squares with each fold (needed for iFFT in terminal). - let mut terminal_offset = coset_offset.clone(); // Commit `num_committed` folded layers to the transcript. for _ in 0..num_committed { @@ -118,36 +105,38 @@ where // >>>> Send commitment: [pโ‚–] transcript.append_bytes(&root); - // Update twiddles and offset for the next level. + // Update twiddles for the next level. update_twiddles_in_place(&mut inv_twiddles); - terminal_offset = terminal_offset.square(); } // One final fold to reach the terminal codeword (size terminal_len), unless // already there (total_folds == 0 means initial_len == terminal_len). - if total_folds > 0 { + if layout.total_folds > 0 { // <<<< Receive challenge: ๐œ_final let zeta = transcript.sample_field_element(); fold_evaluations_in_place(&mut evals, &zeta, &inv_twiddles); - terminal_offset = terminal_offset.square(); } - debug_assert_eq!(evals.len(), terminal_len, "terminal codeword size mismatch"); + debug_assert_eq!( + evals.len(), + layout.terminal_len, + "terminal codeword size mismatch" + ); // Recover the low-degree polynomial coefficients from the terminal codeword // and send them to the verifier. // - // The number of coefficients is determined by the *actual* terminal codeword, - // not the requested `final_poly_log_degree`: for tiny inputs `terminal_len` - // is clamped to `initial_len`, so the terminal polynomial has degree - // < terminal_len / 2^blowup_log = 2^(log2(terminal_len) - blowup_log). Using - // this clamped exponent keeps the coefficient count in lockstep with what the - // verifier reconstructs (`expected_k = min(k, trace_bits)`); passing the raw - // `final_poly_log_degree` would over-pad with zeros and break the round-trip. - let effective_log_degree = terminal_len.trailing_zeros() - blowup_log; + // The coefficient count follows the *actual* terminal codeword via + // `layout.effective_k` (`min(k, trace_bits)`), not the requested + // `final_poly_log_degree`: for tiny inputs the codeword is clamped to the + // full LDE, so passing the raw `k` would over-pad with zeros and break the + // round-trip against the verifier's own `expected_k` reconstruction. + // The terminal coset offset is `coset_offset^(2^total_folds)` โ€” the offset + // after `total_folds` squarings (matches the GPU prover and the verifier). + let terminal_offset = coset_offset.pow(1u64 << layout.total_folds); let final_poly_coeffs = crate::fri::terminal::coeffs_from_terminal_codeword::( &evals, &terminal_offset, - effective_log_degree, + layout.effective_k, ); for c in &final_poly_coeffs { transcript.append_field_element(c); diff --git a/crypto/stark/src/fri/terminal.rs b/crypto/stark/src/fri/terminal.rs index f47875556..716fbcf3d 100644 --- a/crypto/stark/src/fri/terminal.rs +++ b/crypto/stark/src/fri/terminal.rs @@ -1,14 +1,59 @@ -//! Conversion helpers between a FRI terminal codeword and the coefficients of -//! the low-degree polynomial it encodes. -//! -//! These are pure, self-contained helpers โ€” no transcript, no FRI logic. -//! They are used by the prover (`commit_phase_from_evaluations`) and verifier FRI step. +//! Shared, pure FRI early-termination helpers used by both the prover +//! (`commit_phase_from_evaluations`, `try_fri_commit_gpu`) and the verifier +//! (`step_3_verify_fri`): the fold layout (`FriFoldLayout`) and the conversion +//! between a terminal codeword and the coefficients of the low-degree +//! polynomial it encodes. No transcript, no FRI protocol state. use math::fft::bit_reversing::{in_place_bit_reverse_permute, reverse_index}; use math::field::element::FieldElement; use math::field::traits::{IsFFTField, IsField, IsSubFieldOf}; use math::polynomial::Polynomial; +/// The FRI early-termination fold layout. +/// +/// Derived identically by the CPU prover (`commit_phase_from_evaluations`), the +/// GPU prover (`try_fri_commit_gpu`), and the verifier (`fri_termination_params`). +/// Keeping the arithmetic in one place is load-bearing: the three callers must +/// agree exactly or proofs fail to verify, and a CPU/GPU disagreement would +/// surface only on GPU machines. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct FriFoldLayout { + /// Folds from the LDE codeword down to the terminal codeword. + pub(crate) total_folds: u32, + /// Committed (Merkle-rooted) FRI layers = `total_folds - 1`, or 0 when there + /// is no fold or only a single final fold. + pub(crate) num_committed: usize, + /// Terminal codeword length = `2^(blowup_log + effective_k)`. + pub(crate) terminal_len: usize, + /// Terminal polynomial log-degree bound actually used, `min(k, trace_bits)`. + /// This is the verifier's `expected_k` and the prover's `effective_log_degree`. + pub(crate) effective_k: u32, +} + +impl FriFoldLayout { + /// Derive the layout from the LDE codeword size. + /// + /// * `lde_log` โ€” log2 of the LDE (deep-composition) codeword length. + /// * `blowup_log` โ€” log2 of the LDE blowup factor. + /// * `k` โ€” requested `fri_final_poly_log_degree`. + /// + /// Folding stops once the codeword encodes a polynomial of degree `< 2^k`, + /// i.e. at codeword length `2^(blowup_log + k)`, clamped to the full LDE + /// size for traces too small to fold that far (the `.min(lde_log)`). + /// Computing `blowup_log + k` in `u32` (both small) sidesteps the + /// `1 << (blowup_log + k)` overflow an out-of-range `k` would otherwise cause. + pub(crate) fn new(lde_log: u32, blowup_log: u32, k: u32) -> Self { + let terminal_log = (blowup_log + k).min(lde_log); + let total_folds = lde_log - terminal_log; + Self { + total_folds, + num_committed: total_folds.saturating_sub(1) as usize, + terminal_len: 1usize << terminal_log, + effective_k: terminal_log - blowup_log, + } + } +} + /// Prover side: given a FRI terminal codeword in **bit-reversed** order, /// recover the `2^final_poly_log_degree` coefficients of the underlying /// low-degree polynomial. diff --git a/crypto/stark/src/gpu_lde.rs b/crypto/stark/src/gpu_lde.rs index ac11b63dc..e07fb8ec6 100644 --- a/crypto/stark/src/gpu_lde.rs +++ b/crypto/stark/src/gpu_lde.rs @@ -1629,27 +1629,21 @@ where // produced had this dispatch never been called. let transcript_snapshot = transcript.clone(); - // Early-termination schedule (mirrors commit_phase_from_evaluations): - // terminal_len = 2^(blowup_log + final_poly_log_degree), clamped to n0. - // Clamp without materializing the shift so an out-of-range `k` cannot - // overflow `terminal_len` to 0 (see `commit_phase_from_evaluations`). - let k = final_poly_log_degree as usize; - let terminal_shift = blowup_log as usize + k; - let terminal_len = if terminal_shift >= n0.trailing_zeros() as usize { - n0 - } else { - 1usize << terminal_shift - }; - let total_folds = (n0 / terminal_len).trailing_zeros() as usize; + // Fold layout, shared with the CPU prover and the verifier โ€” see `FriFoldLayout`. + let layout = crate::fri::terminal::FriFoldLayout::new( + n0.trailing_zeros(), + blowup_log, + final_poly_log_degree, + ); // The GPU path only runs above gpu_lde_threshold(). Two cases fall back to // the CPU path (which handles both correctly): tiny clamped traces // (total_folds == 0), and terminal_len == 1 (blowup_log + k == 0), whose // final fold would reach n_out == 1 and trip `fold_and_commit_layer`'s // `n_out >= 2` assert. The final fold below is therefore always n_out >= 2. - if total_folds == 0 || terminal_len < 2 { + if layout.total_folds == 0 || layout.terminal_len < 2 { return None; } - let num_committed = total_folds - 1; + let num_committed = layout.num_committed; let mut fri_layer_list: Vec>> = Vec::with_capacity(num_committed); @@ -1696,16 +1690,15 @@ where return None; } }; - debug_assert_eq!(terminal_evals_u64.len(), terminal_len * 3); + debug_assert_eq!(terminal_evals_u64.len(), layout.terminal_len * 3); let terminal_codeword = u64_to_ext3_vec::(&terminal_evals_u64); // CPU-side coefficient extraction, identical to commit_phase_from_evaluations. - let terminal_offset = coset_offset.pow(1u64 << total_folds); - let effective_log_degree = terminal_len.trailing_zeros() - blowup_log; + let terminal_offset = coset_offset.pow(1u64 << layout.total_folds); let final_poly_coeffs = crate::fri::terminal::coeffs_from_terminal_codeword::( &terminal_codeword, &terminal_offset, - effective_log_degree, + layout.effective_k, ); // >>>> Send the final polynomial coefficients. diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index cd0f73639..7f32e7750 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -236,24 +236,23 @@ pub trait IsStarkVerifier< composition_poly_claimed_ood_evaluation == composition_poly_ood_evaluation } - /// FRI termination params derived from options + domain: `(blowup_log, expected_k, total_folds)`. + /// The FRI fold layout for this proof, derived from options + domain. /// - /// * `blowup_log` - log2 of the LDE blowup factor. - /// * `expected_k` - clamped final-poly log-degree: `min(k, trace_bits)`. - /// * `total_folds` - number of FRI folds performed: `trace_bits - expected_k`. - /// - /// Both the commit phase (prover) and the Fiat-Shamir replay (verifier) must use - /// the same computation; having it in one place prevents silent drift between the two - /// callers that would break all proofs. + /// Delegates to the shared [`crate::fri::terminal::FriFoldLayout`] so the + /// verifier's Fiat-Shamir replay and structural checks use exactly the same + /// arithmetic as the CPU and GPU provers; drift between them would break all + /// proofs. `VerifierDomain.lde_length` is the codeword size and + /// `lde_length / trace_length` the blowup factor. + // `FriFoldLayout` is a crate-internal helper type returned from a default method + // of this public trait; the exposure is intentional (internal helper). + #[allow(private_interfaces)] fn fri_termination_params( air: &dyn AIR, domain: &VerifierDomain, - ) -> (u32, u32, u32) { + ) -> crate::fri::terminal::FriFoldLayout { let k = air.options().fri_final_poly_log_degree as u32; let blowup_log = (domain.lde_length / domain.trace_length).trailing_zeros(); - let expected_k = k.min(domain.root_order); - let total_folds = domain.root_order - expected_k; - (blowup_log, expected_k, total_folds) + crate::fri::terminal::FriFoldLayout::new(domain.lde_length.trailing_zeros(), blowup_log, k) } /// Reconstructs the Deep composition polynomial evaluations at the challenge indices values using the provided @@ -280,21 +279,19 @@ pub trait IsStarkVerifier< // ---- Reconstruct the FRI terminal codeword from the final-poly coeffs ---- // The prover folds the deep composition codeword down to a terminal - // codeword of length `terminal_len = 2^(blowup_log + expected_k)` and sends the - // `2^expected_k` coefficients of the low-degree polynomial it encodes. - // `VerifierDomain.root_order` is `log2(trace_length)` (trace bits), and the - // LDE blowup adds `blowup_log` bits. - let (blowup_log, expected_k, total_folds) = Self::fri_termination_params(air, domain); - let num_committed = total_folds.saturating_sub(1) as usize; + // codeword of length `terminal_len = 2^(blowup_log + effective_k)` and sends + // the `2^effective_k` coefficients of the low-degree polynomial it encodes. + let layout = Self::fri_termination_params(air, domain); + let num_committed = layout.num_committed; // Structural check: number of committed FRI layers must equal // `num_committed` (zero when no fold or a single final fold happened). if proof.fri_layers_merkle_roots.len() != num_committed { return false; } - // Structural check: the final polynomial must have exactly `2^expected_k` + // Structural check: the final polynomial must have exactly `2^effective_k` // coefficients; otherwise the reconstruction below is ill-defined. - if proof.fri_final_poly_coeffs.len() != (1usize << expected_k) { + if proof.fri_final_poly_coeffs.len() != (1usize << layout.effective_k) { return false; } // Structural check: every per-query FRI decommitment must carry exactly @@ -312,14 +309,13 @@ pub trait IsStarkVerifier< return false; } - let terminal_len = (1usize << blowup_log) << expected_k; - let terminal_offset = domain.coset_offset.pow(1u64 << total_folds); - let terminal_codeword = crate::fri::terminal::terminal_codeword_from_coeffs::< - Field, - FieldExtension, - >( - &proof.fri_final_poly_coeffs, &terminal_offset, terminal_len - ); + let terminal_offset = domain.coset_offset.pow(1u64 << layout.total_folds); + let terminal_codeword = + crate::fri::terminal::terminal_codeword_from_coeffs::( + &proof.fri_final_poly_coeffs, + &terminal_offset, + layout.terminal_len, + ); // verify FRI let mut evaluation_point_inverse = challenges @@ -1098,10 +1094,7 @@ pub trait IsStarkVerifier< // actually folds past the committed layers. For tiny traces (the clamp // case) no fold happens, so no challenge is drawn. This must mirror the // prover's `commit_phase_from_evaluations` exactly. - // - // `VerifierDomain.root_order` is `log2(trace_length)` (trace bits). The - // number of total folds equals `trace_bits - min(k, trace_bits)`. - let (_, _, total_folds) = Self::fri_termination_params(air, domain); + let total_folds = Self::fri_termination_params(air, domain).total_folds; // >>>> Send final-fold challenge ๐œ_final (only when folding occurs) if total_folds > 0 { From 7c39c7b5ab3ff3ebe7422a7158625cf8a3605db7 Mon Sep 17 00:00:00 2001 From: MauroFab Date: Mon, 6 Jul 2026 16:19:41 -0300 Subject: [PATCH 12/13] refactor(stark): hoist the FRI terminal-codeword check out of the fold loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `verify_query_and_sym_openings` checked each query against the reconstructed terminal codeword in two places โ€” a dedicated early return for the single-fold (`total_folds == 1`) regime, and the last-iteration `else` arm inside the fold loop for the multi-fold regime. Two hand-synced copies of a soundness-critical comparison (the last-iteration arm being exactly where the padded-decommitment bypass the PR guards against would land) is a drift risk. After the fold loop, `v` and `index` already hold the query's terminal-layer value and position in every regime, so a single check hoisted after the loop covers both โ€” deleting the `is_empty()` early return and the `i < len - 1` last-iteration branch. The per-query decommitment length checks in `step_3_verify_fri` (which pin the fold count) make this behaviour-identical. Verified by the full FRI early-termination soundness suite (empty/padded/ truncated/over-length decommitment rejection across the no-fold, single-fold, and multi-fold regimes) and the prove/verify roundtrips. --- crypto/stark/src/verifier.rs | 101 +++++++++++++++++------------------ 1 file changed, 48 insertions(+), 53 deletions(-) diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index 7f32e7750..64c01b17a 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -558,61 +558,56 @@ pub trait IsStarkVerifier< (p0_eval + p0_eval_sym) + evaluation_point_inv * &zetas[0] * (p0_eval - p0_eval_sym); let mut index = iota; - // Handle case with 0 committed FRI layers but a single final fold - // (`total_folds == 1`). The fold loop below doesn't iterate, so we compare - // the folded value `v` against the reconstructed terminal codeword at the - // query's terminal-layer position (`index == iota`) directly. - if fri_layers_merkle_roots.is_empty() { - return terminal_codeword.get(index).is_some_and(|t| &v == t); - } + // Fold through every committed layer: use the proof to verify the openings + // of pแตข(โˆ’๐œ^(2โฑ)) (given by the prover) and pแตข(๐œ^(2โฑ)) (computed on the + // previous iteration), then obtain pแตขโ‚Šโ‚(๐œ^(2โฑโบยน)). When there are no + // committed layers (`total_folds == 1`, a single final fold) this fold is + // empty and `v`/`index` already hold the terminal-layer value/position. + let openings_ok = + fri_layers_merkle_roots + .iter() + .enumerate() + .zip(&fri_decommitment.layers_auth_paths) + .zip(&fri_decommitment.layers_evaluations_sym) + .zip(evaluation_point_vec) + .fold( + true, + |result, + ( + (((i, merkle_root), auth_path_sym), evaluation_sym), + evaluation_point_inv, + )| { + // Verify opening Open(pแตข(Dโ‚–), โˆ’๐œ^(2โฑ)) and Open(pแตข(Dโ‚–), ๐œ^(2โฑ)). + // `v` is pแตข(๐œ^(2โฑ)). + // `evaluation_sym` is pแตข(โˆ’๐œ^(2โฑ)). + let openings_ok = Self::verify_fri_layer_openings( + merkle_root, + auth_path_sym, + &v, + evaluation_sym, + index, + ); + + // Update `v` with next value pแตขโ‚Šโ‚(๐œ^(2โฑโบยน)). + v = (&v + evaluation_sym) + + evaluation_point_inv * &zetas[i + 1] * (&v - evaluation_sym); + + // Update index for next iteration. The index of the squares in the next layer + // is obtained by halving the current index. This is due to the bit-reverse + // ordering of the elements in the Merkle tree. + index >>= 1; - // For each FRI layer, starting from the layer 1: use the proof to verify the validity of values pแตข(โˆ’๐œ^(2โฑ)) (given by the prover) and - // pแตข(๐œ^(2โฑ)) (computed on the previous iteration by the verifier). Then use them to obtain pแตขโ‚Šโ‚(๐œ^(2โฑโบยน)). - // Finally, check that the final value coincides with the given by the prover. - fri_layers_merkle_roots - .iter() - .enumerate() - .zip(&fri_decommitment.layers_auth_paths) - .zip(&fri_decommitment.layers_evaluations_sym) - .zip(evaluation_point_vec) - .fold( - true, - |result, - ( - (((i, merkle_root), auth_path_sym), evaluation_sym), - evaluation_point_inv, - )| { - // Verify opening Open(pแตข(Dโ‚–), โˆ’๐œ^(2โฑ)) and Open(pแตข(Dโ‚–), ๐œ^(2โฑ)). - // `v` is pแตข(๐œ^(2โฑ)). - // `evaluation_sym` is pแตข(โˆ’๐œ^(2โฑ)). - let openings_ok = Self::verify_fri_layer_openings( - merkle_root, - auth_path_sym, - &v, - evaluation_sym, - index, - ); - - // Update `v` with next value pแตขโ‚Šโ‚(๐œ^(2โฑโบยน)). - v = (&v + evaluation_sym) + evaluation_point_inv * &zetas[i + 1] * (&v - evaluation_sym); - - // Update index for next iteration. The index of the squares in the next layer - // is obtained by halving the current index. This is due to the bit-reverse - // ordering of the elements in the Merkle tree. - index >>= 1; - - if i < fri_decommitment.layers_evaluations_sym.len() - 1 { result & openings_ok - } else { - // Last committed layer: `v` is now the folded value at the - // terminal layer and `index` (after the final `index >>= 1`) - // is its FRI-order position there. Check it against the - // reconstructed terminal codeword. - let terminal_ok = terminal_codeword.get(index).is_some_and(|t| &v == t); - result & terminal_ok & openings_ok - } - }, - ) + }, + ); + + // After folding through all committed layers, `v` is the query's value at + // the terminal layer and `index` its FRI-order position there. Check it + // against the reconstructed terminal codeword. This single check covers + // both the single-fold (`total_folds == 1`, empty fold above) and + // multi-fold regimes; `.get()` fails closed on an out-of-range index. + let terminal_ok = terminal_codeword.get(index).is_some_and(|t| &v == t); + openings_ok & terminal_ok } fn reconstruct_deep_composition_poly_evaluations_for_all_queries( From 5bb336dab71673c6f34f11fb29ab46a1f1f5730d Mon Sep 17 00:00:00 2001 From: MauroFab Date: Mon, 6 Jul 2026 16:36:28 -0300 Subject: [PATCH 13/13] fix(prover): bind fri_final_poly_log_degree into the continuation-global statement #729 binds `fri_final_poly_log_degree` into the monolithic statement (STATEMENT_V3) and the continuation-epoch statement (CONTINUATION_EPOCH_V2), but not into `absorb_continuation_global_statement`. The cross-epoch global proof is itself a STARK produced and verified under the same ProofOptions, so its FRI transcript shape depends on `k` just like the others; leaving it unbound makes the canonical-binding guarantee half-applied and contradicts the global statement's own doc comment ("canonically pinned, like the monolithic path's absorb_statement"). Absorb the byte and bump CONTINUATION_GLOBAL_V1 -> V2. A mismatch could only ever reject (the verifier derives every FRI parameter from its own options and structurally checks the proof), so this is defense-in-depth/consistency, not a soundness fix. Adds the `must bind fri_final_poly_log_degree` assertion to the global-statement test; continuation prove/verify roundtrips still pass. --- prover/src/continuation.rs | 11 ++++++++++- prover/src/statement.rs | 10 ++++++++-- prover/src/tests/statement_tests.rs | 21 ++++++++++++++------- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/prover/src/continuation.rs b/prover/src/continuation.rs index 105ac92a8..c5c2fc944 100644 --- a/prover/src/continuation.rs +++ b/prover/src/continuation.rs @@ -110,6 +110,7 @@ fn global_transcript( elf_bytes: &[u8], num_epochs: usize, num_private_input_pages: usize, + fri_final_poly_log_degree: u8, touched_page_bases: &[u64], ) -> DefaultTranscript { let mut transcript = DefaultTranscript::::new(&[]); @@ -118,6 +119,7 @@ fn global_transcript( elf_bytes, num_epochs, num_private_input_pages, + fri_final_poly_log_degree, touched_page_bases, ); transcript @@ -686,6 +688,7 @@ fn prove_global( elf_bytes, boundaries.len(), num_private_input_pages, + opts.fri_final_poly_log_degree, page_bases, ), #[cfg(feature = "disk-spill")] @@ -730,7 +733,13 @@ fn verify_global( Verifier::multi_verify( &refs, proof, - &mut global_transcript(elf_bytes, num_epochs, num_private_input_pages, page_bases), + &mut global_transcript( + elf_bytes, + num_epochs, + num_private_input_pages, + opts.fri_final_poly_log_degree, + page_bases, + ), &FieldElement::zero(), ) } diff --git a/prover/src/statement.rs b/prover/src/statement.rs index e2ca27fef..87dab84cd 100644 --- a/prover/src/statement.rs +++ b/prover/src/statement.rs @@ -125,20 +125,23 @@ pub(crate) fn absorb_statement( /// Continuation domain tags. Distinct from the monolithic `DOMAIN_TAG` so a /// monolithic proof and a continuation proof can never share a transcript prefix. const CONTINUATION_EPOCH_TAG: &[u8] = b"LAMBDAVM_CONTINUATION_EPOCH_V2"; -const CONTINUATION_GLOBAL_TAG: &[u8] = b"LAMBDAVM_CONTINUATION_GLOBAL_V1"; +const CONTINUATION_GLOBAL_TAG: &[u8] = b"LAMBDAVM_CONTINUATION_GLOBAL_V2"; /// Statement bound into the cross-epoch **global** proof's transcript before /// Phase A: the ELF (so the global proof is program-bound), the epoch count (so a /// global proof from a run with a different number of epochs cannot be spliced in), /// the private-input page count (so the global proof's AIR layout โ€” which touched pages /// are built non-preprocessed โ€” is canonically pinned, like the monolithic path's -/// `absorb_statement`), and the touched page-base set (which GLOBAL_MEMORY tables exist). +/// `absorb_statement`), `fri_final_poly_log_degree` (which sets the FRI transcript +/// shape, exactly as the monolithic and epoch statements bind it), and the touched +/// page-base set (which GLOBAL_MEMORY tables exist). /// Prove and verify must call this with identical arguments. pub(crate) fn absorb_continuation_global_statement( t: &mut impl IsTranscript, elf_bytes: &[u8], num_epochs: usize, num_private_input_pages: usize, + fri_final_poly_log_degree: u8, touched_page_bases: &[u64], ) { t.append_bytes(CONTINUATION_GLOBAL_TAG); @@ -146,6 +149,9 @@ pub(crate) fn absorb_continuation_global_statement( t.append_bytes(&(num_epochs as u64).to_le_bytes()); t.append_bytes(&(num_private_input_pages as u64).to_le_bytes()); + // fri_final_poly_log_degree: single byte, no endianness concern. + t.append_bytes(&[fri_final_poly_log_degree]); + // Touched page-base set: count-prefixed, each fixed-width u64. Binds the exact set // (and order) of GLOBAL_MEMORY tables the verifier rebuilds, so a tampered list // diverges the challenges. Prover and verifier pass the identical canonical diff --git a/prover/src/tests/statement_tests.rs b/prover/src/tests/statement_tests.rs index 75f8fb8e3..d3dafc0c7 100644 --- a/prover/src/tests/statement_tests.rs +++ b/prover/src/tests/statement_tests.rs @@ -178,6 +178,7 @@ fn global_state( elf: &[u8], num_epochs: usize, num_private_input_pages: usize, + fri_final_poly_log_degree: u8, touched_page_bases: &[u64], ) -> [u8; 32] { let mut t = DefaultTranscript::::new(&[]); @@ -186,6 +187,7 @@ fn global_state( elf, num_epochs, num_private_input_pages, + fri_final_poly_log_degree, touched_page_bases, ); t.state() @@ -193,31 +195,36 @@ fn global_state( #[test] fn continuation_global_state_binds_program_epoch_count_pages_and_touched_set() { - let baseline = global_state(b"elf", 3, 1, &[0x1000, 0x2000]); - assert_eq!(baseline, global_state(b"elf", 3, 1, &[0x1000, 0x2000])); // deterministic + let baseline = global_state(b"elf", 3, 1, 7, &[0x1000, 0x2000]); + assert_eq!(baseline, global_state(b"elf", 3, 1, 7, &[0x1000, 0x2000])); // deterministic assert_ne!( baseline, - global_state(b"elf", 4, 1, &[0x1000, 0x2000]), + global_state(b"elf", 4, 1, 7, &[0x1000, 0x2000]), "must bind epoch count" ); assert_ne!( baseline, - global_state(b"other-elf", 3, 1, &[0x1000, 0x2000]), + global_state(b"other-elf", 3, 1, 7, &[0x1000, 0x2000]), "must bind the ELF" ); assert_ne!( baseline, - global_state(b"elf", 3, 2, &[0x1000, 0x2000]), + global_state(b"elf", 3, 2, 7, &[0x1000, 0x2000]), "must bind the private-input page count" ); assert_ne!( baseline, - global_state(b"elf", 3, 1, &[0x1000, 0x3000]), + global_state(b"elf", 3, 1, 8, &[0x1000, 0x2000]), + "must bind fri_final_poly_log_degree" + ); + assert_ne!( + baseline, + global_state(b"elf", 3, 1, 7, &[0x1000, 0x3000]), "must bind the touched page-base set" ); assert_ne!( baseline, - global_state(b"elf", 3, 1, &[0x1000]), + global_state(b"elf", 3, 1, 7, &[0x1000]), "must bind the touched page-base count" ); }