diff --git a/docs/continuations_design.md b/docs/continuations_design.md index 71bb3577a..b8c095c61 100644 --- a/docs/continuations_design.md +++ b/docs/continuations_design.md @@ -641,7 +641,7 @@ recursion/aggregation layer (deferred). `prove_and_verify_continuation` (the thin integrated wrapper). - `prover/src/lib.rs` — `verify_l2g_commitment_binding` (epoch L2G root ↔ global sub-table root) and the commit-bus offset/balance helpers - (`compute_commit_bus_offset`, `compute_expected_commit_bus_balance`) that take the + (`compute_commit_bus_offset`, `compute_expected_commit_bus_balance_view`) that take the carried x254 as `start_index`. - `prover/src/tables/trace_builder.rs` — seeds `current_commit_index` from x254 (`read_index`) so committed-byte indexing carries across epochs. diff --git a/prover/src/continuation.rs b/prover/src/continuation.rs index 2e5c56a8b..7e52181e3 100644 --- a/prover/src/continuation.rs +++ b/prover/src/continuation.rs @@ -55,6 +55,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; @@ -69,7 +70,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; @@ -209,9 +211,14 @@ fn l2g_memory_air( /// verifier. Correctness is enforced by the GlobalMemory bus (the genesis token must /// telescope into the epochs' reads), not by ELF recomputation. (Not a ZK/hiding claim — /// the committed column is still opened at STARK query positions.) +/// `preprocessed`, when `Some`, is used directly instead of recomputing the +/// genesis commitment from `config.init_values` — the recursion guest's +/// supplied roots skip the in-VM FFT + Merkle build (see `verify_global`). +/// `None` recomputes from `config` as before. fn global_memory_air( opts: &ProofOptions, config: &PageConfig, + preprocessed: Option, ) -> AirWithBuses { let air = AirWithBuses::new( global_memory::cols::NUM_COLUMNS, @@ -225,11 +232,13 @@ fn global_memory_air( if config.is_private_input { return air; } - let commitment = if config.init_values.is_some() { - page::compute_precomputed_commitment(config, opts) - } else { - page::zero_init_preprocessed_commitment(opts) - }; + let commitment = preprocessed.unwrap_or_else(|| { + if config.init_values.is_some() { + page::compute_precomputed_commitment(config, opts) + } else { + page::zero_init_preprocessed_commitment(opts) + } + }); air.with_preprocessed(commitment, global_memory::NUM_PREPROCESSED_COLS) } @@ -288,6 +297,44 @@ fn global_memory_configs( ) } +/// [`global_memory_configs`], but classification-only: whether each page is +/// ELF-backed (an address-range check against `elf.data` segments) or zero-init +/// — never materializing any byte. Correct ONLY when a supplied genesis root +/// covers every classified-ELF-backed page (see `verify_global`'s caller). +fn global_memory_configs_classify_only( + page_bases: &[u64], + elf: &Elf, + num_private_input_pages: usize, +) -> Vec { + page_bases + .iter() + .map(|&page_base| { + if page::is_private_input_page(page_base, num_private_input_pages) { + PageConfig::with_private_input(page_base, Vec::new()) + } else if elf_page_has_data(elf, page_base) { + PageConfig::with_data(page_base, Vec::new()) + } else { + PageConfig::zero_init(page_base) + } + }) + .collect() +} + +/// Whether any ELF segment overlaps the byte range `[page_base, page_base + DEFAULT_PAGE_SIZE)`. +/// `elf.data` is small (a handful of `PT_LOAD` segments) and sorted by `base_addr`, so this +/// is cheap without needing a full byte-level image. +fn elf_page_has_data(elf: &Elf, page_base: u64) -> bool { + // Saturating: `page_base` can be the stack's page, right at `STACK_TOP = + // 0xFFFFFFFFFFFFFFF0` — `page_base + DEFAULT_PAGE_SIZE` overflows there. + let page_end = page_base.saturating_add(page::DEFAULT_PAGE_SIZE as u64); + elf.data.iter().any(|segment| { + let seg_start = segment.base_addr; + // 4 bytes/word (`Segment::values: Vec`); `executor::elf::WORD_SIZE` is crate-private. + let seg_end = seg_start.saturating_add(segment.values.len() as u64 * 4); + seg_start < page_end && page_base < seg_end + }) +} + /// Shared genesis-config builder for prover and verifier, one `PageConfig` per page base /// in `page_bases` (which must be canonical: sorted + deduped). `init_page_data` holds /// each page's genesis bytes (ELF + private input on the prover side; ELF only on the @@ -404,6 +451,7 @@ impl ContinuationProof { /// INIT = `register_init` and FINI = `reg_fini`. Continuation epochs /// use the L2G bookend, so PAGE is skipped and `page_configs` is empty. The /// epoch-local L2G air is built separately by the caller (it needs the `label`). +#[allow(clippy::too_many_arguments)] fn build_epoch_airs( elf: &Elf, opts: &ProofOptions, @@ -412,6 +460,7 @@ fn build_epoch_airs( register_init: &[u32], reg_fini: &[u32], is_final: bool, + decode_commitment: Option, ) -> VmAirs { // Continuation epochs preprocess FINI = R_{i+1} too (not just INIT = R_i), so the // final register file is a verifier-known public value bound by the REG-C2 @@ -427,7 +476,7 @@ fn build_epoch_airs( false, page_configs, table_counts, - None, + decode_commitment, is_final, None, None, @@ -480,6 +529,7 @@ fn prove_epoch( start.register_init, ®_fini, is_final, + None, ); let label = start.label; @@ -528,25 +578,35 @@ 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, opts: &ProofOptions, + 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; } @@ -558,8 +618,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; } @@ -567,10 +627,11 @@ fn verify_epoch( elf, opts, &[], - &epoch.table_counts, + table_counts, register_init, - &epoch.reg_fini, + reg_fini, is_final, + decode_commitment, ); let l2g_air = l2g_memory_air(opts, label); let mut refs = airs.air_refs(); @@ -579,9 +640,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, ) @@ -594,10 +655,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(), ) { @@ -605,18 +666,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 @@ -670,7 +726,7 @@ fn prove_global( .collect(); let gm_airs: Vec<_> = gm_configs .iter() - .map(|config| global_memory_air(opts, config)) + .map(|config| global_memory_air(opts, config, None)) .collect(); let mut pairs: Vec<(AirRef, &mut TraceTable, &())> = l2g_airs @@ -697,14 +753,16 @@ fn prove_global( .map_err(|e| Error::Prover(format!("{e:?}"))) } +#[allow(clippy::too_many_arguments)] fn verify_global( num_epochs: usize, page_bases: &[u64], - proof: &MultiProof, + proof_views: &[StarkProofView], elf: &Elf, elf_bytes: &[u8], num_private_input_pages: usize, opts: &ProofOptions, + page_genesis_commitments: Option<&[(u64, Commitment)]>, ) -> bool { // One L2G air per epoch, each with its own 1-based `fini_epoch` constant — // must match the order/labels the global proof committed in `prove_global`. @@ -719,10 +777,48 @@ fn verify_global( // recomputes their genesis from the ELF; the GlobalMemory bus enforces them. A // wrong `num_private_input_pages` flips a touched page's preprocessed mode, so the // rebuilt AIR no longer matches the committed trace and `multi_verify` rejects. - let gm_configs = global_memory_configs(page_bases, elf, num_private_input_pages); + // + // `page_genesis_commitments` (the recursion guest's supplied roots) skips the + // per-data-page recompute; a supplied root shifts the genesis binding to the + // attestation fold + consumer recompute, exactly like the monolithic guest's + // `page_commitments`. Zero-init pages always share one commitment, computed + // once here rather than per touched page. + let gm_configs = if page_genesis_commitments.is_some() { + global_memory_configs_classify_only(page_bases, elf, num_private_input_pages) + } else { + global_memory_configs(page_bases, elf, num_private_input_pages) + }; + // Keyed by page NUMBER, not raw page-aligned address: the low PAGE_SIZE_LOG2 bits + // of a page base are always zero, so shifting them off first avoids wasting hash + // input entropy on bits that never vary. + let supplied: HashMap = page_genesis_commitments + .map(|s| { + s.iter() + .map(|&(base, c)| (page::page_number(base), c)) + .collect() + }) + .unwrap_or_default(); + // A missing entry here would silently recompute (slow) instead of failing loudly. + debug_assert!( + page_genesis_commitments.is_none_or(|_| gm_configs + .iter() + .filter(|c| !c.is_private_input && c.init_values.is_some()) + .all(|c| supplied.contains_key(&page::page_number(c.page_base)))), + "page_genesis_commitments is missing an entry for a touched data page", + ); + let zero_init_root = page::zero_init_preprocessed_commitment(opts); let gm_airs: Vec<_> = gm_configs .iter() - .map(|config| global_memory_air(opts, config)) + .map(|config| { + let preprocessed = if config.is_private_input { + None + } else if config.init_values.is_some() { + supplied.get(&page::page_number(config.page_base)).copied() + } else { + Some(zero_init_root) + }; + global_memory_air(opts, config, preprocessed) + }) .collect(); let mut refs: Vec = l2g_airs.iter().map(|a| a as AirRef).collect(); @@ -730,9 +826,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, @@ -924,6 +1020,28 @@ pub fn verify_continuation( elf_bytes: &[u8], bundle: &ContinuationProof, opts: &ProofOptions, +) -> Result>, Error> { + verify_continuation_with_roots(elf_bytes, bundle, opts, None, None) +} + +/// [`verify_continuation`] with caller-supplied ELF-derived roots: the DECODE +/// preprocessed root (shared by every epoch) and the global-memory genesis +/// roots for touched data pages. Supplied roots are used VERBATIM — they are +/// NOT bound to `elf_bytes` here, exactly like `verify_with_options`' supplied +/// roots on the monolithic path. The recursion guest supplies them via private +/// input to skip the in-VM FFT + Merkle recomputes; on success it folds them +/// into the attestation's `program_id`, and the consumer's recompute+compare +/// is what restores the binding. `None` = recompute from the ELF (the +/// trustless host path). +/// +/// KEEP IN SYNC with [`verify_continuation_archived`]: same validation, same +/// order — see the note there. +pub fn verify_continuation_with_roots( + elf_bytes: &[u8], + bundle: &ContinuationProof, + 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 @@ -966,14 +1084,26 @@ pub fn verify_continuation( 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, opts, + decode_commitment, ) { return Ok(None); } @@ -1011,14 +1141,21 @@ pub fn verify_continuation( "touched_page_bases 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, opts, + page_genesis_commitments, ) { return Ok(None); } @@ -1031,6 +1168,166 @@ pub fn verify_continuation( 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). +/// +/// KEEP IN SYNC with [`verify_continuation_with_roots`]: the two must apply +/// the same validation in the same order (page-count bound, `reg_fini` length, +/// per-epoch verify, page-base canonicalization + alignment, global verify, +/// L2G binding). Any check added to one must be mirrored in the other — the +/// archived path processes the same untrusted bundle fields. +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 +/// are what a caller packs as a continuation recursion guest's private input, +/// and what a consumer recomputes to re-bind the guest's attestation. +pub fn continuation_precomputed_commitments( + elf_bytes: &[u8], + bundle: &ContinuationProof, + opts: &ProofOptions, +) -> Result<(Commitment, Vec<(u64, Commitment)>), Error> { + let elf = Elf::load(elf_bytes).map_err(|e| Error::ElfLoad(format!("{e}")))?; + let decode_commitment = crate::tables::decode::commitment_from_elf(&elf, opts) + .map_err(|e| Error::Recursion(format!("DECODE commitment from ELF: {e}")))?; + let page_bases = canonical_page_bases(&bundle.touched_page_bases); + let page_commitments = global_memory_configs(&page_bases, &elf, bundle.num_private_input_pages) + .iter() + .filter(|c| !c.is_private_input && c.init_values.is_some()) + .map(|c| (c.page_base, page::compute_precomputed_commitment(c, opts))) + .collect(); + Ok((decode_commitment, page_commitments)) +} + /// Convenience wrapper: prove then verify in one call (the original integrated API). /// Returns `Ok(Some(public_output))` iff the continuation proves and verifies. pub fn prove_and_verify_continuation( @@ -1130,6 +1427,50 @@ mod tests { ); } + // Supplied genesis roots (the recursion guest's path) must verify identically + // to the trustless recompute, and a tampered supplied root must be rejected. + #[test] + fn test_verify_continuation_with_supplied_roots() { + let elf_bytes = asm_elf_bytes("all_loadstore_32"); + let opts = ProofOptions::default_test_options(); + let bundle = prove_continuation(&elf_bytes, &[], 3, &opts).unwrap(); + + let expected = verify_continuation(&elf_bytes, &bundle, &opts) + .unwrap() + .expect("trustless verify must accept an honest bundle"); + + let (decode_commitment, page_commitments) = + continuation_precomputed_commitments(&elf_bytes, &bundle, &opts).unwrap(); + let got = verify_continuation_with_roots( + &elf_bytes, + &bundle, + &opts, + Some(decode_commitment), + Some(&page_commitments), + ) + .unwrap() + .expect("supplied-roots verify must accept the same honest bundle"); + assert_eq!( + got, expected, + "supplied-roots output must match the recompute path" + ); + + let mut tampered_decode = decode_commitment; + tampered_decode[0] ^= 0xFF; + let rejected = verify_continuation_with_roots( + &elf_bytes, + &bundle, + &opts, + Some(tampered_decode), + Some(&page_commitments), + ) + .unwrap(); + assert!( + rejected.is_none(), + "a tampered supplied DECODE root must be rejected" + ); + } + // Regression for touched-cell prediction from carried registers. A syscall // whose operand pointers live in registers (ECSM reads a0/a1/a2) can have those // registers set in an EARLIER epoch than the call. `test_ecsm_split` sets diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 77c534d48..4004afa8c 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -215,11 +215,25 @@ pub const RECURSION_INPUT_VERSION: u32 = 1; /// Required alignment (bytes) of the archive's first byte in guest memory. pub const RECURSION_INPUT_ALIGN: usize = 16; -/// Aligning prefix length: `magic(4) + version(4) + reserved(4) = 12` bytes, -/// chosen so the archive starts 16-aligned given the executor's +/// Aligning prefix length: `magic(4) + version(4) + kind(1) + reserved(3) = 12` +/// bytes, chosen so the archive starts 16-aligned given the executor's /// `PRIVATE_INPUT_START + 4` payload base. Asserted below. pub const RECURSION_INPUT_PREFIX_LEN: usize = 12; +/// Layout discriminator for the archive that follows the prefix, stored in the +/// first byte of the prefix's former 4-byte reserved field (byte offset 8). +/// Monolithic blobs carry [`RECURSION_INPUT_KIND_MONOLITHIC`]; continuation +/// blobs carry [`RECURSION_INPUT_KIND_CONTINUATION`]. Each guest feature checks +/// the byte at the prefix stage, so feeding a blob of the other layout fails +/// deterministically instead of relying on the rkyv bytecheck happening to +/// reject a differently-shaped archive. Blobs encoded before the kind byte was +/// introduced have zeros there, which is exactly `KIND_MONOLITHIC`, so they +/// remain valid. +pub const RECURSION_INPUT_KIND_MONOLITHIC: u8 = 0; + +/// See [`RECURSION_INPUT_KIND_MONOLITHIC`]. +pub const RECURSION_INPUT_KIND_CONTINUATION: u8 = 1; + const _: () = { let payload_base = (executor::vm::memory::PRIVATE_INPUT_START_INDEX as usize) + 4; let pad = @@ -244,7 +258,7 @@ pub fn encode_recursion_input(input: &GuestInput) -> Result, Error> { let mut blob = Vec::with_capacity(RECURSION_INPUT_PREFIX_LEN + archive.len()); blob.extend_from_slice(&RECURSION_INPUT_MAGIC); blob.extend_from_slice(&RECURSION_INPUT_VERSION.to_le_bytes()); - blob.extend_from_slice(&[0u8; 4]); // reserved + blob.extend_from_slice(&[RECURSION_INPUT_KIND_MONOLITHIC, 0, 0, 0]); // kind + reserved debug_assert_eq!(blob.len(), RECURSION_INPUT_PREFIX_LEN); blob.extend_from_slice(&archive); Ok(blob) @@ -253,6 +267,8 @@ pub fn encode_recursion_input(input: &GuestInput) -> Result, Error> { /// Validate the wire prefix and return the archive bytes (zero-copy slice). /// Returns `None` if the magic or version doesn't match — the caller should /// halt cleanly rather than proceed into an `access_unchecked`. +/// Layout-agnostic: callers that know which archive shape they expect should +/// prefer [`recursion_archive_bytes_for_kind`]. pub fn recursion_archive_bytes(blob: &[u8]) -> Option<&[u8]> { if blob.len() < RECURSION_INPUT_PREFIX_LEN { return None; @@ -267,6 +283,19 @@ pub fn recursion_archive_bytes(blob: &[u8]) -> Option<&[u8]> { Some(&blob[RECURSION_INPUT_PREFIX_LEN..]) } +/// [`recursion_archive_bytes`] plus a layout-kind check: returns `None` unless +/// the blob's kind byte (prefix byte 8) equals `expected_kind`. Lets each guest +/// feature reject the other layout at the prefix stage — a continuation blob +/// fed to the monolithic verifier (or vice versa) now fails here instead of +/// inside the bytecheck of a differently-shaped archive. +pub fn recursion_archive_bytes_for_kind(blob: &[u8], expected_kind: u8) -> Option<&[u8]> { + let archive = recursion_archive_bytes(blob)?; + if blob[8] != expected_kind { + return None; + } + Some(archive) +} + /// 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`. @@ -315,8 +344,12 @@ pub fn verify_recursion_blob<'a>( // 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")))?; + let archive_bytes = recursion_archive_bytes_for_kind(blob, RECURSION_INPUT_KIND_MONOLITHIC) + .ok_or_else(|| { + Error::Execution(String::from( + "recursion blob: bad magic, version, or layout kind", + )) + })?; // 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 @@ -872,26 +905,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 +954,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 +1007,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..d8cae782c 100644 --- a/prover/src/recursion.rs +++ b/prover/src/recursion.rs @@ -223,6 +223,146 @@ 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`]), distinguished by the prefix's +/// kind byte ([`crate::RECURSION_INPUT_KIND_CONTINUATION`]): each guest feature +/// is pinned to one layout, and a blob of the other kind fails the prefix +/// check deterministically, before any archive access. +#[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(&[crate::RECURSION_INPUT_KIND_CONTINUATION, 0, 0, 0]); // kind + 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. +/// +/// Host-side counterpart of [`verify_continuation_and_attest_blob`], kept for +/// API symmetry with the monolithic [`verify_and_attest_blob`]/owned pair; +/// the guest entry point is the `_blob` variant. +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_for_kind(blob, crate::RECURSION_INPUT_KIND_CONTINUATION) + .ok_or_else(|| { + Error::Execution(String::from( + "continuation recursion blob: bad magic, version, or layout kind", + )) + })?; + // 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/tables/page.rs b/prover/src/tables/page.rs index 18ce6b52b..dc7aa5118 100644 --- a/prover/src/tables/page.rs +++ b/prover/src/tables/page.rs @@ -49,6 +49,18 @@ use super::types::{BusId, FE, GoldilocksExtension, GoldilocksField, VmTable}; /// Default page size in bytes (256KB). pub const DEFAULT_PAGE_SIZE: usize = 1 << 18; +/// `page_base` is always a multiple of `DEFAULT_PAGE_SIZE`, which is a power of +/// two — so a page is identified just as cheaply by its shifted page NUMBER, +/// with no low always-zero bits to waste hash entropy on. +const _: () = assert!(DEFAULT_PAGE_SIZE.is_power_of_two()); +pub(crate) const PAGE_SIZE_LOG2: u32 = DEFAULT_PAGE_SIZE.trailing_zeros(); + +/// Shift a page-aligned address down to its page number. +pub(crate) fn page_number(page_base: u64) -> u64 { + debug_assert_eq!(page_base % DEFAULT_PAGE_SIZE as u64, 0, "not page-aligned"); + page_base >> PAGE_SIZE_LOG2 +} + /// Stack top address (where SP starts). Re-exported from executor. pub use executor::vm::registers::STACK_TOP; diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index 527c092c5..e0e7ddf41 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..b605fb1c3 100644 --- a/prover/src/tests/recursion_smoke_test.rs +++ b/prover/src/tests/recursion_smoke_test.rs @@ -531,6 +531,164 @@ 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" + ); +} + +/// Continuation analog of [`test_recursion_rejects_corrupted_commitment`]: +/// verifying an honest bundle against a corrupted DECODE root must fail +/// (`Ok(None)`), not silently accept. +#[test] +fn test_recursion_continuation_rejects_corrupted_commitment() { + 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"); + let (mut decode_commitment, page_commitments) = + crate::continuation::continuation_precomputed_commitments( + &fib_elf_bytes, + &bundle, + &MIN_PROOF_OPTIONS, + ) + .expect("continuation_precomputed_commitments errored"); + decode_commitment[0] ^= 0xFF; + + let verdict = crate::continuation::verify_continuation_with_roots( + &fib_elf_bytes, + &bundle, + &MIN_PROOF_OPTIONS, + Some(decode_commitment), + Some(&page_commitments), + ) + .expect("verify errored"); + assert!( + verdict.is_none(), + "corrupted decode_commitment must be rejected, not silently accepted" + ); +} + +/// The prefix kind byte must discriminate the two guest layouts +/// deterministically: a continuation blob is rejected by the monolithic +/// verifier and vice versa, at the prefix stage (`Err`), before any archive +/// access — no reliance on the rkyv bytecheck happening to fail. +#[test] +fn test_recursion_blob_kind_byte_discriminates_layouts() { + let root = workspace_root(); + let fib_elf_bytes = read_guest_elf(&root, "fibonacci"); + let inner_input = 10u64.to_le_bytes(); + + // An honest continuation blob (kind = CONTINUATION). + let bundle = crate::continuation::prove_continuation( + &fib_elf_bytes, + &inner_input, + 4, + &MIN_PROOF_OPTIONS, + ) + .expect("continuation prove should succeed"); + let continuation_blob = + recursion::encode_continuation_guest_input(bundle, &fib_elf_bytes, &MIN_PROOF_OPTIONS) + .expect("encode_continuation_guest_input failed"); + + // The continuation verifier accepts its own kind... + recursion::verify_continuation_and_attest_blob(&continuation_blob, &MIN_PROOF_OPTIONS) + .expect("verify_continuation_and_attest_blob errored") + .expect("honest continuation blob must verify"); + // ...but the monolithic verifier rejects it at the prefix check. + assert!( + recursion::verify_and_attest_blob(&continuation_blob, &MIN_PROOF_OPTIONS).is_err(), + "monolithic verifier must reject a continuation blob at the prefix stage" + ); + + // And the converse: a monolithic blob is rejected by the continuation verifier. + let empty_elf_bytes = read_guest_elf(&root, "empty"); + let (_vm_proof, monolithic_blob) = prove_inner_and_encode_blob( + "kind-discriminate", + &empty_elf_bytes, + &[], + &MIN_PROOF_OPTIONS, + ); + assert!( + recursion::verify_continuation_and_attest_blob(&monolithic_blob, &MIN_PROOF_OPTIONS) + .is_err(), + "continuation verifier must reject a monolithic blob at the prefix stage" + ); + + // Flipping an honest continuation blob's kind byte to MONOLITHIC makes the + // continuation verifier reject its own otherwise-valid blob. + let mut tampered = continuation_blob; + tampered[8] = crate::RECURSION_INPUT_KIND_MONOLITHIC; + assert!( + recursion::verify_continuation_and_attest_blob(&tampered, &MIN_PROOF_OPTIONS).is_err(), + "continuation verifier must reject a blob whose kind byte was tampered" + ); +} + /// 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