diff --git a/crypto/stark/src/proof/view.rs b/crypto/stark/src/proof/view.rs index 6eb8cedaf..85addd392 100644 --- a/crypto/stark/src/proof/view.rs +++ b/crypto/stark/src/proof/view.rs @@ -11,8 +11,8 @@ use crate::config::Commitment; use crate::frame::Frame; use crate::fri::fri_decommit::{ArchivedFriDecommitment, FriDecommitment}; use crate::proof::stark::{ - ArchivedDeepPolynomialOpening, ArchivedPolynomialOpenings, ArchivedStarkProof, - DeepPolynomialOpening, PolynomialOpenings, StarkProof, + ArchivedDeepPolynomialOpening, ArchivedMultiProof, ArchivedPolynomialOpenings, + ArchivedStarkProof, DeepPolynomialOpening, MultiProof, PolynomialOpenings, StarkProof, }; use crate::table::{ArchivedTable, Table, TableView}; use math::field::element::{ArchivedFieldElement, FieldElement}; @@ -481,6 +481,154 @@ where } } +/// Borrowed view over a [`MultiProof`] (owned or archived-in-place), +/// producing per-proof [`StarkProofView`]s without ever materializing an +/// owned `MultiProof` from an archive. Replaces the +/// `proofs.iter().map(StarkProofView::Owned/Archived).collect()` boilerplate +/// that used to appear at every `MultiProof` verify call site. +pub enum MultiProofView<'a, F: IsSubFieldOf, E: IsField, PI> +where + F::BaseType: math::field::element::NativeArchived, + E::BaseType: math::field::element::NativeArchived, + PI: rkyv::Archive, + ::Archived: rkyv::Deserialize, +{ + Owned(&'a MultiProof), + Archived(&'a ArchivedMultiProof), +} + +impl<'a, F: IsSubFieldOf, E: IsField, PI> Clone for MultiProofView<'a, F, E, PI> +where + F::BaseType: math::field::element::NativeArchived, + E::BaseType: math::field::element::NativeArchived, + PI: rkyv::Archive, + ::Archived: rkyv::Deserialize, +{ + fn clone(&self) -> Self { + *self + } +} +impl<'a, F: IsSubFieldOf, E: IsField, PI> Copy for MultiProofView<'a, F, E, PI> +where + F::BaseType: math::field::element::NativeArchived, + E::BaseType: math::field::element::NativeArchived, + PI: rkyv::Archive, + ::Archived: rkyv::Deserialize, +{ +} + +impl<'a, F: IsSubFieldOf, E: IsField, PI> MultiProofView<'a, F, E, PI> +where + F::BaseType: math::field::element::NativeArchived, + E::BaseType: math::field::element::NativeArchived, + PI: rkyv::Archive, + ::Archived: rkyv::Deserialize, +{ + #[inline(always)] + pub fn len(&self) -> usize { + match self { + Self::Owned(p) => p.proofs.len(), + Self::Archived(p) => p.proofs.len(), + } + } + + #[inline(always)] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + #[inline(always)] + pub fn get(&self, i: usize) -> StarkProofView<'a, F, E, PI> { + match self { + Self::Owned(p) => StarkProofView::Owned(&p.proofs[i]), + Self::Archived(p) => StarkProofView::Archived(&p.proofs.as_slice()[i]), + } + } + + #[inline(always)] + pub fn last(&self) -> Option> { + let len = self.len(); + (len > 0).then(|| self.get(len - 1)) + } + + #[inline(always)] + pub fn iter(&self) -> impl Iterator> + 'a { + let this = *self; + (0..this.len()).map(move |i| this.get(i)) + } +} + +/// A source of [`StarkProofView`]s the verifier can iterate over more than +/// once without ever materializing a `Vec` — implemented for a plain slice +/// (or `Vec`) of views and for [`MultiProofView`] alike, so +/// [`crate::verifier::IsStarkVerifier::multi_verify_views`] runs identically +/// whether its caller already had a slice or is reading straight out of a +/// (owned or archived) `MultiProof`. +pub trait ProofViewSource<'a, F: IsSubFieldOf + 'a, E: IsField + 'a, PI: 'a>: Copy +where + F::BaseType: math::field::element::NativeArchived, + E::BaseType: math::field::element::NativeArchived, + PI: rkyv::Archive, + ::Archived: rkyv::Deserialize, +{ + fn view_len(&self) -> usize; + fn view_iter(&self) -> impl Iterator>; +} + +impl<'a, F: IsSubFieldOf + 'a, E: IsField + 'a, PI: 'a> ProofViewSource<'a, F, E, PI> + for &'a [StarkProofView<'a, F, E, PI>] +where + F::BaseType: math::field::element::NativeArchived, + E::BaseType: math::field::element::NativeArchived, + PI: rkyv::Archive, + ::Archived: rkyv::Deserialize, +{ + #[inline(always)] + fn view_len(&self) -> usize { + self.len() + } + #[inline(always)] + fn view_iter(&self) -> impl Iterator> { + self.iter().copied() + } +} + +impl<'a, F: IsSubFieldOf + 'a, E: IsField + 'a, PI: 'a> ProofViewSource<'a, F, E, PI> + for &'a Vec> +where + F::BaseType: math::field::element::NativeArchived, + E::BaseType: math::field::element::NativeArchived, + PI: rkyv::Archive, + ::Archived: rkyv::Deserialize, +{ + #[inline(always)] + fn view_len(&self) -> usize { + self.len() + } + #[inline(always)] + fn view_iter(&self) -> impl Iterator> { + self.iter().copied() + } +} + +impl<'a, F: IsSubFieldOf + 'a, E: IsField + 'a, PI: 'a> ProofViewSource<'a, F, E, PI> + for MultiProofView<'a, F, E, PI> +where + F::BaseType: math::field::element::NativeArchived, + E::BaseType: math::field::element::NativeArchived, + PI: rkyv::Archive, + ::Archived: rkyv::Deserialize, +{ + #[inline(always)] + fn view_len(&self) -> usize { + MultiProofView::len(self) + } + #[inline(always)] + fn view_iter(&self) -> impl Iterator> { + MultiProofView::iter(self) + } +} + // --------------------------------------------------------------------------- // Field-coverage guards. // diff --git a/crypto/stark/src/tests/bus_tests/soundness_tests.rs b/crypto/stark/src/tests/bus_tests/soundness_tests.rs index 8327aafb2..157802cdf 100644 --- a/crypto/stark/src/tests/bus_tests/soundness_tests.rs +++ b/crypto/stark/src/tests/bus_tests/soundness_tests.rs @@ -1049,7 +1049,7 @@ fn test_malformed_ood_next_block_shape_rejected_archived() { assert!( !Verifier::multi_verify_archived( &airs, - &archived.proofs, + archived, &mut DefaultTranscript::::new(&[]), &FieldElement::zero(), ), @@ -1279,7 +1279,7 @@ fn test_gz_pruning_reduces_next_row_openings() { .unwrap(); assert!(Verifier::multi_verify_archived( &airs, - &archived.proofs, + archived, &mut DefaultTranscript::::new(&[]), &FieldElement::zero(), )); diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index ae26afbe9..64ae24363 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -10,10 +10,10 @@ use crate::{ config::Commitment, domain::new_verifier_domain, lookup::{BusPublicInputs, LOGUP_CHALLENGE_ALPHA, LOGUP_NUM_CHALLENGES, compute_alpha_powers}, - proof::stark::{ArchivedStarkProof, MultiProof}, + proof::stark::{ArchivedMultiProof, MultiProof}, proof::view::{ - DeepPolynomialOpeningView, FriDecommitmentView, PolynomialOpeningsView, StarkProofView, - StarkTableView, + DeepPolynomialOpeningView, FriDecommitmentView, MultiProofView, PolynomialOpeningsView, + ProofViewSource, StarkProofView, StarkTableView, }, table::Table, }; @@ -1095,19 +1095,19 @@ pub trait IsStarkVerifier< FieldElement: AsBytes + Sync + Send, FieldElement: AsBytes + Sync + Send, { - let views: Vec> = multi_proof - .proofs - .iter() - .map(StarkProofView::Owned) - .collect(); - Self::multi_verify_views(airs, &views, transcript, expected_bus_balance) + Self::multi_verify_views( + airs, + MultiProofView::Owned(multi_proof), + transcript, + expected_bus_balance, + ) } /// Verifies one or more rkyv-archived STARK proofs read **in place** from /// their archive buffer — no proof deserialization, no per-field allocation. fn multi_verify_archived( airs: &[&dyn AIR], - proofs: &[ArchivedStarkProof], + multi_proof: &ArchivedMultiProof, transcript: &mut (impl IsStarkTranscript + Clone), expected_bus_balance: &FieldElement, ) -> bool @@ -1115,29 +1115,35 @@ pub trait IsStarkVerifier< FieldElement: AsBytes + Sync + Send, FieldElement: AsBytes + Sync + Send, { - let views: Vec> = - proofs.iter().map(StarkProofView::Archived).collect(); - Self::multi_verify_views(airs, &views, transcript, expected_bus_balance) + Self::multi_verify_views( + airs, + MultiProofView::Archived(multi_proof), + transcript, + expected_bus_balance, + ) } /// The single verification implementation, shared by [`Self::multi_verify`] /// (owned) and [`Self::multi_verify_archived`] (archived), operating on /// proof views rather than either's concrete type. - fn multi_verify_views( + fn multi_verify_views<'p>( airs: &[&dyn AIR], - proofs: &[StarkProofView], + proofs: impl ProofViewSource<'p, Field, FieldExtension, PI>, transcript: &mut (impl IsStarkTranscript + Clone), expected_bus_balance: &FieldElement, ) -> bool where + Field: 'p, + FieldExtension: 'p, + PI: 'p, FieldElement: AsBytes + Sync + Send, FieldElement: AsBytes + Sync + Send, { - if airs.len() != proofs.len() { + if airs.len() != proofs.view_len() { error!( "AIR count ({}) does not match proof count ({})", airs.len(), - proofs.len() + proofs.view_len() ); return false; } @@ -1151,8 +1157,7 @@ pub trait IsStarkVerifier< // For preprocessed tables, use the hardcoded commitment (verifier cannot // trust the prover). For normal tables, use the commitment from the proof. - for (idx, (air, proof)) in airs.iter().zip(proofs).enumerate() { - let proof = *proof; + for (idx, (air, proof)) in airs.iter().zip(proofs.view_iter()).enumerate() { // Soundness: the number of composition-poly parts is fixed by the AIR's // degree bound, NOT chosen by the prover. Deriving it from the proof would // let a malicious prover inflate the part count, widening the composition @@ -1229,8 +1234,7 @@ pub trait IsStarkVerifier< // boundary constraints on LogUp columns, so the bus balance check is // the only cross-table validation. - for (idx, (air, proof)) in airs.iter().zip(proofs).enumerate() { - let proof = *proof; + for (idx, (air, proof)) in airs.iter().zip(proofs.view_iter()).enumerate() { if air.has_trace_interaction() && !proof.has_bus_public_inputs() { error!( "Table {idx}: AIR has LogUp interactions but proof is missing bus_public_inputs" @@ -1252,8 +1256,7 @@ pub trait IsStarkVerifier< // state after Phase B, domain-separated by table index). This matches // the prover's forking and makes per-table verification independent. - for (idx, (air, proof)) in airs.iter().zip(proofs).enumerate() { - let proof = *proof; + for (idx, (air, proof)) in airs.iter().zip(proofs.view_iter()).enumerate() { // Must match prover: fork with domain separator for multi-table, // use original transcript directly for single-table. let num_tables = airs.len(); @@ -1309,7 +1312,7 @@ pub trait IsStarkVerifier< if needs_lookup_challenges { let mut total = FieldElement::::zero(); - for (air, proof) in airs.iter().zip(proofs) { + for (air, proof) in airs.iter().zip(proofs.view_iter()) { if air.has_trace_interaction() && let Some(contribution) = proof.bus_table_contribution() { @@ -1345,7 +1348,7 @@ pub trait IsStarkVerifier< { Self::multi_verify_views( &[air], - &[StarkProofView::Owned(proof)], + &[StarkProofView::Owned(proof)][..], transcript, &FieldElement::zero(), ) diff --git a/prover/src/continuation.rs b/prover/src/continuation.rs index 0f10a24d4..169cd7278 100644 --- a/prover/src/continuation.rs +++ b/prover/src/continuation.rs @@ -58,6 +58,7 @@ use stark::constraints::builder::{ConstraintBuilder, ConstraintSet, EmptyConstra use stark::lookup::{AirWithBuses, AuxiliaryTraceBuildData, NullBoundaryConstraintBuilder}; use stark::proof::options::ProofOptions; use stark::proof::stark::MultiProof; +use stark::proof::view::MultiProofView; use stark::prover::{IsStarkProver, Prover}; use stark::trace::TraceTable; use stark::traits::AIR; @@ -72,7 +73,7 @@ use crate::tables::types::{GoldilocksExtension, GoldilocksField}; use crate::tables::{MaxRowsConfig, global_memory}; use crate::{ Error, FIXED_TABLE_COUNT, RuntimePageRange, TableCounts, VmAirs, - compute_expected_commit_bus_balance, verify_l2g_commitment_binding, + compute_expected_commit_bus_balance_view, verify_l2g_commitment_binding_view, }; type F = GoldilocksField; @@ -154,7 +155,7 @@ impl ConstraintSet for L2gMemoryConstraints { /// Uses the `EmptyConstraints` set deliberately: the MU boolean (`MU·(1-MU)=0`), the /// column range checks, and the `init_epoch < fini_epoch` ordering are NOT /// re-asserted here. They are enforced once in the epoch proof's `l2g_memory_air`, -/// and `verify_l2g_commitment_binding` ties this global L2G sub-table to the *same* +/// and `verify_l2g_commitment_binding_view` ties this global L2G sub-table to the *same* /// committed trace (equal Merkle roots). So under collision resistance the trace the /// global bus runs over already satisfies all those constraints — do not add them /// here (it would be redundant, not a missing check). @@ -405,7 +406,7 @@ struct EpochProof { /// register binding. x254 (commit index) rides along at address 508. reg_fini: Vec, /// The committed L2G table root, tied to the global proof by - /// [`verify_l2g_commitment_binding`]. + /// [`verify_l2g_commitment_binding_view`]. l2g_root: Commitment, } @@ -446,6 +447,142 @@ impl ContinuationProof { } } +/// Borrowed view over an [`EpochProof`] (owned or archived-in-place). Lets +/// `verify_epoch` take a single argument again instead of the field-by-field +/// parameter list the owned/archived split used to force on every caller: +/// each accessor reads straight off whichever representation is behind it, a +/// plain field copy on the owned side and (for the small metadata fields) an +/// `rkyv::deserialize` on the archived side. +#[derive(Clone, Copy)] +enum EpochProofView<'a> { + Owned(&'a EpochProof), + Archived(&'a ArchivedEpochProof), +} + +impl<'a> EpochProofView<'a> { + /// The epoch's STARK proof (its tables + the epoch-local L2G sub-table + /// last), as a [`MultiProofView`] — never materialized into an owned + /// `MultiProof` on the archived side. + fn proof(&self) -> MultiProofView<'a, F, E, ()> { + match self { + Self::Owned(e) => MultiProofView::Owned(&e.proof), + Self::Archived(e) => MultiProofView::Archived(&e.proof), + } + } + + /// Bytes this epoch committed (zero-copy borrow either way). + fn public_output(&self) -> &'a [u8] { + match self { + Self::Owned(e) => &e.public_output, + Self::Archived(e) => e.public_output.as_slice(), + } + } + + fn table_counts(&self) -> Result { + match self { + Self::Owned(e) => Ok(e.table_counts.clone()), + Self::Archived(e) => { + rkyv::deserialize::(&e.table_counts).map_err( + |err| Error::Execution(format!("rkyv deserialize table_counts failed: {err}")), + ) + } + } + } + + /// Always empty for continuation epochs (PAGE is skipped); still routed + /// through the archive rather than assumed, so a malformed non-empty + /// bundle value surfaces instead of being silently ignored. + fn runtime_page_ranges(&self) -> Result, Error> { + match self { + Self::Owned(e) => Ok(e.runtime_page_ranges.clone()), + Self::Archived(e) => rkyv::deserialize::, rkyv::rancor::Error>( + &e.runtime_page_ranges, + ) + .map_err(|err| Error::Execution(format!("rkyv deserialize page ranges failed: {err}"))), + } + } + + /// Length of `reg_fini` without materializing it — used for the + /// up-front malformed-bundle check, which only needs the count. + fn reg_fini_len(&self) -> usize { + match self { + Self::Owned(e) => e.reg_fini.len(), + Self::Archived(e) => e.reg_fini.len(), + } + } + + fn reg_fini(&self) -> Result, Error> { + match self { + Self::Owned(e) => Ok(e.reg_fini.clone()), + Self::Archived(e) => rkyv::deserialize::, rkyv::rancor::Error>(&e.reg_fini) + .map_err(|err| { + Error::Execution(format!("rkyv deserialize reg_fini failed: {err}")) + }), + } + } + + fn l2g_root(&self) -> Commitment { + match self { + Self::Owned(e) => e.l2g_root, + Self::Archived(e) => e.l2g_root, + } + } +} + +/// Borrowed view over a [`ContinuationProof`] (owned or archived-in-place), +/// mirroring [`EpochProofView`] one level up. Lets +/// [`verify_continuation_with_roots`] and [`verify_continuation_archived`] +/// share one implementation ([`verify_continuation_view`]) instead of two +/// near-duplicate ~130-line bodies. +#[derive(Clone, Copy)] +enum ContinuationProofView<'a> { + Owned(&'a ContinuationProof), + Archived(&'a ArchivedContinuationProof), +} + +impl<'a> ContinuationProofView<'a> { + fn num_epochs(&self) -> usize { + match self { + Self::Owned(c) => c.epochs.len(), + Self::Archived(c) => c.epochs.len(), + } + } + + fn epoch(&self, i: usize) -> EpochProofView<'a> { + match self { + Self::Owned(c) => EpochProofView::Owned(&c.epochs[i]), + Self::Archived(c) => EpochProofView::Archived(&c.epochs.as_slice()[i]), + } + } + + fn epochs(&self) -> impl Iterator> { + let this = *self; + (0..this.num_epochs()).map(move |i| this.epoch(i)) + } + + /// The one cross-epoch global-memory proof, as a [`MultiProofView`]. + fn global(&self) -> MultiProofView<'a, F, E, ()> { + match self { + Self::Owned(c) => MultiProofView::Owned(&c.global), + Self::Archived(c) => MultiProofView::Archived(&c.global), + } + } + + fn num_private_input_pages(&self) -> usize { + match self { + Self::Owned(c) => c.num_private_input_pages, + Self::Archived(c) => c.num_private_input_pages.to_native() as usize, + } + } + + fn touched_page_bases(&self) -> Vec { + match self { + Self::Owned(c) => c.touched_page_bases.clone(), + Self::Archived(c) => c.touched_page_bases.iter().map(|v| v.to_native()).collect(), + } + } +} + /// Build an epoch's AIRs identically on the prove and verify sides — the single /// source of truth for the AIR set, so the two halves can never diverge. The set /// is `VmAirs` (HALT included iff `is_final`), with REGISTER preprocessed to @@ -579,28 +716,33 @@ fn prove_epoch( }) } -/// Verify one epoch using ONLY the [`EpochProof`] bundle plus the verifier-derived -/// `register_init` (epoch 0: from the ELF; epoch i>0: from the previous epoch's -/// `reg_fini`), `is_final`, and `label`. Rebuilds the AIRs and transcript -/// from the bundle's statement values and indexes commits from the carried x254 -/// (`register_init[X254_INDEX]`), never from the prover's memory. PAGE is skipped for -/// continuation epochs, so the AIRs are built with no page configs (the bundle does -/// not get to supply any). Returns `true` iff the proof verifies and its committed -/// L2G root matches the claimed one. +/// Verify one epoch using ONLY the epoch's public statement fields (via +/// [`EpochProofView`]) plus the verifier-derived `register_init` (epoch 0: +/// from the ELF; epoch i>0: from the previous epoch's `reg_fini`), `is_final`, +/// and `label`. Rebuilds the AIRs and transcript from the bundle's statement +/// values and indexes commits from the carried x254 +/// (`register_init[X254_INDEX]`), never from the prover's memory. PAGE is +/// skipped for continuation epochs, so the AIRs are built with no page configs +/// (the bundle does not get to supply any). Returns `Ok(true)` iff the proof +/// verifies and its committed L2G root matches the claimed one; `Err` iff a +/// small metadata field failed to materialize off an archived bundle. +/// +/// `epoch` is zero-copy either way: owned or archived (see the two callers). #[allow(clippy::too_many_arguments)] fn verify_epoch( elf: &Elf, elf_bytes: &[u8], - epoch: &EpochProof, + epoch: EpochProofView<'_>, register_init: &[u32], is_final: bool, label: u64, opts: &ProofOptions, decode_commitment: Option, -) -> bool { +) -> Result { + let table_counts = epoch.table_counts()?; // Reject degenerate table counts (mirrors the monolithic verifier). - if epoch.table_counts.validate().is_err() { - return false; + if table_counts.validate().is_err() { + return Ok(false); } // Cross-check table_counts before building AIRs from bundle data. Continuation @@ -611,18 +753,23 @@ fn verify_epoch( } else { FIXED_TABLE_COUNT - 1 }; - let expected_proof_count = epoch.table_counts.total() + fixed_tables + 1; - if expected_proof_count != epoch.proof.proofs.len() { - return false; + let proof = epoch.proof(); + let expected_proof_count = table_counts.total() + fixed_tables + 1; + if expected_proof_count != proof.len() { + return Ok(false); } + let reg_fini = epoch.reg_fini()?; + let runtime_page_ranges = epoch.runtime_page_ranges()?; + let public_output = epoch.public_output(); + let airs = build_epoch_airs( elf, opts, &[], - &epoch.table_counts, + &table_counts, register_init, - &epoch.reg_fini, + ®_fini, is_final, decode_commitment, ); @@ -633,9 +780,9 @@ fn verify_epoch( let seed = || { epoch_transcript( elf_bytes, - &epoch.public_output, - &epoch.table_counts, - &epoch.runtime_page_ranges, + public_output, + &table_counts, + &runtime_page_ranges, label, opts.fri_final_poly_log_degree, ) @@ -648,29 +795,24 @@ fn verify_epoch( .copied() .unwrap_or(0) as u64; - let expected = match compute_expected_commit_bus_balance( + let expected = match compute_expected_commit_bus_balance_view( &refs, - &epoch.proof, - &epoch.public_output, + proof, + public_output, commit_start_index, &mut seed(), ) { Some(expected) => expected, - None => return false, + None => return Ok(false), }; - if !Verifier::multi_verify(&refs, &epoch.proof, &mut seed(), &expected) { - return false; + if !Verifier::multi_verify_views(&refs, proof, &mut seed(), &expected) { + return Ok(false); } // The claimed L2G root must be the one this proof actually committed (it is what - // verify_l2g_commitment_binding later ties to the global proof). - epoch - .proof - .proofs - .last() - .map(|p| p.lde_trace_main_merkle_root) - == Some(epoch.l2g_root) + // verify_l2g_commitment_binding_view later ties to the global proof). + Ok(proof.last().map(|p| *p.lde_trace_main_merkle_root()) == Some(epoch.l2g_root())) } /// Build the cross-epoch global memory proof: every epoch's L2G sub-table on the @@ -755,7 +897,7 @@ fn prove_global( fn verify_global( num_epochs: usize, page_bases: &[u64], - proof: &MultiProof, + proof: MultiProofView<'_, F, E, ()>, elf: &Elf, elf_bytes: &[u8], num_private_input_pages: usize, @@ -823,7 +965,7 @@ fn verify_global( refs.push(air as AirRef); } - Verifier::multi_verify( + Verifier::multi_verify_views( &refs, proof, &mut global_transcript( @@ -1037,22 +1179,68 @@ pub fn verify_continuation_with_roots( decode_commitment: Option, page_genesis_commitments: Option<&[(u64, Commitment)]>, ) -> Result>, Error> { + let result = verify_continuation_view( + ContinuationProofView::Owned(bundle), + elf_bytes, + opts, + decode_commitment, + page_genesis_commitments, + )?; + Ok(result.map(|(public_output, _entry_point)| public_output)) +} + +/// [`verify_continuation_with_roots`]'s zero-copy counterpart, for the +/// recursion `continuation` guest: reads every per-epoch/global proof in +/// place via [`ContinuationProofView::Archived`] instead of deserializing an +/// owned [`MultiProof`]. Only small per-epoch metadata is materialized. Roots +/// are always supplied here (the guest never recomputes from the ELF in-VM). +/// +/// Also returns `entry_point` so callers can fold a `program_id` via +/// [`crate::recursion::program_id_from_digest`] without a second `Elf::load`. +pub(crate) fn verify_continuation_archived( + archived: &ArchivedContinuationProof, + elf_bytes: &[u8], + opts: &ProofOptions, + decode_commitment: Commitment, + page_genesis_commitments: &[(u64, Commitment)], +) -> Result, u64)>, Error> { + verify_continuation_view( + ContinuationProofView::Archived(archived), + elf_bytes, + opts, + Some(decode_commitment), + Some(page_genesis_commitments), + ) +} + +/// Shared implementation behind [`verify_continuation_with_roots`] (owned) and +/// [`verify_continuation_archived`] (archived), operating on a +/// [`ContinuationProofView`] rather than either's concrete type — the same +/// split [`crate::verify_recursion_blob`] uses for the monolithic path. +/// Returns the public output plus `entry_point` (see [`verify_continuation_archived`]). +fn verify_continuation_view( + bundle: ContinuationProofView<'_>, + elf_bytes: &[u8], + opts: &ProofOptions, + decode_commitment: Option, + page_genesis_commitments: Option<&[(u64, Commitment)]>, +) -> Result, u64)>, Error> { // Bound the claimed private-input page count before using it to size/allocate AIRs // (mirrors `verify_with_options`). The count is also bound into the global proof's // Fiat-Shamir statement (`absorb_continuation_global_statement`), so any wrong value // diverges the verifier's challenges and `verify_global`'s `multi_verify` rejects — // on top of the committed-AIR-shape mismatch a wrong count causes on a touched page. let max_private_input_pages = page::max_private_input_pages(); - if bundle.num_private_input_pages > max_private_input_pages { + let num_private_input_pages = bundle.num_private_input_pages(); + if num_private_input_pages > max_private_input_pages { return Err(Error::InvalidTableCounts(format!( - "num_private_input_pages ({}) exceeds max ({max_private_input_pages})", - bundle.num_private_input_pages + "num_private_input_pages ({num_private_input_pages}) exceeds max ({max_private_input_pages})", ))); } let elf = Elf::load(elf_bytes).map_err(|e| Error::ElfLoad(format!("{e}")))?; - let n = bundle.epochs.len(); + let n = bundle.num_epochs(); if n == 0 { return Ok(None); } @@ -1060,11 +1248,11 @@ pub fn verify_continuation_with_roots( // Reject a malformed bundle up front. `reg_fini` is prover-supplied (deserialized, // untrusted) and is indexed by `NUM_REGISTER_ADDRESSES` when building each epoch's // preprocessed REGISTER commitment, so a wrong length would otherwise panic the - // verifier instead of cleanly rejecting the proof. + // verifier instead of cleanly rejecting the proof. Only the length is read here + // (no materialization) — the values are only needed once we actually verify. if bundle - .epochs - .iter() - .any(|e| e.reg_fini.len() != register::NUM_REGISTER_ADDRESSES) + .epochs() + .any(|e| e.reg_fini_len() != register::NUM_REGISTER_ADDRESSES) { return Ok(None); } @@ -1074,9 +1262,11 @@ pub fn verify_continuation_with_roots( let mut epoch_roots: Vec = Vec::with_capacity(n); let mut public_output: Vec = Vec::new(); - for (index, epoch) in bundle.epochs.iter().enumerate() { + for (index, epoch) in bundle.epochs().enumerate() { let is_final = index == n - 1; let label = local_to_global::epoch_label(index as u64); + let l2g_root = epoch.l2g_root(); + let epoch_public_output = epoch.public_output(); if !verify_epoch( &elf, @@ -1087,15 +1277,15 @@ pub fn verify_continuation_with_roots( label, opts, decode_commitment, - ) { + )? { return Ok(None); } - epoch_roots.push(epoch.l2g_root); - public_output.extend_from_slice(&epoch.public_output); + epoch_roots.push(l2g_root); + public_output.extend_from_slice(epoch_public_output); // Next epoch's init is this epoch's bound fini — the cross-epoch register // (and x254) binding. A mismatched fini desyncs the next epoch's AIRs. - register_init = epoch.reg_fini.clone(); + register_init = epoch.reg_fini()?; } // Cross-epoch global memory: genesis for ELF/runtime pages is rebuilt FROM THE ELF @@ -1107,7 +1297,8 @@ pub fn verify_continuation_with_roots( // touched page-base set (never cell values); the bundle carries the latter directly. // Canonicalize the (untrusted) list so a shuffled-but-same-set list still verifies, // while a different set fails via GlobalMemory-bus imbalance / AIR-count mismatch. - let page_bases = canonical_page_bases(&bundle.touched_page_bases); + let touched_page_bases = bundle.touched_page_bases(); + let page_bases = canonical_page_bases(&touched_page_bases); // Every honest base is produced by `page::page_base_for_address`, so it is page-aligned; a // non-aligned base is only reachable via a hand-crafted bundle. Left unchecked, such a base // still falls in the private-input range (`page::is_private_input_page`), so it would be @@ -1137,13 +1328,14 @@ pub fn verify_continuation_with_roots( "page_genesis_commitments contains a non-page-aligned entry".to_string(), )); } + let global_proof = bundle.global(); if !verify_global( n, &page_bases, - &bundle.global, + global_proof, &elf, elf_bytes, - bundle.num_private_input_pages, + num_private_input_pages, opts, page_genesis_commitments, ) { @@ -1151,11 +1343,11 @@ pub fn verify_continuation_with_roots( } // Each epoch's committed L2G table is the same one the global proof used. - if !verify_l2g_commitment_binding(&epoch_roots, &bundle.global) { + if !verify_l2g_commitment_binding_view(&epoch_roots, global_proof) { return Ok(None); } - Ok(Some(public_output)) + Ok(Some((public_output, elf.entry_point))) } /// Precompute the ELF-derived roots [`verify_continuation_with_roots`] accepts: @@ -1966,10 +2158,11 @@ mod tests { ); } - // Negative: corrupting an epoch's claimed L2G table root must be rejected — - // `verify_l2g_commitment_binding` compares each epoch's `l2g_root` against the - // corresponding sub-proof root in the global proof, so a mismatched root causes - // the binding to fail. Guards the L2G root↔global commitment binding. + // Negative: corrupting an epoch's claimed L2G table root must be rejected. This + // tamper is caught by `verify_epoch`'s own root-consistency check (the epoch's + // claimed `l2g_root` no longer matches what its own proof committed) before the + // cross-epoch `verify_l2g_commitment_binding_view` ever runs — see + // `test_split_verify_rejects_global_proof_from_a_different_run` for that. #[test] fn test_split_verify_rejects_tampered_l2g_root() { let _ = env_logger::builder().is_test(true).try_init(); @@ -1987,4 +2180,124 @@ mod tests { .is_none() ); } + + // Same tamper as `test_split_verify_rejects_tampered_l2g_root`, but through the + // zero-copy blob path (`verify_continuation_and_attest`) rather than + // `verify_continuation`. Guards the archived path's per-epoch root check against + // the same corruption the owned path already catches. + #[test] + fn test_continuation_blob_rejects_tampered_l2g_root() { + let _ = env_logger::builder().is_test(true).try_init(); + let elf_bytes = asm_elf_bytes("all_loadstore_32"); + let mut bundle = + prove_continuation(&elf_bytes, &[], 3, &crate::recursion::MIN_PROOF_OPTIONS).unwrap(); + assert!( + bundle.epochs.len() >= 2, + "need multiple epochs to exercise the binding" + ); + bundle.epochs[0].l2g_root[0] ^= 0xFF; + + let blob = crate::recursion::encode_continuation_guest_input( + bundle, + &elf_bytes, + &crate::recursion::MIN_PROOF_OPTIONS, + ) + .expect("encode_continuation_guest_input failed"); + + let result = crate::recursion::verify_continuation_and_attest( + &blob, + &crate::recursion::MIN_PROOF_OPTIONS, + ) + .expect("verify_continuation_and_attest errored"); + assert!( + result.is_none(), + "a tampered l2g_root must be rejected over the archived blob path too" + ); + } + + // Negative: `verify_l2g_commitment_binding_view`'s own reject branch, which the two + // tests above don't reach (they're caught earlier by `verify_epoch`'s per-epoch root + // check). Two bundles proved from the same ELF/epoch size with different + // same-length private inputs share every shape value (`n`, `table_counts`, + // `touched_page_bases`, `num_private_input_pages`) but commit different actual L2G + // data, so splicing one's `global` proof onto the other's epochs leaves every + // per-epoch check and `verify_global`'s own `multi_verify` passing (each half is + // independently valid for that exact shape) while the per-epoch claimed roots no + // longer match what the spliced-in global proof's L2G sub-tables actually commit. + #[test] + fn test_split_verify_rejects_global_proof_from_a_different_run() { + let _ = env_logger::builder().is_test(true).try_init(); + let elf_bytes = asm_elf_bytes("test_private_input_xpage"); + let opts = ProofOptions::default_test_options(); + + let input_a: Vec = (0u8..16).collect(); + let input_b: Vec = (0u8..16).map(|b| b ^ 0xFF).collect(); + + let mut bundle_a = prove_continuation(&elf_bytes, &input_a, 2, &opts).unwrap(); + let bundle_b = prove_continuation(&elf_bytes, &input_b, 2, &opts).unwrap(); + assert!( + verify_continuation(&elf_bytes, &bundle_a, &opts) + .unwrap() + .is_some(), + "bundle_a must verify standalone before splicing" + ); + assert!( + verify_continuation(&elf_bytes, &bundle_b, &opts) + .unwrap() + .is_some(), + "bundle_b must verify standalone before splicing" + ); + assert_eq!( + bundle_a.epochs.len(), + bundle_b.epochs.len(), + "same ELF/epoch size/input length must yield the same epoch split" + ); + assert_eq!( + bundle_a.touched_page_bases, bundle_b.touched_page_bases, + "same-length private inputs must touch the same pages" + ); + assert_ne!( + bundle_a.epochs[0].l2g_root, bundle_b.epochs[0].l2g_root, + "different private-input bytes must commit different L2G data" + ); + + bundle_a.global = bundle_b.global; + + assert!( + verify_continuation(&elf_bytes, &bundle_a, &opts) + .unwrap() + .is_none(), + "a global proof spliced in from a different run must be rejected" + ); + } + + // Same construction as `test_split_verify_rejects_global_proof_from_a_different_run`, + // but through the zero-copy blob path — guards + // `verify_l2g_commitment_binding_view`'s archived call site. + #[test] + fn test_continuation_blob_rejects_global_proof_from_a_different_run() { + let _ = env_logger::builder().is_test(true).try_init(); + let elf_bytes = asm_elf_bytes("test_private_input_xpage"); + let opts = crate::recursion::MIN_PROOF_OPTIONS; + + let input_a: Vec = (0u8..16).collect(); + let input_b: Vec = (0u8..16).map(|b| b ^ 0xFF).collect(); + + let mut bundle_a = prove_continuation(&elf_bytes, &input_a, 2, &opts).unwrap(); + let bundle_b = prove_continuation(&elf_bytes, &input_b, 2, &opts).unwrap(); + assert_eq!(bundle_a.epochs.len(), bundle_b.epochs.len()); + assert_eq!(bundle_a.touched_page_bases, bundle_b.touched_page_bases); + assert_ne!(bundle_a.epochs[0].l2g_root, bundle_b.epochs[0].l2g_root); + + bundle_a.global = bundle_b.global; + + let blob = crate::recursion::encode_continuation_guest_input(bundle_a, &elf_bytes, &opts) + .expect("encode_continuation_guest_input failed"); + let result = crate::recursion::verify_continuation_and_attest(&blob, &opts) + .expect("verify_continuation_and_attest errored"); + assert!( + result.is_none(), + "a global proof spliced in from a different run must be rejected over the archived blob path too" + ); + } } diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 77c534d48..ff9601bb4 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -66,7 +66,7 @@ use crate::test_utils::{ pub use stark::config::Commitment; pub use stark::proof::options::{GoldilocksCubicProofOptions, ProofOptions}; use stark::proof::stark::MultiProof; -use stark::proof::view::StarkProofView; +use stark::proof::view::{MultiProofView, ProofViewSource}; /// A run-length encoded range of contiguous zero-initialized 4KB pages. /// @@ -267,6 +267,39 @@ pub fn recursion_archive_bytes(blob: &[u8]) -> Option<&[u8]> { Some(&blob[RECURSION_INPUT_PREFIX_LEN..]) } +/// Validate a recursion-input blob's wire prefix and bytecheck-validate its +/// archive, in place. Shared by [`verify_recursion_blob`] and +/// [`crate::recursion::verify_continuation_and_attest`]. +/// +/// Returns the archived value, the original (possibly-unaligned) archive +/// bytes, and the base pointer `archived` reads from — callers rebasing +/// zero-copy subslices back onto `blob` need that base. +pub(crate) fn access_recursion_archive<'s, 'a: 's, T>( + blob: &'a [u8], + aligned_fallback: &'s mut rkyv::util::AlignedVec, +) -> Result<(&'s T, &'a [u8], *const u8), Error> +where + T: rkyv::Portable + + for<'b> rkyv::bytecheck::CheckBytes>, +{ + let archive_bytes: &'a [u8] = recursion_archive_bytes(blob) + .ok_or_else(|| Error::Execution(String::from("recursion blob: bad magic or version")))?; + + let archive: &'s [u8] = + if (archive_bytes.as_ptr() as usize).is_multiple_of(RECURSION_INPUT_ALIGN) { + archive_bytes + } else { + aligned_fallback.extend_from_slice(archive_bytes); + aligned_fallback + }; + let archive_base = archive.as_ptr(); + + let archived: &'s T = rkyv::access::(archive).map_err(|e| { + Error::Execution(format!("recursion blob: bytecheck validation failed: {e}")) + })?; + Ok((archived, archive_bytes, archive_base)) +} + /// Result of a recursion-blob verification: the verdict plus the inner /// proof's committed public output (zero-copy from the blob), which the /// recursion guest folds into `program_id(...) ‖ public_output`. @@ -310,31 +343,15 @@ pub fn verify_recursion_blob<'a>( ) -> Result, Error> { use rkyv::rancor::Error as RkyvError; - // Validate + strip the aligning magic/version prefix. In the guest the - // returned slice starts at the 16-aligned archive base (the prefix exists - // precisely so the archive lands aligned at + // In the guest the blob's archive starts at the 16-aligned archive base + // (the wire prefix exists precisely so the archive lands aligned at // `PRIVATE_INPUT_START + 4 + PREFIX_LEN`), so the in-place doubleword - // loads do not trap. - let archive_bytes = recursion_archive_bytes(blob) - .ok_or_else(|| Error::Execution(String::from("recursion blob: bad magic or version")))?; - - // A host caller's buffer carries no alignment guarantee (`Vec` is - // align-1) — in-place access there would be UB. Fall back to one aligned - // copy when the base is misaligned; the guest path is aligned by - // construction and stays zero-copy. - let mut aligned_fallback = rkyv::util::AlignedVec::<{ RECURSION_INPUT_ALIGN }>::new(); - let archive: &[u8] = if (archive_bytes.as_ptr() as usize).is_multiple_of(RECURSION_INPUT_ALIGN) - { - archive_bytes - } else { - aligned_fallback.extend_from_slice(archive_bytes); - &aligned_fallback - }; - - // `blob` is untrusted; validate before the zero-copy access. - let archived = rkyv::access::(archive).map_err(|e| { - Error::Execution(format!("recursion blob: bytecheck validation failed: {e}")) - })?; + // loads do not trap. A host caller's buffer carries no such guarantee + // (`Vec` is align-1), so `access_recursion_archive` falls back to one + // aligned copy in `aligned_fallback` when the base is misaligned. + let mut aligned_fallback = rkyv::util::AlignedVec::::new(); + let (archived, archive_bytes, archive_base): (&ArchivedGuestInput, &[u8], *const u8) = + access_recursion_archive(blob, &mut aligned_fallback)?; // Materialize only the small metadata; the proof stays in the buffer. let table_counts: TableCounts = @@ -356,11 +373,11 @@ pub fn verify_recursion_blob<'a>( let public_output: &[u8] = archived.vm_proof.public_output.as_slice(); let decode_commitment: Commitment = archived.decode_commitment; - // Rebase the returned slices onto the caller's buffer: `archive` may be - // the aligned fallback copy, whose lifetime ends with this call. Same - // bytes at the same offsets in both buffers. + // Rebase the returned slices onto the caller's buffer: `archived` may + // point into the aligned fallback copy, whose lifetime ends with this + // call. Same bytes at the same offsets in both buffers. let rebase = |s: &[u8]| -> &'a [u8] { - let offset = s.as_ptr() as usize - archive.as_ptr() as usize; + let offset = s.as_ptr() as usize - archive_base as usize; &archive_bytes[offset..offset + s.len()] }; let inner_elf_rebased = rebase(inner_elf); @@ -372,16 +389,8 @@ pub fn verify_recursion_blob<'a>( let program = Elf::load(inner_elf).map_err(|e| Error::ElfLoad(format!("{e}")))?; let elf_digest = statement::elf_digest(inner_elf); - let views: Vec> = archived - .vm_proof - .proof - .proofs - .as_slice() - .iter() - .map(StarkProofView::Archived) - .collect(); let ok = verify_proof_parts( - &views, + MultiProofView::Archived(&archived.vm_proof.proof), &table_counts, &runtime_page_ranges, num_private_input_pages, @@ -872,26 +881,6 @@ impl VmAirs { // Bus Balance Target: Verifier-Computed COMMIT Output Bus // ============================================================================= -/// Replay the prover's Phase A (main trace commitments) to recover the shared -/// LogUp challenges (z, alpha). Creates a fresh transcript, appends all main -/// trace commitments in the same order as the prover, then samples two -/// challenge elements. -pub(crate) fn replay_transcript_phase_a( - airs: &[&dyn AIR], - multi_proof: &MultiProof, - transcript: &mut DefaultTranscript, -) -> (FieldElement, FieldElement) { - for (air, proof) in airs.iter().zip(&multi_proof.proofs) { - if air.is_preprocessed() { - transcript.append_bytes(&air.precomputed_commitment()); - } - transcript.append_bytes(&proof.lde_trace_main_merkle_root); - } - let z: FieldElement = transcript.sample_field_element(); - let alpha: FieldElement = transcript.sample_field_element(); - (z, alpha) -} - /// Compute the bus balance offset for the COMMIT[index, value] bus. /// /// For each public output byte at index `i` with value `v`: @@ -941,31 +930,15 @@ pub(crate) fn compute_commit_bus_offset( ) } -/// Compute the expected COMMIT bus balance for a `MultiProof`. -/// -/// Replays Phase A of the transcript to recover (z, alpha), then computes -/// the offset from the given public output bytes. Call this after `multi_prove` -/// and before `multi_verify`. -pub(crate) fn compute_expected_commit_bus_balance( - airs: &[&dyn AIR], - proof: &MultiProof, - public_output_bytes: &[u8], - start_index: u64, - transcript: &mut DefaultTranscript, -) -> Option> { - let (z, alpha) = replay_transcript_phase_a(airs, proof, transcript); - compute_commit_bus_offset(public_output_bytes, start_index, &z, &alpha) -} - -/// View counterpart of [`replay_transcript_phase_a`]: replays Phase A over a -/// proof view (owned or archived-in-place), with no `MultiProof` -/// deserialization required either way. -pub(crate) fn replay_transcript_phase_a_view( +/// Replay the prover's Phase A (main trace commitments) to recover the shared +/// LogUp challenges (z, alpha), over a proof view (owned or archived-in-place) +/// — no `MultiProof` deserialization required either way. +pub(crate) fn replay_transcript_phase_a_view<'p>( airs: &[&dyn AIR], - proofs: &[StarkProofView], + proofs: impl ProofViewSource<'p, F, E, ()>, transcript: &mut DefaultTranscript, ) -> (FieldElement, FieldElement) { - for (air, proof) in airs.iter().zip(proofs) { + for (air, proof) in airs.iter().zip(proofs.view_iter()) { if air.is_preprocessed() { transcript.append_bytes(&air.precomputed_commitment()); } @@ -976,11 +949,11 @@ pub(crate) fn replay_transcript_phase_a_view( (z, alpha) } -/// View counterpart of [`compute_expected_commit_bus_balance`]: operates on a -/// proof view slice (owned or archived-in-place). -pub(crate) fn compute_expected_commit_bus_balance_view( +/// Computes the expected COMMIT bus balance for a proof view slice (owned or +/// archived-in-place). +pub(crate) fn compute_expected_commit_bus_balance_view<'p>( airs: &[&dyn AIR], - proofs: &[StarkProofView], + proofs: impl ProofViewSource<'p, F, E, ()>, public_output_bytes: &[u8], start_index: u64, transcript: &mut DefaultTranscript, @@ -992,22 +965,25 @@ pub(crate) fn compute_expected_commit_bus_balance_view( /// Bind the final cross-epoch GlobalMemory proof to the per-epoch proofs. /// /// The final proof commits one local-to-global sub-table per epoch as its first -/// `N` tables, so `final_proof.proofs[i].lde_trace_main_merkle_root` is epoch +/// `N` tables, so `final_proof.get(i).lde_trace_main_merkle_root()` is epoch /// `i`'s L2G commitment. `epoch_l2g_roots[i]` is the same root as committed in /// epoch `i`'s own proof. Equal roots prove the cross-epoch matching ran over /// the very same L2G tables the epochs committed (shared commitments). /// -/// Called by `continuation::verify_continuation`; also exercised by the +/// `final_proof` is a [`MultiProofView`] (owned or archived-in-place), so this +/// reads straight off either representation with no `MultiProof` deserialization. +/// +/// Called by `continuation::verify_continuation_view`; also exercised by the /// local-to-global bus tests. -pub(crate) fn verify_l2g_commitment_binding( +pub(crate) fn verify_l2g_commitment_binding_view( epoch_l2g_roots: &[Commitment], - final_proof: &MultiProof, + final_proof: MultiProofView<'_, F, E, ()>, ) -> bool { - final_proof.proofs.len() >= epoch_l2g_roots.len() + final_proof.len() >= epoch_l2g_roots.len() && epoch_l2g_roots .iter() .enumerate() - .all(|(i, root)| final_proof.proofs[i].lde_trace_main_merkle_root == *root) + .all(|(i, root)| *final_proof.get(i).lde_trace_main_merkle_root() == *root) } // ============================================================================= @@ -1300,15 +1276,8 @@ pub(crate) fn verify_prepared( decode_commitment: Option, page_commitments: Option<&[(u64, Commitment)]>, ) -> Result { - let views: Vec> = vm_proof - .proof - .proofs - .iter() - .map(StarkProofView::Owned) - .collect(); - verify_proof_parts( - &views, + MultiProofView::Owned(&vm_proof.proof), &vm_proof.table_counts, &vm_proof.runtime_page_ranges, vm_proof.num_private_input_pages, @@ -1324,12 +1293,12 @@ pub(crate) fn verify_prepared( /// The single VM-proof verification implementation, given the proof's /// metadata fields plus an already-parsed ELF and its digest. Both /// [`verify_prepared`] (owned proof) and [`verify_recursion_blob`] (guest -/// blob, zero-copy) funnel here, passing a [`StarkProofView`] slice over -/// their respective (owned or archived) proof data — no serialization, no +/// blob, zero-copy) funnel here, passing a [`MultiProofView`] over their +/// respective (owned or archived) proof data — no serialization, no /// duplicated verification logic, and no repeated `Elf::load`/digest. #[allow(clippy::too_many_arguments)] fn verify_proof_parts( - proofs: &[StarkProofView], + proofs: MultiProofView<'_, F, E, ()>, table_counts: &TableCounts, runtime_page_ranges: &[RuntimePageRange], num_private_input_pages: usize, diff --git a/prover/src/recursion.rs b/prover/src/recursion.rs index 6bb3b1247..654b58fa4 100644 --- a/prover/src/recursion.rs +++ b/prover/src/recursion.rs @@ -223,6 +223,116 @@ pub fn verify_and_attest_blob( Ok(Some(attestation)) } +/// The continuation guest's private-input layout (the `continuation` guest +/// feature). Mirrors [`crate::GuestInput`] with the monolithic proof replaced +/// by the bundle and the PAGE roots replaced by the global-memory genesis +/// roots (see [`crate::continuation::continuation_precomputed_commitments`]). +/// Rkyv-archived on the same magic-prefixed wire format as the monolithic +/// blob ([`crate::encode_recursion_input`]); the guest is feature-pinned to +/// one layout, and a blob of the other kind fails the bytecheck validation. +#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +pub struct ContinuationGuestInput { + pub bundle: crate::continuation::ContinuationProof, + pub inner_elf: Vec, + pub decode_commitment: Commitment, + pub page_commitments: Vec<(u64, Commitment)>, +} + +/// Build the continuation guest's private-input blob for `bundle` of +/// `inner_elf`: precomputes the roots and rkyv-encodes a +/// [`ContinuationGuestInput`] behind the standard aligning prefix. Takes the +/// bundle by value (it is large; the encoder is its last consumer). +pub fn encode_continuation_guest_input( + bundle: crate::continuation::ContinuationProof, + inner_elf: &[u8], + opts: &ProofOptions, +) -> Result, Error> { + let (decode_commitment, page_commitments) = + crate::continuation::continuation_precomputed_commitments(inner_elf, &bundle, opts)?; + let input = ContinuationGuestInput { + bundle, + inner_elf: inner_elf.to_vec(), + decode_commitment, + page_commitments, + }; + let archive = rkyv::to_bytes::(&input) + .map_err(|e| Error::Execution(format!("rkyv encode failed: {e}")))?; + let mut blob = Vec::with_capacity(crate::RECURSION_INPUT_PREFIX_LEN + archive.len()); + blob.extend_from_slice(&crate::RECURSION_INPUT_MAGIC); + blob.extend_from_slice(&crate::RECURSION_INPUT_VERSION.to_le_bytes()); + blob.extend_from_slice(&[0u8; 4]); // reserved + debug_assert_eq!(blob.len(), crate::RECURSION_INPUT_PREFIX_LEN); + blob.extend_from_slice(&archive); + Ok(blob) +} + +/// [`verify_and_attest_blob`]'s logic for a continuation bundle: takes the +/// wire-format blob ([`encode_continuation_guest_input`]) and does the +/// intended `continuation` guest's whole job in one call — verify every +/// epoch + the global memory proof against the supplied roots, then attest +/// `program_id(elf, roots) || public_output`. Uses the same [`program_id`] as +/// the monolithic path over the continuation's root set (DECODE + touched +/// data-page genesis roots), so a consumer re-binds with +/// [`crate::continuation::continuation_precomputed_commitments`] over the +/// bundle it holds — the touched-page set is bundle-dependent, unlike the +/// monolithic path's ELF-only page set. The archive is bytecheck-validated, +/// then verified zero-copy via +/// [`crate::continuation::verify_continuation_archived`] — no owned +/// deserialize of the (large) bundle, same as [`crate::verify_recursion_blob`] +/// for the monolithic proof. +pub fn verify_continuation_and_attest( + blob: &[u8], + proof_options: &ProofOptions, +) -> Result>, Error> { + use rkyv::rancor::Error as RkyvError; + + let archive_bytes = crate::recursion_archive_bytes(blob).ok_or_else(|| { + Error::Execution(String::from( + "continuation recursion blob: bad magic or version", + )) + })?; + // Host callers' Vec carries no alignment guarantee; the guest slice is + // aligned by construction (same prefix arithmetic as the monolithic blob). + let mut aligned_fallback = rkyv::util::AlignedVec::<{ crate::RECURSION_INPUT_ALIGN }>::new(); + let archive: &[u8] = + if (archive_bytes.as_ptr() as usize).is_multiple_of(crate::RECURSION_INPUT_ALIGN) { + archive_bytes + } else { + aligned_fallback.extend_from_slice(archive_bytes); + &aligned_fallback + }; + let archived = rkyv::access::(archive) + .map_err(|e| Error::Execution(format!("continuation blob validation failed: {e}")))?; + + // Only small metadata here; the bundle's proofs stay in the archive (read + // in place by `verify_continuation_archived`). + let page_commitments: Vec<(u64, Commitment)> = rkyv::deserialize::< + Vec<(u64, Commitment)>, + RkyvError, + >(&archived.page_commitments) + .map_err(|e| Error::Execution(format!("rkyv deserialize page commitments failed: {e}")))?; + let decode_commitment: Commitment = archived.decode_commitment; + let inner_elf: &[u8] = archived.inner_elf.as_slice(); + + let Some((public_output, entry_point)) = crate::continuation::verify_continuation_archived( + &archived.bundle, + inner_elf, + proof_options, + decode_commitment, + &page_commitments, + )? + else { + return Ok(None); + }; + + // Avoids a second `Elf::load` (already done by `verify_continuation_archived`). + let digest = elf_digest(inner_elf); + let id = program_id_from_digest(&digest, entry_point, &decode_commitment, &page_commitments); + let mut attestation = id.to_vec(); + attestation.extend_from_slice(&public_output); + Ok(Some(attestation)) +} + /// Split committed attestation bytes into `(program_id, inner_public_output)`. /// `None` if too short to contain an id. pub fn split_attestation(committed: &[u8]) -> Option<([u8; 32], &[u8])> { diff --git a/prover/src/tests/local_to_global_bus_tests.rs b/prover/src/tests/local_to_global_bus_tests.rs index 2234208df..8025596d6 100644 --- a/prover/src/tests/local_to_global_bus_tests.rs +++ b/prover/src/tests/local_to_global_bus_tests.rs @@ -18,6 +18,7 @@ use stark::lookup::{ }; use stark::proof::options::ProofOptions; use stark::proof::stark::MultiProof; +use stark::proof::view::MultiProofView; use stark::trace::TraceTable; use stark::traits::AIR; use stark::verifier::{IsStarkVerifier, Verifier}; @@ -537,7 +538,10 @@ fn test_l2g_binding_holds() { let final_proof = prove_global(&boundaries); let roots: Vec = boundaries.iter().map(|b| l2g_root(b)).collect(); - assert!(crate::verify_l2g_commitment_binding(&roots, &final_proof)); + assert!(crate::verify_l2g_commitment_binding_view( + &roots, + MultiProofView::Owned(&final_proof) + )); } #[test] @@ -560,7 +564,10 @@ fn test_l2g_binding_rejects_mismatch() { tampered[0][0].fini.value = 999; let final_proof = prove_global(&tampered); - assert!(!crate::verify_l2g_commitment_binding(&roots, &final_proof)); + assert!(!crate::verify_l2g_commitment_binding_view( + &roots, + MultiProofView::Owned(&final_proof) + )); } // ========================================================================= diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index 864e4e3f9..ffe9071b2 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -18,6 +18,7 @@ use math::field::element::FieldElement; use stark::constraints::builder::EmptyConstraints; use stark::lookup::{AirWithBuses, AuxiliaryTraceBuildData}; use stark::proof::options::ProofOptions; +use stark::proof::view::{MultiProofView, StarkProofView}; use stark::traits::AIR; use stark::verifier::{IsStarkVerifier, Verifier}; @@ -75,10 +76,15 @@ fn prove_and_verify_vm_minimal(elf: &Elf, traces: &mut Traces) -> bool { }; // Compute the verifier-side expected COMMIT bus balance from public output bytes + let views: Vec> = multi_proof + .proofs + .iter() + .map(StarkProofView::Owned) + .collect(); let mut replay_transcript = DefaultTranscript::::new(&[]); - let expected_bus_balance = crate::compute_expected_commit_bus_balance( + let expected_bus_balance = crate::compute_expected_commit_bus_balance_view( &airs.air_refs(), - &multi_proof, + &views, &traces.public_output_bytes, 0, &mut replay_transcript, @@ -86,9 +92,9 @@ fn prove_and_verify_vm_minimal(elf: &Elf, traces: &mut Traces) -> bool { .expect("fingerprint collision in test"); // Verify using centralized air_refs() which includes all tables - Verifier::multi_verify( + Verifier::multi_verify_views( &airs.air_refs(), - &multi_proof, + &views, &mut DefaultTranscript::::new(&[]), &expected_bus_balance, ) @@ -163,18 +169,24 @@ fn verify_vm_minimal(vm_proof: &VmProof, elf_bytes: &[u8]) -> bool { None, ); let air_refs = airs.air_refs(); + let views: Vec> = vm_proof + .proof + .proofs + .iter() + .map(StarkProofView::Owned) + .collect(); let mut replay_transcript = DefaultTranscript::::new(&[]); - let expected_bus_balance = crate::compute_expected_commit_bus_balance( + let expected_bus_balance = crate::compute_expected_commit_bus_balance_view( &air_refs, - &vm_proof.proof, + &views, &vm_proof.public_output, 0, &mut replay_transcript, ) .expect("fingerprint collision in test"); - Verifier::multi_verify( + Verifier::multi_verify_views( &air_refs, - &vm_proof.proof, + &views, &mut DefaultTranscript::::new(&[]), &expected_bus_balance, ) @@ -1378,19 +1390,21 @@ fn test_prove_elfs_test_commit_4_wrong_pages_rejected() { None, ); let verifier_air_refs = verifier_airs.air_refs(); + let views: Vec> = + proof.proofs.iter().map(StarkProofView::Owned).collect(); let mut replay_transcript = DefaultTranscript::::new(&[]); - let expected_bus_balance = crate::compute_expected_commit_bus_balance( + let expected_bus_balance = crate::compute_expected_commit_bus_balance_view( &verifier_air_refs, - &proof, + &views, &traces.public_output_bytes, 0, &mut replay_transcript, ) .expect("fingerprint collision in test"); - let verified = Verifier::multi_verify( + let verified = Verifier::multi_verify_views( &verifier_air_refs, - &proof, + &views, &mut DefaultTranscript::::new(&[]), &expected_bus_balance, ); @@ -2133,19 +2147,21 @@ fn test_deep_stack_runtime_pages_roundtrip() { None, ); let verifier_air_refs = verifier_airs.air_refs(); + let views: Vec> = + proof.proofs.iter().map(StarkProofView::Owned).collect(); let mut replay_transcript = DefaultTranscript::::new(&[]); - let expected_bus_balance = crate::compute_expected_commit_bus_balance( + let expected_bus_balance = crate::compute_expected_commit_bus_balance_view( &verifier_air_refs, - &proof, + &views, &traces.public_output_bytes, 0, &mut replay_transcript, ) .expect("fingerprint collision in test"); - let verified = Verifier::multi_verify( + let verified = Verifier::multi_verify_views( &verifier_air_refs, - &proof, + &views, &mut DefaultTranscript::::new(&[]), &expected_bus_balance, ); @@ -2206,19 +2222,21 @@ fn test_deep_stack_missing_pages_rejected() { None, ); let verifier_air_refs = verifier_airs.air_refs(); + let views: Vec> = + proof.proofs.iter().map(StarkProofView::Owned).collect(); let mut replay_transcript = DefaultTranscript::::new(&[]); - let expected_bus_balance = crate::compute_expected_commit_bus_balance( + let expected_bus_balance = crate::compute_expected_commit_bus_balance_view( &verifier_air_refs, - &proof, + &views, &traces.public_output_bytes, 0, &mut replay_transcript, ) .expect("fingerprint collision in test"); - let verified = Verifier::multi_verify( + let verified = Verifier::multi_verify_views( &verifier_air_refs, - &proof, + &views, &mut DefaultTranscript::::new(&[]), &expected_bus_balance, ); @@ -2314,19 +2332,21 @@ fn test_heap_alloc_runtime_pages_roundtrip() { None, ); let verifier_air_refs = verifier_airs.air_refs(); + let views: Vec> = + proof.proofs.iter().map(StarkProofView::Owned).collect(); let mut replay_transcript = DefaultTranscript::::new(&[]); - let expected_bus_balance = crate::compute_expected_commit_bus_balance( + let expected_bus_balance = crate::compute_expected_commit_bus_balance_view( &verifier_air_refs, - &proof, + &views, &traces.public_output_bytes, 0, &mut replay_transcript, ) .expect("fingerprint collision in test"); - let verified = Verifier::multi_verify( + let verified = Verifier::multi_verify_views( &verifier_air_refs, - &proof, + &views, &mut DefaultTranscript::::new(&[]), &expected_bus_balance, ); @@ -2915,7 +2935,7 @@ fn test_count_elements_nonzero() { /// not terminate, so it is proven with the HALT table excluded (`include_halt = false`). #[test] fn test_prove_first_epoch_without_halt() { - use crate::compute_expected_commit_bus_balance; + use crate::compute_expected_commit_bus_balance_view; use crate::tables::trace_builder::build_initial_image; use crate::test_utils::asm_elf_bytes; @@ -2972,10 +2992,15 @@ fn test_prove_first_epoch_without_halt() { ) .expect("first epoch failed to prove"); + let views: Vec> = multi_proof + .proofs + .iter() + .map(StarkProofView::Owned) + .collect(); let mut replay = DefaultTranscript::::new(&[]); - let expected_bus_balance = compute_expected_commit_bus_balance( + let expected_bus_balance = compute_expected_commit_bus_balance_view( &airs.air_refs(), - &multi_proof, + &views, &traces.public_output_bytes, 0, &mut replay, @@ -2983,9 +3008,9 @@ fn test_prove_first_epoch_without_halt() { .expect("fingerprint collision in test"); assert!( - Verifier::multi_verify( + Verifier::multi_verify_views( &airs.air_refs(), - &multi_proof, + &views, &mut DefaultTranscript::::new(&[]), &expected_bus_balance, ), @@ -2998,7 +3023,7 @@ fn test_prove_first_epoch_without_halt() { /// does not terminate (HALT excluded). #[test] fn test_prove_second_epoch_from_snapshot() { - use crate::compute_expected_commit_bus_balance; + use crate::compute_expected_commit_bus_balance_view; use crate::tables::register; use crate::test_utils::asm_elf_bytes; @@ -3056,10 +3081,15 @@ fn test_prove_second_epoch_from_snapshot() { ) .expect("second epoch failed to prove"); + let views: Vec> = multi_proof + .proofs + .iter() + .map(StarkProofView::Owned) + .collect(); let mut replay = DefaultTranscript::::new(&[]); - let expected_bus_balance = compute_expected_commit_bus_balance( + let expected_bus_balance = compute_expected_commit_bus_balance_view( &airs.air_refs(), - &multi_proof, + &views, &traces.public_output_bytes, 0, &mut replay, @@ -3067,9 +3097,9 @@ fn test_prove_second_epoch_from_snapshot() { .expect("fingerprint collision in test"); assert!( - Verifier::multi_verify( + Verifier::multi_verify_views( &airs.air_refs(), - &multi_proof, + &views, &mut DefaultTranscript::::new(&[]), &expected_bus_balance, ), @@ -3083,7 +3113,7 @@ fn test_prove_second_epoch_from_snapshot() { /// will bind to. The cross-epoch GlobalMemory matching is proven separately. #[test] fn test_epoch_proof_commits_l2g() { - use crate::compute_expected_commit_bus_balance; + use crate::compute_expected_commit_bus_balance_view; use crate::tables::local_to_global; use crate::tables::register; use crate::tables::trace_builder::{build_initial_image, epoch_touched_cells}; @@ -3167,10 +3197,15 @@ fn test_epoch_proof_commits_l2g() { let mut refs = airs.air_refs(); refs.push(&inert_l2g_air); + let views: Vec> = multi_proof + .proofs + .iter() + .map(StarkProofView::Owned) + .collect(); let mut replay = DefaultTranscript::::new(&[]); - let expected_bus_balance = compute_expected_commit_bus_balance( + let expected_bus_balance = compute_expected_commit_bus_balance_view( &refs, - &multi_proof, + &views, &traces.public_output_bytes, 0, &mut replay, @@ -3178,9 +3213,9 @@ fn test_epoch_proof_commits_l2g() { .expect("fingerprint collision in test"); assert!( - Verifier::multi_verify( + Verifier::multi_verify_views( &refs, - &multi_proof, + &views, &mut DefaultTranscript::::new(&[]), &expected_bus_balance, ), @@ -3210,7 +3245,7 @@ fn test_epoch_proof_commits_l2g() { /// argument. #[test] fn test_continuation_pipeline_end_to_end() { - use crate::compute_expected_commit_bus_balance; + use crate::compute_expected_commit_bus_balance_view; use crate::tables::local_to_global; use crate::tables::register; use crate::tables::trace_builder::{build_initial_image, epoch_touched_cells}; @@ -3323,19 +3358,24 @@ fn test_continuation_pipeline_end_to_end() { let mut refs = airs.air_refs(); refs.push(&inert_l2g_air); + let views: Vec> = multi_proof + .proofs + .iter() + .map(StarkProofView::Owned) + .collect(); let mut replay = DefaultTranscript::::new(&[]); - let expected_bus_balance = compute_expected_commit_bus_balance( + let expected_bus_balance = compute_expected_commit_bus_balance_view( &refs, - &multi_proof, + &views, &traces.public_output_bytes, 0, &mut replay, ) .expect("fingerprint collision in test"); assert!( - Verifier::multi_verify( + Verifier::multi_verify_views( &refs, - &multi_proof, + &views, &mut DefaultTranscript::::new(&[]), &expected_bus_balance, ), @@ -3361,7 +3401,10 @@ fn test_continuation_pipeline_end_to_end() { // epoch proof exposed equals the per-epoch L2G sub-table root in the final proof. let final_proof = crate::tests::local_to_global_bus_tests::prove_global(&boundaries); assert!( - crate::verify_l2g_commitment_binding(&epoch_roots, &final_proof), + crate::verify_l2g_commitment_binding_view( + &epoch_roots, + MultiProofView::Owned(&final_proof) + ), "final proof must be bound to the real per-epoch L2G roots" ); } @@ -3372,7 +3415,7 @@ fn test_continuation_pipeline_end_to_end() { /// `Memory` bus still nets to zero — L2G has replaced PAGE as the bookend. #[test] fn test_epoch_memory_bus_with_l2g_bookend() { - use crate::compute_expected_commit_bus_balance; + use crate::compute_expected_commit_bus_balance_view; use crate::tables::local_to_global; use crate::tables::register; use crate::tables::trace_builder::build_initial_image; @@ -3458,10 +3501,15 @@ fn test_epoch_memory_bus_with_l2g_bookend() { let mut refs = airs.air_refs(); refs.push(&l2g_air); + let views: Vec> = multi_proof + .proofs + .iter() + .map(StarkProofView::Owned) + .collect(); let mut replay = DefaultTranscript::::new(&[]); - let expected_bus_balance = compute_expected_commit_bus_balance( + let expected_bus_balance = compute_expected_commit_bus_balance_view( &refs, - &multi_proof, + &views, &traces.public_output_bytes, 0, &mut replay, @@ -3469,9 +3517,9 @@ fn test_epoch_memory_bus_with_l2g_bookend() { .expect("fingerprint collision in test"); assert!( - Verifier::multi_verify( + Verifier::multi_verify_views( &refs, - &multi_proof, + &views, &mut DefaultTranscript::::new(&[]), &expected_bus_balance, ), diff --git a/prover/src/tests/recursion_smoke_test.rs b/prover/src/tests/recursion_smoke_test.rs index 15817df3f..bd1244c30 100644 --- a/prover/src/tests/recursion_smoke_test.rs +++ b/prover/src/tests/recursion_smoke_test.rs @@ -531,6 +531,69 @@ fn test_recursion_blob_decodes_and_verifies_on_host() { assert!(v.ok, "misaligned-buffer verify must also succeed"); } +/// Continuation flavor of the roundtrip guard: prove the empty program via +/// continuations (tiny epochs so the bundle is genuinely multi-epoch), encode +/// the [`recursion::ContinuationGuestInput`] blob, decode it exactly as the +/// intended `continuation`-feature guest would, and mirror its +/// `verify_continuation_and_attest` call — a cheap host-side check of the +/// encode/decode/verify/attest contract without running the VM. +#[test] +fn test_recursion_continuation_blob_decodes_and_verifies_on_host() { + let root = workspace_root(); + let fib_elf_bytes = read_guest_elf(&root, "fibonacci"); + let inner_input = 10u64.to_le_bytes(); + + let bundle = crate::continuation::prove_continuation( + &fib_elf_bytes, + &inner_input, + 4, + &MIN_PROOF_OPTIONS, + ) + .expect("continuation prove should succeed"); + assert!( + bundle.num_epochs() > 1, + "epoch=2^4 must split fibonacci(10) into multiple epochs for this test to bite" + ); + // Ground truth: the trustless recompute path must accept the bundle. + let expected_output = + crate::continuation::verify_continuation(&fib_elf_bytes, &bundle, &MIN_PROOF_OPTIONS) + .expect("verify_continuation errored") + .expect("bundle must verify with recomputed roots"); + // Consumer re-bind values, computed before the encode consumes the bundle: + // recompute the roots from the bundle + trusted ELF and compare ids (the + // continuation analog of check_attestation). + let (expected_decode, expected_pages) = + crate::continuation::continuation_precomputed_commitments( + &fib_elf_bytes, + &bundle, + &MIN_PROOF_OPTIONS, + ) + .expect("continuation_precomputed_commitments errored"); + let expected_id = + recursion::program_id_from_elf(&fib_elf_bytes, &expected_decode, &expected_pages) + .expect("program_id_from_elf errored"); + + let blob = + recursion::encode_continuation_guest_input(bundle, &fib_elf_bytes, &MIN_PROOF_OPTIONS) + .expect("encode_continuation_guest_input failed"); + + // Verify exactly as the guest does (built with `continuation` + `min`): + // prefix validation + rkyv access + deserialize + verify + attest. + let attestation = recursion::verify_continuation_and_attest(&blob, &MIN_PROOF_OPTIONS) + .expect("verify_continuation_and_attest errored") + .expect("continuation proof did not survive the rkyv round-trip"); + let (id, output) = recursion::split_attestation(&attestation).expect("attestation too short"); + assert_eq!( + id, expected_id, + "attested id must match the honest recompute" + ); + assert_eq!( + output, + &expected_output[..], + "supplied-roots output must match the recompute path's output" + ); +} + /// Corrupting a private-input commitment on an *honest* proof makes /// verification fail (`Ok(false)`). Necessary but not sufficient alone — a /// custom prover can supply consistent mismatched roots (see