From 5f7a780504e63aea14b158ea59d2e4a0d412ec5d Mon Sep 17 00:00:00 2001 From: Mario Rugiero Date: Thu, 16 Jul 2026 18:25:10 -0300 Subject: [PATCH 1/9] perf(prover): verify continuation proofs in place via rkyv Adapts continuation verification to the same in-place rkyv pattern verify_recursion_blob already uses for the monolithic path (#769): verify_epoch/verify_global take a StarkProofView slice (owned or archived) instead of an owned MultiProof/EpochProof, so the new guest entry point (verify_continuation_and_attest_blob, via verify_continuation_archived) reads every per-epoch/global STARK proof straight out of the archive. Only small per-epoch metadata (table counts, reg_fini, l2g_root, public output) is materialized - the (large) per-epoch/global proof data is never copied into an owned MultiProof just to verify it. Adds the continuation guest's wire format (ContinuationGuestInput, encode_continuation_guest_input) mirroring GuestInput's magic-prefixed rkyv layout, and verify_continuation_and_attest[_blob] mirroring verify_and_attest_blob's program_id fold. replay_transcript_phase_a/compute_expected_commit_bus_balance (owned) lose their last production caller to this refactor; deleted rather than kept as compatibility wrappers now that every caller (production and test) goes through the _view variants already introduced by #769. --- prover/src/continuation.rs | 220 +++++++++++++++++++---- prover/src/lib.rs | 56 ++---- prover/src/recursion.rs | 132 ++++++++++++++ prover/src/tests/prove_elfs_tests.rs | 143 ++++++++++----- prover/src/tests/recursion_smoke_test.rs | 63 +++++++ 5 files changed, 493 insertions(+), 121 deletions(-) diff --git a/prover/src/continuation.rs b/prover/src/continuation.rs index 0f10a24d4..b9fb71321 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::StarkProofView; use stark::prover::{IsStarkProver, Prover}; use stark::trace::TraceTable; use stark::traits::AIR; @@ -72,7 +73,8 @@ 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, + verify_l2g_commitment_binding_views, }; type F = GoldilocksField; @@ -579,19 +581,27 @@ 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 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. +/// +/// `proof_views` 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, + proof_views: &[StarkProofView], + table_counts: &TableCounts, + runtime_page_ranges: &[RuntimePageRange], + reg_fini: &[u32], + claimed_l2g_root: Commitment, + public_output: &[u8], register_init: &[u32], is_final: bool, label: u64, @@ -599,7 +609,7 @@ fn verify_epoch( decode_commitment: Option, ) -> bool { // Reject degenerate table counts (mirrors the monolithic verifier). - if epoch.table_counts.validate().is_err() { + if table_counts.validate().is_err() { return false; } @@ -611,8 +621,8 @@ 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() { + let expected_proof_count = table_counts.total() + fixed_tables + 1; + if expected_proof_count != proof_views.len() { return false; } @@ -620,9 +630,9 @@ fn verify_epoch( elf, opts, &[], - &epoch.table_counts, + table_counts, register_init, - &epoch.reg_fini, + reg_fini, is_final, decode_commitment, ); @@ -633,9 +643,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,10 +658,10 @@ 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_views, + public_output, commit_start_index, &mut seed(), ) { @@ -659,18 +669,13 @@ fn verify_epoch( None => return false, }; - if !Verifier::multi_verify(&refs, &epoch.proof, &mut seed(), &expected) { + if !Verifier::multi_verify_views(&refs, proof_views, &mut seed(), &expected) { return 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) + proof_views.last().map(|p| *p.lde_trace_main_merkle_root()) == Some(claimed_l2g_root) } /// Build the cross-epoch global memory proof: every epoch's L2G sub-table on the @@ -755,7 +760,7 @@ fn prove_global( fn verify_global( num_epochs: usize, page_bases: &[u64], - proof: &MultiProof, + proof_views: &[StarkProofView], elf: &Elf, elf_bytes: &[u8], num_private_input_pages: usize, @@ -823,9 +828,9 @@ fn verify_global( refs.push(air as AirRef); } - Verifier::multi_verify( + Verifier::multi_verify_views( &refs, - proof, + proof_views, &mut global_transcript( elf_bytes, num_epochs, @@ -1078,10 +1083,21 @@ pub fn verify_continuation_with_roots( let is_final = index == n - 1; let label = local_to_global::epoch_label(index as u64); + let proof_views: Vec> = epoch + .proof + .proofs + .iter() + .map(StarkProofView::Owned) + .collect(); if !verify_epoch( &elf, elf_bytes, - epoch, + &proof_views, + &epoch.table_counts, + &epoch.runtime_page_ranges, + &epoch.reg_fini, + epoch.l2g_root, + &epoch.public_output, ®ister_init, is_final, label, @@ -1137,10 +1153,16 @@ pub fn verify_continuation_with_roots( "page_genesis_commitments contains a non-page-aligned entry".to_string(), )); } + let global_proof_views: Vec> = bundle + .global + .proofs + .iter() + .map(StarkProofView::Owned) + .collect(); if !verify_global( n, &page_bases, - &bundle.global, + &global_proof_views, &elf, elf_bytes, bundle.num_private_input_pages, @@ -1158,6 +1180,138 @@ pub fn verify_continuation_with_roots( Ok(Some(public_output)) } +/// [`verify_continuation_with_roots`]'s zero-copy counterpart, for the +/// recursion `continuation` guest: reads every per-epoch/global proof in +/// place via [`StarkProofView::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). +pub(crate) fn verify_continuation_archived( + archived: &ArchivedContinuationProof, + elf_bytes: &[u8], + opts: &ProofOptions, + decode_commitment: Commitment, + page_genesis_commitments: &[(u64, Commitment)], +) -> Result>, Error> { + use rkyv::rancor::Error as RkyvError; + + let max_private_input_pages = page::max_private_input_pages(); + let num_private_input_pages = archived.num_private_input_pages.to_native() as usize; + if num_private_input_pages > max_private_input_pages { + return Err(Error::InvalidTableCounts(format!( + "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 = archived.epochs.len(); + if n == 0 { + return Ok(None); + } + + if archived + .epochs + .iter() + .any(|e| e.reg_fini.len() != register::NUM_REGISTER_ADDRESSES) + { + return Ok(None); + } + + let mut register_init = register::register_init_from_entry_point(elf.entry_point); + let mut epoch_roots: Vec = Vec::with_capacity(n); + let mut public_output: Vec = Vec::new(); + + for (index, epoch) in archived.epochs.iter().enumerate() { + let is_final = index == n - 1; + let label = local_to_global::epoch_label(index as u64); + + let table_counts: TableCounts = + rkyv::deserialize::(&epoch.table_counts).map_err(|e| { + Error::Execution(format!("rkyv deserialize table_counts failed: {e}")) + })?; + let runtime_page_ranges: Vec = rkyv::deserialize::< + Vec, + RkyvError, + >(&epoch.runtime_page_ranges) + .map_err(|e| Error::Execution(format!("rkyv deserialize page ranges failed: {e}")))?; + let reg_fini: Vec = rkyv::deserialize::, RkyvError>(&epoch.reg_fini) + .map_err(|e| Error::Execution(format!("rkyv deserialize reg_fini failed: {e}")))?; + let l2g_root: Commitment = epoch.l2g_root; + let epoch_public_output: &[u8] = epoch.public_output.as_slice(); + + let proof_views: Vec> = epoch + .proof + .proofs + .as_slice() + .iter() + .map(StarkProofView::Archived) + .collect(); + + if !verify_epoch( + &elf, + elf_bytes, + &proof_views, + &table_counts, + &runtime_page_ranges, + ®_fini, + l2g_root, + epoch_public_output, + ®ister_init, + is_final, + label, + opts, + Some(decode_commitment), + ) { + return Ok(None); + } + + epoch_roots.push(l2g_root); + public_output.extend_from_slice(epoch_public_output); + register_init = reg_fini; + } + + let touched_page_bases: Vec = archived + .touched_page_bases + .iter() + .map(|v| v.to_native()) + .collect(); + let page_bases = canonical_page_bases(&touched_page_bases); + if page_bases + .iter() + .any(|&b| b != page::page_base_for_address(b)) + { + return Err(Error::MalformedContinuationBundle( + "touched_page_bases contains a non-page-aligned entry".to_string(), + )); + } + + let global_proof_views: Vec> = archived + .global + .proofs + .as_slice() + .iter() + .map(StarkProofView::Archived) + .collect(); + if !verify_global( + n, + &page_bases, + &global_proof_views, + &elf, + elf_bytes, + num_private_input_pages, + opts, + Some(page_genesis_commitments), + ) { + return Ok(None); + } + + if !verify_l2g_commitment_binding_views(&epoch_roots, &global_proof_views) { + return Ok(None); + } + + Ok(Some(public_output)) +} + /// Precompute the ELF-derived roots [`verify_continuation_with_roots`] accepts: /// the DECODE preprocessed root and one genesis root per touched non-private /// data page (the same set `verify_global` would rebuild from the ELF). These diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 77c534d48..88bb9b8c7 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -872,26 +872,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,25 +921,9 @@ 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. +/// 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( airs: &[&dyn AIR], proofs: &[StarkProofView], @@ -1010,6 +974,20 @@ pub(crate) fn verify_l2g_commitment_binding( .all(|(i, root)| final_proof.proofs[i].lde_trace_main_merkle_root == *root) } +/// View counterpart of [`verify_l2g_commitment_binding`]: reads each global +/// sub-table's committed root directly off a proof view (owned or +/// archived-in-place), with no `MultiProof` deserialization either way. +pub(crate) fn verify_l2g_commitment_binding_views( + epoch_l2g_roots: &[Commitment], + final_proof_views: &[StarkProofView], +) -> bool { + final_proof_views.len() >= epoch_l2g_roots.len() + && epoch_l2g_roots + .iter() + .enumerate() + .all(|(i, root)| *final_proof_views[i].lde_trace_main_merkle_root() == *root) +} + // ============================================================================= // Public API: Prove / Verify // ============================================================================= diff --git a/prover/src/recursion.rs b/prover/src/recursion.rs index 6bb3b1247..20be0fc5a 100644 --- a/prover/src/recursion.rs +++ b/prover/src/recursion.rs @@ -223,6 +223,138 @@ 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 (the +/// `continuation` guest feature): 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. +pub fn verify_continuation_and_attest( + bundle: &crate::continuation::ContinuationProof, + elf_bytes: &[u8], + proof_options: &ProofOptions, + decode_commitment: Commitment, + page_commitments: &[(u64, Commitment)], +) -> Result>, Error> { + let Some(public_output) = crate::continuation::verify_continuation_with_roots( + elf_bytes, + bundle, + proof_options, + Some(decode_commitment), + Some(page_commitments), + )? + else { + return Ok(None); + }; + let id = program_id_from_elf(elf_bytes, &decode_commitment, page_commitments)?; + let mut attestation = id.to_vec(); + attestation.extend_from_slice(&public_output); + Ok(Some(attestation)) +} + +/// [`verify_continuation_and_attest`]'s logic over the wire-format blob +/// ([`encode_continuation_guest_input`]) — the `continuation` guest's whole +/// job in one call. 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( + 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) = crate::continuation::verify_continuation_archived( + &archived.bundle, + inner_elf, + proof_options, + decode_commitment, + &page_commitments, + )? + else { + return Ok(None); + }; + + let id = program_id_from_elf(inner_elf, &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/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index 864e4e3f9..88c889d83 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::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, ), @@ -3372,7 +3412,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 +3498,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 +3514,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..c9c71febb 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 +/// `continuation`-feature guest does, 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(&blob, &MIN_PROOF_OPTIONS) + .expect("verify_continuation_and_attest_blob 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 From 27e342bcabd6a0f82e72e0f64adb87d3ee2de6b7 Mon Sep 17 00:00:00 2001 From: Mario Rugiero Date: Fri, 17 Jul 2026 16:09:26 -0300 Subject: [PATCH 2/9] test(prover): cover continuation blob tamper path, drop dead API Adds a negative test for the archived continuation path: encode a bundle with a tampered epoch l2g_root, assert the blob verify rejects it. Drops verify_continuation_and_attest_blob's now-unused owned-bundle predecessor and renames the surviving function to verify_continuation_and_attest, since the _blob suffix only existed to disambiguate it from that owned variant. Also fixes a stale intra-doc link left over from the deleted compute_expected_commit_bus_balance. --- prover/src/continuation.rs | 40 ++++++++++++++++++++++ prover/src/lib.rs | 4 +-- prover/src/recursion.rs | 42 +++++------------------- prover/src/tests/recursion_smoke_test.rs | 6 ++-- 4 files changed, 54 insertions(+), 38 deletions(-) diff --git a/prover/src/continuation.rs b/prover/src/continuation.rs index b9fb71321..c5ee166cd 100644 --- a/prover/src/continuation.rs +++ b/prover/src/continuation.rs @@ -2141,4 +2141,44 @@ 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 `verify_continuation_archived`'s view-based + // `verify_l2g_commitment_binding_views` against the same corruption its owned + // counterpart 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" + ); + } } diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 88bb9b8c7..8f4c61896 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -940,8 +940,8 @@ 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). +/// 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( airs: &[&dyn AIR], proofs: &[StarkProofView], diff --git a/prover/src/recursion.rs b/prover/src/recursion.rs index 20be0fc5a..7e7a62a13 100644 --- a/prover/src/recursion.rs +++ b/prover/src/recursion.rs @@ -266,45 +266,21 @@ pub fn encode_continuation_guest_input( Ok(blob) } -/// [`verify_and_attest_blob`]'s logic for a continuation bundle (the -/// `continuation` guest feature): verify every epoch + the global memory -/// proof against the supplied roots, then attest +/// [`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. +/// 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( - bundle: &crate::continuation::ContinuationProof, - elf_bytes: &[u8], - proof_options: &ProofOptions, - decode_commitment: Commitment, - page_commitments: &[(u64, Commitment)], -) -> Result>, Error> { - let Some(public_output) = crate::continuation::verify_continuation_with_roots( - elf_bytes, - bundle, - proof_options, - Some(decode_commitment), - Some(page_commitments), - )? - else { - return Ok(None); - }; - let id = program_id_from_elf(elf_bytes, &decode_commitment, page_commitments)?; - let mut attestation = id.to_vec(); - attestation.extend_from_slice(&public_output); - Ok(Some(attestation)) -} - -/// [`verify_continuation_and_attest`]'s logic over the wire-format blob -/// ([`encode_continuation_guest_input`]) — the `continuation` guest's whole -/// job in one call. 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( blob: &[u8], proof_options: &ProofOptions, ) -> Result>, Error> { diff --git a/prover/src/tests/recursion_smoke_test.rs b/prover/src/tests/recursion_smoke_test.rs index c9c71febb..bd1244c30 100644 --- a/prover/src/tests/recursion_smoke_test.rs +++ b/prover/src/tests/recursion_smoke_test.rs @@ -534,7 +534,7 @@ fn test_recursion_blob_decodes_and_verifies_on_host() { /// 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 -/// `continuation`-feature guest does, and mirror its +/// 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] @@ -579,8 +579,8 @@ fn test_recursion_continuation_blob_decodes_and_verifies_on_host() { // 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(&blob, &MIN_PROOF_OPTIONS) - .expect("verify_continuation_and_attest_blob errored") + 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!( From 71cfb5d1eea50d04243f93821b59e3ea1f3c2b29 Mon Sep 17 00:00:00 2001 From: Mario Rugiero Date: Fri, 17 Jul 2026 16:34:16 -0300 Subject: [PATCH 3/9] fmt --- prover/src/continuation.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/prover/src/continuation.rs b/prover/src/continuation.rs index c5ee166cd..d15e32d11 100644 --- a/prover/src/continuation.rs +++ b/prover/src/continuation.rs @@ -2151,13 +2151,8 @@ mod tests { 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(); + 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" From 3883d1b6bc6802f8bd1747e86c8cf52f54155102 Mon Sep 17 00:00:00 2001 From: Mario Rugiero Date: Fri, 17 Jul 2026 18:09:44 -0300 Subject: [PATCH 4/9] refactor(prover,stark): replace MultiProof field-explosion with borrowed views verify_epoch/verify_global/verify_proof_parts had ballooned into long parameter lists (proof_views, table_counts, runtime_page_ranges, reg_fini, l2g_root, public_output, ...) built ad hoc at every call site via `.iter().map(StarkProofView::Owned/Archived).collect()`, a leftover from keeping owned and archived verify paths side by side. Adds MultiProofView (crypto/stark) mirroring StarkProofView, and EpochProofView/ContinuationProofView (prover) mirroring it one level up, so callers pass one view instead of exploding a bundle into loose fields. multi_verify_views, compute_expected_commit_bus_balance_view, and replay_transcript_phase_a_view are now generic over a ProofViewSource trait (impl'd for slices, Vecs, and MultiProofView) and iterate in place - no Vec materialization anywhere in the path. Collapses verify_continuation_with_roots/verify_continuation_archived (two ~130-line near-duplicates) into one verify_continuation_view, and unifies verify_l2g_commitment_binding/_views into one view-based fn. --- crypto/stark/src/proof/view.rs | 141 +++++- .../src/tests/bus_tests/soundness_tests.rs | 4 +- crypto/stark/src/verifier.rs | 53 ++- prover/src/continuation.rs | 445 ++++++++++-------- prover/src/lib.rs | 66 +-- prover/src/tests/local_to_global_bus_tests.rs | 11 +- prover/src/tests/prove_elfs_tests.rs | 7 +- 7 files changed, 442 insertions(+), 285 deletions(-) diff --git a/crypto/stark/src/proof/view.rs b/crypto/stark/src/proof/view.rs index 6eb8cedaf..54ec8aa07 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,143 @@ 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, +{ + pub fn len(&self) -> usize { + match self { + Self::Owned(p) => p.proofs.len(), + Self::Archived(p) => p.proofs.len(), + } + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + 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]), + } + } + + pub fn last(&self) -> Option> { + let len = self.len(); + (len > 0).then(|| self.get(len - 1)) + } + + 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, +{ + fn view_len(&self) -> usize { + self.len() + } + 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, +{ + fn view_len(&self) -> usize { + self.len() + } + 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, +{ + fn view_len(&self) -> usize { + MultiProofView::len(self) + } + 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 d15e32d11..84fd9c2d4 100644 --- a/prover/src/continuation.rs +++ b/prover/src/continuation.rs @@ -58,7 +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::StarkProofView; +use stark::proof::view::MultiProofView; use stark::prover::{IsStarkProver, Prover}; use stark::trace::TraceTable; use stark::traits::AIR; @@ -73,8 +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_view, verify_l2g_commitment_binding, - verify_l2g_commitment_binding_views, + compute_expected_commit_bus_balance_view, verify_l2g_commitment_binding_view, }; type F = GoldilocksField; @@ -156,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). @@ -407,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, } @@ -448,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 @@ -581,36 +716,33 @@ fn prove_epoch( }) } -/// Verify one epoch using ONLY the epoch's public statement fields 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. /// -/// `proof_views` is zero-copy either way: owned or archived (see the two callers). +/// `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], - proof_views: &[StarkProofView], - table_counts: &TableCounts, - runtime_page_ranges: &[RuntimePageRange], - reg_fini: &[u32], - claimed_l2g_root: Commitment, - public_output: &[u8], + 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 table_counts.validate().is_err() { - return false; + return Ok(false); } // Cross-check table_counts before building AIRs from bundle data. Continuation @@ -621,18 +753,23 @@ fn verify_epoch( } else { FIXED_TABLE_COUNT - 1 }; + let proof = epoch.proof(); let expected_proof_count = table_counts.total() + fixed_tables + 1; - if expected_proof_count != proof_views.len() { - return false; + 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, &[], - table_counts, + &table_counts, register_init, - reg_fini, + ®_fini, is_final, decode_commitment, ); @@ -644,8 +781,8 @@ fn verify_epoch( epoch_transcript( elf_bytes, public_output, - table_counts, - runtime_page_ranges, + &table_counts, + &runtime_page_ranges, label, opts.fri_final_poly_log_degree, ) @@ -660,22 +797,22 @@ fn verify_epoch( let expected = match compute_expected_commit_bus_balance_view( &refs, - proof_views, + proof, public_output, commit_start_index, &mut seed(), ) { Some(expected) => expected, - None => return false, + None => return Ok(false), }; - if !Verifier::multi_verify_views(&refs, proof_views, &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). - proof_views.last().map(|p| *p.lde_trace_main_merkle_root()) == Some(claimed_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 @@ -760,7 +897,7 @@ fn prove_global( fn verify_global( num_epochs: usize, page_bases: &[u64], - proof_views: &[StarkProofView], + proof: MultiProofView<'_, F, E, ()>, elf: &Elf, elf_bytes: &[u8], num_private_input_pages: usize, @@ -830,7 +967,7 @@ fn verify_global( Verifier::multi_verify_views( &refs, - proof_views, + proof, &mut global_transcript( elf_bytes, num_epochs, @@ -1041,6 +1178,47 @@ pub fn verify_continuation_with_roots( opts: &ProofOptions, decode_commitment: Option, page_genesis_commitments: Option<&[(u64, Commitment)]>, +) -> Result>, Error> { + verify_continuation_view( + ContinuationProofView::Owned(bundle), + elf_bytes, + opts, + decode_commitment, + page_genesis_commitments, + ) +} + +/// [`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). +pub(crate) fn verify_continuation_archived( + archived: &ArchivedContinuationProof, + elf_bytes: &[u8], + opts: &ProofOptions, + decode_commitment: Commitment, + page_genesis_commitments: &[(u64, Commitment)], +) -> Result>, 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. +fn verify_continuation_view( + bundle: ContinuationProofView<'_>, + elf_bytes: &[u8], + opts: &ProofOptions, + decode_commitment: Option, + page_genesis_commitments: Option<&[(u64, Commitment)]>, ) -> Result>, 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 @@ -1048,16 +1226,16 @@ pub fn verify_continuation_with_roots( // 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); } @@ -1065,11 +1243,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); } @@ -1079,39 +1257,30 @@ 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(); - let proof_views: Vec> = epoch - .proof - .proofs - .iter() - .map(StarkProofView::Owned) - .collect(); if !verify_epoch( &elf, elf_bytes, - &proof_views, - &epoch.table_counts, - &epoch.runtime_page_ranges, - &epoch.reg_fini, - epoch.l2g_root, - &epoch.public_output, + epoch, ®ister_init, is_final, 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 @@ -1123,7 +1292,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 @@ -1153,19 +1323,14 @@ pub fn verify_continuation_with_roots( "page_genesis_commitments contains a non-page-aligned entry".to_string(), )); } - let global_proof_views: Vec> = bundle - .global - .proofs - .iter() - .map(StarkProofView::Owned) - .collect(); + let global_proof = bundle.global(); if !verify_global( n, &page_bases, - &global_proof_views, + global_proof, &elf, elf_bytes, - bundle.num_private_input_pages, + num_private_input_pages, opts, page_genesis_commitments, ) { @@ -1173,139 +1338,7 @@ 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) { - return Ok(None); - } - - Ok(Some(public_output)) -} - -/// [`verify_continuation_with_roots`]'s zero-copy counterpart, for the -/// recursion `continuation` guest: reads every per-epoch/global proof in -/// place via [`StarkProofView::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). -pub(crate) fn verify_continuation_archived( - archived: &ArchivedContinuationProof, - elf_bytes: &[u8], - opts: &ProofOptions, - decode_commitment: Commitment, - page_genesis_commitments: &[(u64, Commitment)], -) -> Result>, Error> { - use rkyv::rancor::Error as RkyvError; - - let max_private_input_pages = page::max_private_input_pages(); - let num_private_input_pages = archived.num_private_input_pages.to_native() as usize; - if num_private_input_pages > max_private_input_pages { - return Err(Error::InvalidTableCounts(format!( - "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 = archived.epochs.len(); - if n == 0 { - return Ok(None); - } - - if archived - .epochs - .iter() - .any(|e| e.reg_fini.len() != register::NUM_REGISTER_ADDRESSES) - { - return Ok(None); - } - - let mut register_init = register::register_init_from_entry_point(elf.entry_point); - let mut epoch_roots: Vec = Vec::with_capacity(n); - let mut public_output: Vec = Vec::new(); - - for (index, epoch) in archived.epochs.iter().enumerate() { - let is_final = index == n - 1; - let label = local_to_global::epoch_label(index as u64); - - let table_counts: TableCounts = - rkyv::deserialize::(&epoch.table_counts).map_err(|e| { - Error::Execution(format!("rkyv deserialize table_counts failed: {e}")) - })?; - let runtime_page_ranges: Vec = rkyv::deserialize::< - Vec, - RkyvError, - >(&epoch.runtime_page_ranges) - .map_err(|e| Error::Execution(format!("rkyv deserialize page ranges failed: {e}")))?; - let reg_fini: Vec = rkyv::deserialize::, RkyvError>(&epoch.reg_fini) - .map_err(|e| Error::Execution(format!("rkyv deserialize reg_fini failed: {e}")))?; - let l2g_root: Commitment = epoch.l2g_root; - let epoch_public_output: &[u8] = epoch.public_output.as_slice(); - - let proof_views: Vec> = epoch - .proof - .proofs - .as_slice() - .iter() - .map(StarkProofView::Archived) - .collect(); - - if !verify_epoch( - &elf, - elf_bytes, - &proof_views, - &table_counts, - &runtime_page_ranges, - ®_fini, - l2g_root, - epoch_public_output, - ®ister_init, - is_final, - label, - opts, - Some(decode_commitment), - ) { - return Ok(None); - } - - epoch_roots.push(l2g_root); - public_output.extend_from_slice(epoch_public_output); - register_init = reg_fini; - } - - let touched_page_bases: Vec = archived - .touched_page_bases - .iter() - .map(|v| v.to_native()) - .collect(); - let page_bases = canonical_page_bases(&touched_page_bases); - if page_bases - .iter() - .any(|&b| b != page::page_base_for_address(b)) - { - return Err(Error::MalformedContinuationBundle( - "touched_page_bases contains a non-page-aligned entry".to_string(), - )); - } - - let global_proof_views: Vec> = archived - .global - .proofs - .as_slice() - .iter() - .map(StarkProofView::Archived) - .collect(); - if !verify_global( - n, - &page_bases, - &global_proof_views, - &elf, - elf_bytes, - num_private_input_pages, - opts, - Some(page_genesis_commitments), - ) { - return Ok(None); - } - - if !verify_l2g_commitment_binding_views(&epoch_roots, &global_proof_views) { + if !verify_l2g_commitment_binding_view(&epoch_roots, global_proof) { return Ok(None); } @@ -2121,7 +2154,7 @@ 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 + // `verify_l2g_commitment_binding_view` 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. #[test] @@ -2145,7 +2178,7 @@ mod tests { // 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 `verify_continuation_archived`'s view-based - // `verify_l2g_commitment_binding_views` against the same corruption its owned + // `verify_l2g_commitment_binding_view` against the same corruption its owned // counterpart already catches. #[test] fn test_continuation_blob_rejects_tampered_l2g_root() { diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 8f4c61896..1540a6daa 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. /// @@ -372,16 +372,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, @@ -924,12 +916,12 @@ pub(crate) fn compute_commit_bus_offset( /// 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( +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()); } @@ -942,9 +934,9 @@ pub(crate) fn replay_transcript_phase_a_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( +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, @@ -956,36 +948,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) -} - -/// View counterpart of [`verify_l2g_commitment_binding`]: reads each global -/// sub-table's committed root directly off a proof view (owned or -/// archived-in-place), with no `MultiProof` deserialization either way. -pub(crate) fn verify_l2g_commitment_binding_views( - epoch_l2g_roots: &[Commitment], - final_proof_views: &[StarkProofView], -) -> bool { - final_proof_views.len() >= epoch_l2g_roots.len() - && epoch_l2g_roots - .iter() - .enumerate() - .all(|(i, root)| *final_proof_views[i].lde_trace_main_merkle_root() == *root) + .all(|(i, root)| *final_proof.get(i).lde_trace_main_merkle_root() == *root) } // ============================================================================= @@ -1278,15 +1259,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, @@ -1302,12 +1276,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/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 88c889d83..ffe9071b2 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -18,7 +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::StarkProofView; +use stark::proof::view::{MultiProofView, StarkProofView}; use stark::traits::AIR; use stark::verifier::{IsStarkVerifier, Verifier}; @@ -3401,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" ); } From 79b2117ef99edd8321daffd6f5f0ea78b6630aa7 Mon Sep 17 00:00:00 2001 From: Mario Rugiero Date: Fri, 17 Jul 2026 18:32:55 -0300 Subject: [PATCH 5/9] perf(stark): match Owned/Archived once per MultiProofView iteration, not per element MultiProofView::iter() built each StarkProofView via get(i): a match on Owned/Archived plus a bounds-checked index, redone for every element on every one of multi_verify_views' several passes over the same proof set, even though a given MultiProofView is homogeneously one variant for its whole lifetime. MultiProofViewIter now matches once, in iter() itself, and then drives a plain slice::Iter for whichever representation was chosen - no per-step bounds check, no re-matching against the source enum. --- crypto/stark/src/proof/view.rs | 71 +++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 5 deletions(-) diff --git a/crypto/stark/src/proof/view.rs b/crypto/stark/src/proof/view.rs index 54ec8aa07..1ece02c9f 100644 --- a/crypto/stark/src/proof/view.rs +++ b/crypto/stark/src/proof/view.rs @@ -524,6 +524,7 @@ where PI: rkyv::Archive, ::Archived: rkyv::Deserialize, { + #[inline(always)] pub fn len(&self) -> usize { match self { Self::Owned(p) => p.proofs.len(), @@ -531,10 +532,12 @@ where } } + #[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]), @@ -542,14 +545,68 @@ where } } + #[inline(always)] pub fn last(&self) -> Option> { - let len = self.len(); - (len > 0).then(|| self.get(len - 1)) + match self { + Self::Owned(p) => p.proofs.last().map(StarkProofView::Owned), + Self::Archived(p) => p.proofs.as_slice().last().map(StarkProofView::Archived), + } + } + + /// Which representation backs this view is decided ONCE here, not + /// per element: the returned [`MultiProofViewIter`] drives a plain + /// `slice::Iter` (no per-step bounds check or `Owned`/`Archived` match + /// against `self` — [`Self::get`] would redo both on every step across + /// every one of [`crate::verifier::IsStarkVerifier::multi_verify_views`]'s + /// several passes over the same proof set). + #[inline(always)] + pub fn iter(&self) -> MultiProofViewIter<'a, F, E, PI> { + match self { + Self::Owned(p) => MultiProofViewIter::Owned(p.proofs.iter()), + Self::Archived(p) => MultiProofViewIter::Archived(p.proofs.as_slice().iter()), + } + } +} + +/// [`MultiProofView::iter`]'s iterator: a plain `slice::Iter` over whichever +/// representation the source `MultiProofView` holds, chosen once at +/// construction. Never re-checks `Owned`-vs-`Archived` against the source +/// enum per element, and never bounds-checks — both were the case for the +/// `(0..len).map(|i| view.get(i))` this replaces. +pub enum MultiProofViewIter<'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(std::slice::Iter<'a, StarkProof>), + Archived(std::slice::Iter<'a, ArchivedStarkProof>), +} + +impl<'a, F: IsSubFieldOf, E: IsField, PI> Iterator for MultiProofViewIter<'a, F, E, PI> +where + F::BaseType: math::field::element::NativeArchived, + E::BaseType: math::field::element::NativeArchived, + PI: rkyv::Archive, + ::Archived: rkyv::Deserialize, +{ + type Item = StarkProofView<'a, F, E, PI>; + + #[inline(always)] + fn next(&mut self) -> Option { + match self { + Self::Owned(it) => it.next().map(StarkProofView::Owned), + Self::Archived(it) => it.next().map(StarkProofView::Archived), + } } - pub fn iter(&self) -> impl Iterator> + 'a { - let this = *self; - (0..this.len()).map(move |i| this.get(i)) + #[inline(always)] + fn size_hint(&self) -> (usize, Option) { + match self { + Self::Owned(it) => it.size_hint(), + Self::Archived(it) => it.size_hint(), + } } } @@ -578,9 +635,11 @@ where 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() } @@ -594,9 +653,11 @@ where 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() } From 896e6a28db40219735b126e4268b9b27071824ed Mon Sep 17 00:00:00 2001 From: Mario Rugiero Date: Fri, 17 Jul 2026 18:39:58 -0300 Subject: [PATCH 6/9] Revert "perf(stark): match Owned/Archived once per MultiProofView iteration, not per element" This reverts commit f0cd8abb8d5b05e656072d462efae5f80e92c9a4. --- crypto/stark/src/proof/view.rs | 71 +++------------------------------- 1 file changed, 5 insertions(+), 66 deletions(-) diff --git a/crypto/stark/src/proof/view.rs b/crypto/stark/src/proof/view.rs index 1ece02c9f..54ec8aa07 100644 --- a/crypto/stark/src/proof/view.rs +++ b/crypto/stark/src/proof/view.rs @@ -524,7 +524,6 @@ where PI: rkyv::Archive, ::Archived: rkyv::Deserialize, { - #[inline(always)] pub fn len(&self) -> usize { match self { Self::Owned(p) => p.proofs.len(), @@ -532,12 +531,10 @@ where } } - #[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]), @@ -545,68 +542,14 @@ where } } - #[inline(always)] pub fn last(&self) -> Option> { - match self { - Self::Owned(p) => p.proofs.last().map(StarkProofView::Owned), - Self::Archived(p) => p.proofs.as_slice().last().map(StarkProofView::Archived), - } - } - - /// Which representation backs this view is decided ONCE here, not - /// per element: the returned [`MultiProofViewIter`] drives a plain - /// `slice::Iter` (no per-step bounds check or `Owned`/`Archived` match - /// against `self` — [`Self::get`] would redo both on every step across - /// every one of [`crate::verifier::IsStarkVerifier::multi_verify_views`]'s - /// several passes over the same proof set). - #[inline(always)] - pub fn iter(&self) -> MultiProofViewIter<'a, F, E, PI> { - match self { - Self::Owned(p) => MultiProofViewIter::Owned(p.proofs.iter()), - Self::Archived(p) => MultiProofViewIter::Archived(p.proofs.as_slice().iter()), - } - } -} - -/// [`MultiProofView::iter`]'s iterator: a plain `slice::Iter` over whichever -/// representation the source `MultiProofView` holds, chosen once at -/// construction. Never re-checks `Owned`-vs-`Archived` against the source -/// enum per element, and never bounds-checks — both were the case for the -/// `(0..len).map(|i| view.get(i))` this replaces. -pub enum MultiProofViewIter<'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(std::slice::Iter<'a, StarkProof>), - Archived(std::slice::Iter<'a, ArchivedStarkProof>), -} - -impl<'a, F: IsSubFieldOf, E: IsField, PI> Iterator for MultiProofViewIter<'a, F, E, PI> -where - F::BaseType: math::field::element::NativeArchived, - E::BaseType: math::field::element::NativeArchived, - PI: rkyv::Archive, - ::Archived: rkyv::Deserialize, -{ - type Item = StarkProofView<'a, F, E, PI>; - - #[inline(always)] - fn next(&mut self) -> Option { - match self { - Self::Owned(it) => it.next().map(StarkProofView::Owned), - Self::Archived(it) => it.next().map(StarkProofView::Archived), - } + let len = self.len(); + (len > 0).then(|| self.get(len - 1)) } - #[inline(always)] - fn size_hint(&self) -> (usize, Option) { - match self { - Self::Owned(it) => it.size_hint(), - Self::Archived(it) => it.size_hint(), - } + pub fn iter(&self) -> impl Iterator> + 'a { + let this = *self; + (0..this.len()).map(move |i| this.get(i)) } } @@ -635,11 +578,9 @@ where 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() } @@ -653,11 +594,9 @@ where 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() } From 772a8a7489de90565d17073ca1ea749bc3bf511d Mon Sep 17 00:00:00 2001 From: Mario Rugiero Date: Fri, 17 Jul 2026 18:58:32 -0300 Subject: [PATCH 7/9] perf(stark): force-inline MultiProofView's per-element accessors Closes the ~0.04% guest-cycle regression the MultiProofView refactor introduced (measured via scripts/bench_recursion_scaling.sh, blowup4/txs=4): len/get/last/iter and the ProofViewSource impls' view_len/view_iter cross a crate boundary (stark -> prover) and apparently weren't getting inlined into multi_verify_views' hot loops. An alternative fix - matching Owned/Archived once per MultiProofView instead of once per element, via a hand-rolled slice::Iter-backed enum iterator - was tried and measured WORSE (+2.3% cycles): it defeats LLVM's specialized Range::map optimization the closure-based iter() benefits from (see the previous two commits). #[inline(always)] alone, keeping the closure-based iter(), measured at parity with (very slightly better than) the pre-refactor baseline. --- crypto/stark/src/proof/view.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crypto/stark/src/proof/view.rs b/crypto/stark/src/proof/view.rs index 54ec8aa07..85addd392 100644 --- a/crypto/stark/src/proof/view.rs +++ b/crypto/stark/src/proof/view.rs @@ -524,6 +524,7 @@ where PI: rkyv::Archive, ::Archived: rkyv::Deserialize, { + #[inline(always)] pub fn len(&self) -> usize { match self { Self::Owned(p) => p.proofs.len(), @@ -531,10 +532,12 @@ where } } + #[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]), @@ -542,11 +545,13 @@ where } } + #[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)) @@ -578,9 +583,11 @@ where 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() } @@ -594,9 +601,11 @@ where 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() } @@ -610,9 +619,11 @@ where 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) } From 995f97170523a2b2ed6bb9bec5972a155ad7716e Mon Sep 17 00:00:00 2001 From: Mario Rugiero Date: Fri, 17 Jul 2026 19:16:28 -0300 Subject: [PATCH 8/9] fix(prover): cover L2G binding rejection, dedupe ELF re-parse in continuation verify - Add tests that splice a different run's global proof onto valid epochs (same shape, different L2G data) so verify_l2g_commitment_binding_view's own reject branch actually executes, both for the owned and archived verify paths. The existing tampered-root tests were caught earlier by verify_epoch's per-epoch root check and never reached this binding. - Thread entry_point out of verify_continuation_archived so verify_continuation_and_attest can fold program_id via program_id_from_digest instead of re-parsing the ELF with program_id_from_elf. - Extract access_recursion_archive, sharing the aligned-fallback + rkyv::access boilerplate between verify_recursion_blob and verify_continuation_and_attest. --- prover/src/continuation.rs | 116 +++++++++++++++++++++++++++++++++---- prover/src/lib.rs | 73 ++++++++++++++--------- prover/src/recursion.rs | 6 +- 3 files changed, 153 insertions(+), 42 deletions(-) diff --git a/prover/src/continuation.rs b/prover/src/continuation.rs index 84fd9c2d4..81b2ec8e3 100644 --- a/prover/src/continuation.rs +++ b/prover/src/continuation.rs @@ -1179,13 +1179,14 @@ pub fn verify_continuation_with_roots( decode_commitment: Option, page_genesis_commitments: Option<&[(u64, Commitment)]>, ) -> Result>, Error> { - verify_continuation_view( + 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 @@ -1193,13 +1194,16 @@ pub fn verify_continuation_with_roots( /// 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>, Error> { +) -> Result, u64)>, Error> { verify_continuation_view( ContinuationProofView::Archived(archived), elf_bytes, @@ -1213,13 +1217,14 @@ pub(crate) fn verify_continuation_archived( /// [`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>, Error> { +) -> 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 @@ -1342,7 +1347,7 @@ fn verify_continuation_view( return Ok(None); } - Ok(Some(public_output)) + Ok(Some((public_output, elf.entry_point))) } /// Precompute the ELF-derived roots [`verify_continuation_with_roots`] accepts: @@ -2153,10 +2158,11 @@ mod tests { ); } - // Negative: corrupting an epoch's claimed L2G table root must be rejected — - // `verify_l2g_commitment_binding_view` 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(); @@ -2177,9 +2183,8 @@ mod tests { // 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 `verify_continuation_archived`'s view-based - // `verify_l2g_commitment_binding_view` against the same corruption its owned - // counterpart already catches. + // `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(); @@ -2209,4 +2214,91 @@ mod tests { "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 1540a6daa..99d725f80 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -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); diff --git a/prover/src/recursion.rs b/prover/src/recursion.rs index 7e7a62a13..654b58fa4 100644 --- a/prover/src/recursion.rs +++ b/prover/src/recursion.rs @@ -314,7 +314,7 @@ pub fn verify_continuation_and_attest( let decode_commitment: Commitment = archived.decode_commitment; let inner_elf: &[u8] = archived.inner_elf.as_slice(); - let Some(public_output) = crate::continuation::verify_continuation_archived( + let Some((public_output, entry_point)) = crate::continuation::verify_continuation_archived( &archived.bundle, inner_elf, proof_options, @@ -325,7 +325,9 @@ pub fn verify_continuation_and_attest( return Ok(None); }; - let id = program_id_from_elf(inner_elf, &decode_commitment, &page_commitments)?; + // 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)) From 860e04f4bb6d57ea8a5a2331c4a6f3f9f7fd78f7 Mon Sep 17 00:00:00 2001 From: Mario Rugiero Date: Fri, 17 Jul 2026 19:20:06 -0300 Subject: [PATCH 9/9] fmt --- prover/src/continuation.rs | 5 ++--- prover/src/lib.rs | 14 +++++++------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/prover/src/continuation.rs b/prover/src/continuation.rs index 81b2ec8e3..169cd7278 100644 --- a/prover/src/continuation.rs +++ b/prover/src/continuation.rs @@ -2291,9 +2291,8 @@ mod tests { 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 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!( diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 99d725f80..ff9601bb4 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -285,13 +285,13 @@ where 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: &'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| {