diff --git a/crypto/stark/src/proof/view.rs b/crypto/stark/src/proof/view.rs index 6eb8cedaf..85addd392 100644 --- a/crypto/stark/src/proof/view.rs +++ b/crypto/stark/src/proof/view.rs @@ -11,8 +11,8 @@ use crate::config::Commitment; use crate::frame::Frame; use crate::fri::fri_decommit::{ArchivedFriDecommitment, FriDecommitment}; use crate::proof::stark::{ - ArchivedDeepPolynomialOpening, ArchivedPolynomialOpenings, ArchivedStarkProof, - DeepPolynomialOpening, PolynomialOpenings, StarkProof, + ArchivedDeepPolynomialOpening, ArchivedMultiProof, ArchivedPolynomialOpenings, + ArchivedStarkProof, DeepPolynomialOpening, MultiProof, PolynomialOpenings, StarkProof, }; use crate::table::{ArchivedTable, Table, TableView}; use math::field::element::{ArchivedFieldElement, FieldElement}; @@ -481,6 +481,154 @@ where } } +/// Borrowed view over a [`MultiProof`] (owned or archived-in-place), +/// producing per-proof [`StarkProofView`]s without ever materializing an +/// owned `MultiProof` from an archive. Replaces the +/// `proofs.iter().map(StarkProofView::Owned/Archived).collect()` boilerplate +/// that used to appear at every `MultiProof` verify call site. +pub enum MultiProofView<'a, F: IsSubFieldOf, E: IsField, PI> +where + F::BaseType: math::field::element::NativeArchived, + E::BaseType: math::field::element::NativeArchived, + PI: rkyv::Archive, + ::Archived: rkyv::Deserialize, +{ + Owned(&'a MultiProof), + Archived(&'a ArchivedMultiProof), +} + +impl<'a, F: IsSubFieldOf, E: IsField, PI> Clone for MultiProofView<'a, F, E, PI> +where + F::BaseType: math::field::element::NativeArchived, + E::BaseType: math::field::element::NativeArchived, + PI: rkyv::Archive, + ::Archived: rkyv::Deserialize, +{ + fn clone(&self) -> Self { + *self + } +} +impl<'a, F: IsSubFieldOf, E: IsField, PI> Copy for MultiProofView<'a, F, E, PI> +where + F::BaseType: math::field::element::NativeArchived, + E::BaseType: math::field::element::NativeArchived, + PI: rkyv::Archive, + ::Archived: rkyv::Deserialize, +{ +} + +impl<'a, F: IsSubFieldOf, E: IsField, PI> MultiProofView<'a, F, E, PI> +where + F::BaseType: math::field::element::NativeArchived, + E::BaseType: math::field::element::NativeArchived, + PI: rkyv::Archive, + ::Archived: rkyv::Deserialize, +{ + #[inline(always)] + pub fn len(&self) -> usize { + match self { + Self::Owned(p) => p.proofs.len(), + Self::Archived(p) => p.proofs.len(), + } + } + + #[inline(always)] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + #[inline(always)] + pub fn get(&self, i: usize) -> StarkProofView<'a, F, E, PI> { + match self { + Self::Owned(p) => StarkProofView::Owned(&p.proofs[i]), + Self::Archived(p) => StarkProofView::Archived(&p.proofs.as_slice()[i]), + } + } + + #[inline(always)] + pub fn last(&self) -> Option> { + let len = self.len(); + (len > 0).then(|| self.get(len - 1)) + } + + #[inline(always)] + pub fn iter(&self) -> impl Iterator> + 'a { + let this = *self; + (0..this.len()).map(move |i| this.get(i)) + } +} + +/// A source of [`StarkProofView`]s the verifier can iterate over more than +/// once without ever materializing a `Vec` — implemented for a plain slice +/// (or `Vec`) of views and for [`MultiProofView`] alike, so +/// [`crate::verifier::IsStarkVerifier::multi_verify_views`] runs identically +/// whether its caller already had a slice or is reading straight out of a +/// (owned or archived) `MultiProof`. +pub trait ProofViewSource<'a, F: IsSubFieldOf + 'a, E: IsField + 'a, PI: 'a>: Copy +where + F::BaseType: math::field::element::NativeArchived, + E::BaseType: math::field::element::NativeArchived, + PI: rkyv::Archive, + ::Archived: rkyv::Deserialize, +{ + fn view_len(&self) -> usize; + fn view_iter(&self) -> impl Iterator>; +} + +impl<'a, F: IsSubFieldOf + 'a, E: IsField + 'a, PI: 'a> ProofViewSource<'a, F, E, PI> + for &'a [StarkProofView<'a, F, E, PI>] +where + F::BaseType: math::field::element::NativeArchived, + E::BaseType: math::field::element::NativeArchived, + PI: rkyv::Archive, + ::Archived: rkyv::Deserialize, +{ + #[inline(always)] + fn view_len(&self) -> usize { + self.len() + } + #[inline(always)] + fn view_iter(&self) -> impl Iterator> { + self.iter().copied() + } +} + +impl<'a, F: IsSubFieldOf + 'a, E: IsField + 'a, PI: 'a> ProofViewSource<'a, F, E, PI> + for &'a Vec> +where + F::BaseType: math::field::element::NativeArchived, + E::BaseType: math::field::element::NativeArchived, + PI: rkyv::Archive, + ::Archived: rkyv::Deserialize, +{ + #[inline(always)] + fn view_len(&self) -> usize { + self.len() + } + #[inline(always)] + fn view_iter(&self) -> impl Iterator> { + self.iter().copied() + } +} + +impl<'a, F: IsSubFieldOf + 'a, E: IsField + 'a, PI: 'a> ProofViewSource<'a, F, E, PI> + for MultiProofView<'a, F, E, PI> +where + F::BaseType: math::field::element::NativeArchived, + E::BaseType: math::field::element::NativeArchived, + PI: rkyv::Archive, + ::Archived: rkyv::Deserialize, +{ + #[inline(always)] + fn view_len(&self) -> usize { + MultiProofView::len(self) + } + #[inline(always)] + fn view_iter(&self) -> impl Iterator> { + MultiProofView::iter(self) + } +} + // --------------------------------------------------------------------------- // Field-coverage guards. // diff --git a/crypto/stark/src/tests/bus_tests/soundness_tests.rs b/crypto/stark/src/tests/bus_tests/soundness_tests.rs index 8327aafb2..157802cdf 100644 --- a/crypto/stark/src/tests/bus_tests/soundness_tests.rs +++ b/crypto/stark/src/tests/bus_tests/soundness_tests.rs @@ -1049,7 +1049,7 @@ fn test_malformed_ood_next_block_shape_rejected_archived() { assert!( !Verifier::multi_verify_archived( &airs, - &archived.proofs, + archived, &mut DefaultTranscript::::new(&[]), &FieldElement::zero(), ), @@ -1279,7 +1279,7 @@ fn test_gz_pruning_reduces_next_row_openings() { .unwrap(); assert!(Verifier::multi_verify_archived( &airs, - &archived.proofs, + archived, &mut DefaultTranscript::::new(&[]), &FieldElement::zero(), )); diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index ae26afbe9..64ae24363 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -10,10 +10,10 @@ use crate::{ config::Commitment, domain::new_verifier_domain, lookup::{BusPublicInputs, LOGUP_CHALLENGE_ALPHA, LOGUP_NUM_CHALLENGES, compute_alpha_powers}, - proof::stark::{ArchivedStarkProof, MultiProof}, + proof::stark::{ArchivedMultiProof, MultiProof}, proof::view::{ - DeepPolynomialOpeningView, FriDecommitmentView, PolynomialOpeningsView, StarkProofView, - StarkTableView, + DeepPolynomialOpeningView, FriDecommitmentView, MultiProofView, PolynomialOpeningsView, + ProofViewSource, StarkProofView, StarkTableView, }, table::Table, }; @@ -1095,19 +1095,19 @@ pub trait IsStarkVerifier< FieldElement: AsBytes + Sync + Send, FieldElement: AsBytes + Sync + Send, { - let views: Vec> = multi_proof - .proofs - .iter() - .map(StarkProofView::Owned) - .collect(); - Self::multi_verify_views(airs, &views, transcript, expected_bus_balance) + Self::multi_verify_views( + airs, + MultiProofView::Owned(multi_proof), + transcript, + expected_bus_balance, + ) } /// Verifies one or more rkyv-archived STARK proofs read **in place** from /// their archive buffer — no proof deserialization, no per-field allocation. fn multi_verify_archived( airs: &[&dyn AIR], - proofs: &[ArchivedStarkProof], + multi_proof: &ArchivedMultiProof, transcript: &mut (impl IsStarkTranscript + Clone), expected_bus_balance: &FieldElement, ) -> bool @@ -1115,29 +1115,35 @@ pub trait IsStarkVerifier< FieldElement: AsBytes + Sync + Send, FieldElement: AsBytes + Sync + Send, { - let views: Vec> = - proofs.iter().map(StarkProofView::Archived).collect(); - Self::multi_verify_views(airs, &views, transcript, expected_bus_balance) + Self::multi_verify_views( + airs, + MultiProofView::Archived(multi_proof), + transcript, + expected_bus_balance, + ) } /// The single verification implementation, shared by [`Self::multi_verify`] /// (owned) and [`Self::multi_verify_archived`] (archived), operating on /// proof views rather than either's concrete type. - fn multi_verify_views( + fn multi_verify_views<'p>( airs: &[&dyn AIR], - proofs: &[StarkProofView], + proofs: impl ProofViewSource<'p, Field, FieldExtension, PI>, transcript: &mut (impl IsStarkTranscript + Clone), expected_bus_balance: &FieldElement, ) -> bool where + Field: 'p, + FieldExtension: 'p, + PI: 'p, FieldElement: AsBytes + Sync + Send, FieldElement: AsBytes + Sync + Send, { - if airs.len() != proofs.len() { + if airs.len() != proofs.view_len() { error!( "AIR count ({}) does not match proof count ({})", airs.len(), - proofs.len() + proofs.view_len() ); return false; } @@ -1151,8 +1157,7 @@ pub trait IsStarkVerifier< // For preprocessed tables, use the hardcoded commitment (verifier cannot // trust the prover). For normal tables, use the commitment from the proof. - for (idx, (air, proof)) in airs.iter().zip(proofs).enumerate() { - let proof = *proof; + for (idx, (air, proof)) in airs.iter().zip(proofs.view_iter()).enumerate() { // Soundness: the number of composition-poly parts is fixed by the AIR's // degree bound, NOT chosen by the prover. Deriving it from the proof would // let a malicious prover inflate the part count, widening the composition @@ -1229,8 +1234,7 @@ pub trait IsStarkVerifier< // boundary constraints on LogUp columns, so the bus balance check is // the only cross-table validation. - for (idx, (air, proof)) in airs.iter().zip(proofs).enumerate() { - let proof = *proof; + for (idx, (air, proof)) in airs.iter().zip(proofs.view_iter()).enumerate() { if air.has_trace_interaction() && !proof.has_bus_public_inputs() { error!( "Table {idx}: AIR has LogUp interactions but proof is missing bus_public_inputs" @@ -1252,8 +1256,7 @@ pub trait IsStarkVerifier< // state after Phase B, domain-separated by table index). This matches // the prover's forking and makes per-table verification independent. - for (idx, (air, proof)) in airs.iter().zip(proofs).enumerate() { - let proof = *proof; + for (idx, (air, proof)) in airs.iter().zip(proofs.view_iter()).enumerate() { // Must match prover: fork with domain separator for multi-table, // use original transcript directly for single-table. let num_tables = airs.len(); @@ -1309,7 +1312,7 @@ pub trait IsStarkVerifier< if needs_lookup_challenges { let mut total = FieldElement::::zero(); - for (air, proof) in airs.iter().zip(proofs) { + for (air, proof) in airs.iter().zip(proofs.view_iter()) { if air.has_trace_interaction() && let Some(contribution) = proof.bus_table_contribution() { @@ -1345,7 +1348,7 @@ pub trait IsStarkVerifier< { Self::multi_verify_views( &[air], - &[StarkProofView::Owned(proof)], + &[StarkProofView::Owned(proof)][..], transcript, &FieldElement::zero(), ) diff --git a/executor/programs/asm/data_page_touch.s b/executor/programs/asm/data_page_touch.s new file mode 100644 index 000000000..69920a1e7 --- /dev/null +++ b/executor/programs/asm/data_page_touch.s @@ -0,0 +1,19 @@ + .data + .align 3 +counter: + .dword 0x123456789ABCDEF0 + + .text + .attribute 5, "rv64i2p1" + .globl main +main: + # Touch an ELF .data page: load, mutate, store back a static global so the + # page is genuinely ELF-backed (init_values non-empty), not stack/zero-init. + la t0, counter # 1: t0 = &counter + ld t1, 0(t0) # 2: t1 = counter (0x123456789ABCDEF0) + addi t1, t1, 1 # 3: t1 += 1 + sd t1, 0(t0) # 4: counter = t1 + + li a0, 0 + li a7, 93 + ecall # 5: Halt diff --git a/executor/src/tests/memory_tests.rs b/executor/src/tests/memory_tests.rs index 6c3b3f2ed..b6e0c6568 100644 --- a/executor/src/tests/memory_tests.rs +++ b/executor/src/tests/memory_tests.rs @@ -5,8 +5,8 @@ use crate::vm::memory::{Memory, MemoryError}; #[test] fn test_commit_public_output_single() { let mut memory = Memory::default(); - memory.store_byte(0x100, b'a'); - memory.store_byte(0x101, b'b'); + memory.store_byte(0x100, b'a').unwrap(); + memory.store_byte(0x101, b'b').unwrap(); memory .commit_public_output(0x100, 2) @@ -23,10 +23,10 @@ fn test_commit_public_output_single() { #[test] fn test_commit_public_output_appends() { let mut memory = Memory::default(); - memory.store_byte(0x100, b'a'); - memory.store_byte(0x101, b'b'); - memory.store_byte(0x104, b'c'); - memory.store_byte(0x105, b'd'); + memory.store_byte(0x100, b'a').unwrap(); + memory.store_byte(0x101, b'b').unwrap(); + memory.store_byte(0x104, b'c').unwrap(); + memory.store_byte(0x105, b'd').unwrap(); memory .commit_public_output(0x100, 2) diff --git a/executor/src/vm/instruction/execution.rs b/executor/src/vm/instruction/execution.rs index c92c0ab88..18bd6775a 100644 --- a/executor/src/vm/instruction/execution.rs +++ b/executor/src/vm/instruction/execution.rs @@ -189,7 +189,7 @@ impl Instruction { match width { LoadStoreWidth::Byte => { let value = read_value & 0xFF; - memory.store_byte(addr, value as u8); + memory.store_byte(addr, value as u8)?; } LoadStoreWidth::Half => { let value = read_value & 0xFFFF; diff --git a/executor/src/vm/memory.rs b/executor/src/vm/memory.rs index e1a269a01..8318ea529 100644 --- a/executor/src/vm/memory.rs +++ b/executor/src/vm/memory.rs @@ -38,12 +38,62 @@ impl BuildHasher for U64BuildHasher { pub type U64HashMap = HashMap; +/// DJB2 hasher for the page map's keys. `U64Hasher`'s identity hash collapses +/// hashbrown's SwissTable tag byte (the top 7 bits of the hash) whenever keys +/// are small or share high bits — e.g. sequential page indices — degrading +/// every probe to a full equality check and causing multi-hundred-second +/// slowdowns on large maps. DJB2 is cheap (a handful of ALU ops) yet spreads +/// bits enough to avoid that pathology; kept separate from `U64Hasher` so +/// other identity-hash consumers (e.g. the pc-keyed instruction map) are +/// unaffected. +#[derive(Clone, Copy)] +pub struct Djb2Hasher(u64); + +impl Default for Djb2Hasher { + #[inline] + fn default() -> Self { + Djb2Hasher(5381) + } +} + +impl Hasher for Djb2Hasher { + #[inline] + fn write(&mut self, bytes: &[u8]) { + for &b in bytes { + self.0 = self.0.wrapping_mul(33).wrapping_add(b as u64); + } + } + + #[inline] + fn write_u64(&mut self, i: u64) { + self.write(&i.to_le_bytes()); + } + + #[inline] + fn finish(&self) -> u64 { + self.0 + } +} + +#[derive(Default, Clone)] +pub struct Djb2BuildHasher; + +impl BuildHasher for Djb2BuildHasher { + type Hasher = Djb2Hasher; + #[inline] + fn build_hasher(&self) -> Djb2Hasher { + Djb2Hasher::default() + } +} + +type Djb2HashMap = HashMap; + /// Total cap on public output bytes across all `commit_public_output` calls. /// The COMMIT AIR concatenates calls via the running `x254` index, so this /// is enforced as a running-total budget rather than a per-call limit. pub const MAX_PUBLIC_OUTPUT_TOTAL_SIZE: u64 = 1024 * 1024; -/// Maximum size of the private input memory region (in bytes). 512 MiB so a -/// real proof (e.g. a continuation bundle) fits as private input. +/// Maximum size of the private input payload (in bytes). 512 MiB so a real +/// proof (e.g. a continuation bundle) fits as private input. pub const MAX_PRIVATE_INPUT_SIZE: u64 = 512 * 1024 * 1024; /// Fixed high address where private input is mapped. Guest programs can read /// directly from this address (ZisK-style memory-mapped input). @@ -52,13 +102,56 @@ pub const MAX_PRIVATE_INPUT_SIZE: u64 = 512 * 1024 * 1024; pub const PRIVATE_INPUT_START_INDEX: u64 = 0xFF000000; /// Size in bytes of the private input's wire-format length prefix (the `u32` LE /// written at `PRIVATE_INPUT_START_INDEX` by [`Memory::store_private_inputs`]; the -/// data follows at `+ PRIVATE_INPUT_LENGTH_PREFIX_BYTES`). Single source of truth -/// for every page-span computation over the private-input region. +/// data follows at `+ PRIVATE_INPUT_LENGTH_PREFIX_BYTES`). pub const PRIVATE_INPUT_LENGTH_PREFIX_BYTES: usize = size_of::(); +/// Page size for the whole address space's paged backing store. Memory is +/// backed by a `HashMap>` +/// instead of one hashmap entry per 4-byte word, so growing/rehashing that +/// table only ever moves 8-byte box pointers, never page bytes themselves. +/// Matches the prover's `DEFAULT_PAGE_SIZE` (`prover/src/tables/page.rs`); +/// redeclared locally since the executor must not depend on the prover crate. +const MEMORY_PAGE_SIZE: usize = 256 * 1024; + +/// Keyed by the page-aligned base address rather than a page index: keying +/// by a small sequential index collapsed the SwissTable tag byte (see +/// [`Djb2Hasher`]), which the address's full bit spread avoids independently +/// of the hash function. +#[inline] +fn page_base(address: u64) -> u64 { + address & !(MEMORY_PAGE_SIZE as u64 - 1) +} + +#[inline] +fn page_offset(address: u64) -> usize { + (address & (MEMORY_PAGE_SIZE as u64 - 1)) as usize +} + +/// Allocates a zero-filled page using fallible allocation, so a guest driving +/// memory usage up fails cleanly with [`MemoryError::AllocationFailed`] +/// instead of aborting the host process. +fn try_allocate_page() -> Result, MemoryError> { + let mut buf: Vec = Vec::new(); + buf.try_reserve_exact(MEMORY_PAGE_SIZE) + .map_err(|_| MemoryError::AllocationFailed)?; + buf.resize(MEMORY_PAGE_SIZE, 0); + Ok(buf + .into_boxed_slice() + .try_into() + .expect("length pinned by resize")) +} + #[derive(Default, Debug, Clone)] pub struct Memory { - cells: U64HashMap<[u8; 4]>, + /// Whole-address-space backing store, paged at [`MEMORY_PAGE_SIZE`] + /// granularity: key is the page-aligned base address (see [`page_base`]), + /// value is a heap-boxed page. Only the boxed pointer lives in the + /// hashmap's table slot, so table growth/rehashing moves 8-byte + /// pointers, never page contents. Pages are allocated lazily, + /// zero-filled, on first write. Private input is just memory written at + /// a fixed high address (see [`PRIVATE_INPUT_START_INDEX`]) — it isn't + /// special-cased at this layer. + pages: Djb2HashMap>, /// Bytes committed to public output via `commit_public_output`. The /// COMMIT AIR doesn't write to a fixed memory region (it streams bytes /// onto the Commit bus by `index`), so this buffer is purely the @@ -67,40 +160,65 @@ pub struct Memory { } impl Memory { + /// Fetches the boxed page for `page_idx`, allocating and inserting a + /// zero-filled page on first access (fallible allocation). + fn get_or_insert_page( + &mut self, + page_idx: u64, + ) -> Result<&mut [u8; MEMORY_PAGE_SIZE], MemoryError> { + use std::collections::hash_map::Entry; + let page = match self.pages.entry(page_idx) { + Entry::Occupied(o) => o.into_mut(), + Entry::Vacant(v) => { + let page = try_allocate_page()?; + v.insert(page) + } + }; + Ok(&mut **page) + } + pub fn load_byte(&self, address: u64) -> u8 { - let aligned_address = address - address % 4; - let value = self - .cells - .get(&aligned_address) - .cloned() - .unwrap_or_default(); - value[(address % 4) as usize] + self.pages + .get(&page_base(address)) + .map(|p| p[page_offset(address)]) + .unwrap_or(0) } - pub fn store_byte(&mut self, address: u64, value: u8) { - let aligned_address = address - address % 4; - let entry = self - .cells - .entry(aligned_address) - .or_insert_with(|| [0, 0, 0, 0]); - entry[(address % 4) as usize] = value; + pub fn store_byte(&mut self, address: u64, value: u8) -> Result<(), MemoryError> { + let idx = page_base(address); + let off = page_offset(address); + let page = self.get_or_insert_page(idx)?; + page[off] = value; + Ok(()) } - /// Iterate over all stored bytes as `(address, value)` pairs. Cells are - /// stored as 4-byte words; each word expands into its four byte addresses. - /// Used to snapshot memory at an epoch boundary. + /// Iterate over all stored bytes as `(address, value)` pairs. Each + /// allocated page expands into its byte addresses (unallocated pages + /// contribute nothing, matching bytes never having been written). Used + /// to snapshot memory at an epoch boundary. pub fn iter_bytes(&self) -> impl Iterator + '_ { - self.cells.iter().flat_map(|(&addr, bytes)| { - bytes - .iter() + self.pages.iter().flat_map(|(&base, page)| { + page.iter() .enumerate() - .map(move |(i, &b)| (addr + i as u64, b)) + .map(move |(i, &b)| (base + i as u64, b)) }) } pub fn load_word(&self, address: u64) -> Result { if address.is_multiple_of(4) { - let bytes = self.cells.get(&address).cloned().unwrap_or_default(); + // `MEMORY_PAGE_SIZE` is a multiple of 4, so a 4-aligned word + // address never straddles a page boundary. + let idx = page_base(address); + let off = page_offset(address); + let bytes = self + .pages + .get(&idx) + .map(|p| { + let mut b = [0u8; 4]; + b.copy_from_slice(&p[off..off + 4]); + b + }) + .unwrap_or([0u8; 4]); Ok(u32::from_le_bytes(bytes)) } else { address.checked_add(3).ok_or(MemoryError::AddressOverflow)?; @@ -116,11 +234,14 @@ impl Memory { pub fn store_word(&mut self, address: u64, value: u32) -> Result<(), MemoryError> { let bytes = value.to_le_bytes(); if address.is_multiple_of(4) { - self.cells.insert(address, bytes); + let idx = page_base(address); + let off = page_offset(address); + let page = self.get_or_insert_page(idx)?; + page[off..off + 4].copy_from_slice(&bytes); } else { address.checked_add(3).ok_or(MemoryError::AddressOverflow)?; for (i, b) in bytes.iter().enumerate() { - self.store_byte(address + i as u64, *b); + self.store_byte(address + i as u64, *b)?; } } Ok(()) @@ -129,11 +250,25 @@ impl Memory { /// Load a doubleword (64-bit) from memory - for LD instruction pub fn load_doubleword(&self, address: u64) -> Result { if address.is_multiple_of(8) { - // 8-alignment bounds `address` to `u64::MAX - 7`, so `address + 4` can't overflow. - let low_bytes = self.cells.get(&address).cloned().unwrap_or_default(); - let high_bytes = self.cells.get(&(address + 4)).cloned().unwrap_or_default(); - let low = u32::from_le_bytes(low_bytes) as u64; - let high = u32::from_le_bytes(high_bytes) as u64; + // 8-alignment bounds `address` to `u64::MAX - 7`, so `address + 4` can't + // overflow. `MEMORY_PAGE_SIZE` is a multiple of 8, so an 8-aligned + // doubleword never straddles a page boundary. + let idx = page_base(address); + let off = page_offset(address); + let (low, high) = self + .pages + .get(&idx) + .map(|p| { + let mut low_bytes = [0u8; 4]; + let mut high_bytes = [0u8; 4]; + low_bytes.copy_from_slice(&p[off..off + 4]); + high_bytes.copy_from_slice(&p[off + 4..off + 8]); + ( + u32::from_le_bytes(low_bytes) as u64, + u32::from_le_bytes(high_bytes) as u64, + ) + }) + .unwrap_or((0, 0)); Ok(low | (high << 32)) } else { address.checked_add(7).ok_or(MemoryError::AddressOverflow)?; @@ -151,13 +286,16 @@ impl Memory { let low = (value & 0xFFFFFFFF) as u32; let high = (value >> 32) as u32; // 8-alignment bounds `address` to `u64::MAX - 7`, so `address + 4` can't overflow. - self.cells.insert(address, low.to_le_bytes()); - self.cells.insert(address + 4, high.to_le_bytes()); + let idx = page_base(address); + let off = page_offset(address); + let page = self.get_or_insert_page(idx)?; + page[off..off + 4].copy_from_slice(&low.to_le_bytes()); + page[off + 4..off + 8].copy_from_slice(&high.to_le_bytes()); } else { address.checked_add(7).ok_or(MemoryError::AddressOverflow)?; let bytes = value.to_le_bytes(); for (i, b) in bytes.iter().enumerate() { - self.store_byte(address + i as u64, *b); + self.store_byte(address + i as u64, *b)?; } } Ok(()) @@ -165,14 +303,16 @@ impl Memory { pub fn load_half(&self, address: u64) -> Result { if address.is_multiple_of(2) { - let aligned_address = address - address % 4; + // `MEMORY_PAGE_SIZE` is a multiple of 2, so a 2-aligned half + // address never straddles a page boundary. + let idx = page_base(address); + let off = page_offset(address); let bytes = self - .cells - .get(&aligned_address) - .cloned() - .unwrap_or_default(); - let offset = (address % 4) as usize; - Ok(u16::from_le_bytes([bytes[offset], bytes[offset + 1]])) + .pages + .get(&idx) + .map(|p| [p[off], p[off + 1]]) + .unwrap_or([0, 0]); + Ok(u16::from_le_bytes(bytes)) } else { address.checked_add(1).ok_or(MemoryError::AddressOverflow)?; Ok(u16::from_le_bytes([ @@ -185,18 +325,15 @@ impl Memory { pub fn store_half(&mut self, address: u64, value: u16) -> Result<(), MemoryError> { let bytes = value.to_le_bytes(); if address.is_multiple_of(2) { - let aligned_address = address - address % 4; - let entry = self - .cells - .entry(aligned_address) - .or_insert_with(|| [0, 0, 0, 0]); - let offset = (address % 4) as usize; - entry[offset] = bytes[0]; - entry[offset + 1] = bytes[1]; + let idx = page_base(address); + let off = page_offset(address); + let page = self.get_or_insert_page(idx)?; + page[off] = bytes[0]; + page[off + 1] = bytes[1]; } else { address.checked_add(1).ok_or(MemoryError::AddressOverflow)?; - self.store_byte(address, bytes[0]); - self.store_byte(address + 1, bytes[1]); + self.store_byte(address, bytes[0])?; + self.store_byte(address + 1, bytes[1])?; } Ok(()) } @@ -225,11 +362,15 @@ impl Memory { /// Pre-loads private input bytes at `PRIVATE_INPUT_START_INDEX` as a /// 4-byte LE length prefix followed by the raw data. The guest reads these /// bytes directly via normal RISC-V loads (ZisK-style memory-mapped input). + /// The size cap leaves room for the length prefix so the write can't spill + /// past the `MAX_PRIVATE_INPUT_SIZE` budget the prover's page accounting + /// (`page::private_input_page_count`, computed independently from the raw + /// input length) assumes. pub fn store_private_inputs(&mut self, inputs: Vec) -> Result<(), MemoryError> { if inputs.is_empty() { return Ok(()); } - if inputs.len() as u64 > MAX_PRIVATE_INPUT_SIZE { + if inputs.len() as u64 > MAX_PRIVATE_INPUT_SIZE - PRIVATE_INPUT_LENGTH_PREFIX_BYTES as u64 { return Err(MemoryError::PrivateInputSizeExceeded); } let len_u32 = @@ -250,31 +391,46 @@ impl Memory { .try_reserve_exact(len_usize) .map_err(|_| MemoryError::AllocationFailed)?; while addr < end { - let aligned = addr - (addr % 4); - let bytes = self.cells.get(&aligned).cloned().unwrap_or_default(); - let offset = (addr % 4) as usize; - let take = std::cmp::min(4 - offset, (end - addr) as usize); - result.extend_from_slice(&bytes[offset..offset + take]); + let idx = page_base(addr); + let off = page_offset(addr); + let take = std::cmp::min(MEMORY_PAGE_SIZE - off, (end - addr) as usize); + match self.pages.get(&idx) { + Some(page) => result.extend_from_slice(&page[off..off + take]), + None => result.extend(std::iter::repeat_n(0u8, take)), + } addr += take as u64; } Ok(result) } - /// Helper method to store a given input at an aligned address. It may also overwrite existing bytes with zero if inputs is not divisible by 4 - /// Should only be used to write to public output and private input where these limitations are not a problem + /// Helper method to store a given input at an aligned address, spanning + /// as many pages as the data needs (allocating each lazily, at most + /// once). It may also overwrite existing bytes with zero if inputs is + /// not divisible by 4. Should only be used to write to public output and + /// private input where these limitations are not a problem. pub(crate) fn set_bytes_aligned( &mut self, - mut addr: u64, + addr: u64, inputs: &[u8], ) -> Result<(), MemoryError> { if !addr.is_multiple_of(4) { return Err(MemoryError::UnalignedAccess); } - for chunk in inputs.chunks(4) { - let mut bytes = [0u8; 4]; - bytes[..chunk.len()].copy_from_slice(chunk); - self.cells.insert(addr, bytes); - addr += 4; + let mut cur_addr = addr; + let mut remaining = inputs; + while !remaining.is_empty() { + let idx = page_base(cur_addr); + let off = page_offset(cur_addr); + let space_in_page = MEMORY_PAGE_SIZE - off; + let take = remaining.len().min(space_in_page); + let page = self.get_or_insert_page(idx)?; + page[off..off + take].copy_from_slice(&remaining[..take]); + remaining = &remaining[take..]; + cur_addr += take as u64; + } + let trailing_zeros = (4 - inputs.len() % 4) % 4; + for i in 0..trailing_zeros as u64 { + self.store_byte(cur_addr + i, 0)?; } Ok(()) } @@ -290,7 +446,7 @@ pub enum MemoryError { PrivateInputSizeExceeded, #[error("Address range exceeds u64::MAX")] AddressOverflow, - #[error("Failed to allocate memory for load_bytes")] + #[error("Failed to allocate memory")] AllocationFailed, } @@ -298,9 +454,9 @@ pub enum MemoryError { mod tests { use super::*; - // The wire-format writer and every private-input page-span computation assume the - // length prefix is exactly a 4-byte LE `u32`; pin that so a change to the constant - // is caught rather than silently drifting from the page math. + // The wire-format writer assumes the length prefix is exactly a 4-byte LE + // `u32`; pin that so a change to the constant is caught rather than + // silently drifting. #[test] fn private_input_length_prefix_is_a_le_u32() { assert_eq!(PRIVATE_INPUT_LENGTH_PREFIX_BYTES, 4); @@ -327,4 +483,45 @@ mod tests { .unwrap(); assert_eq!(data, inputs); } + + // A maximal-size private input must be fully readable back, including + // the last 4 bytes that would spill past `MAX_PRIVATE_INPUT_SIZE` if the + // length prefix weren't accounted for in the accepted payload size. + #[test] + fn store_private_inputs_max_size_roundtrips() { + let mut memory = Memory::default(); + let max_payload = + (MAX_PRIVATE_INPUT_SIZE - PRIVATE_INPUT_LENGTH_PREFIX_BYTES as u64) as usize; + let inputs = vec![0x42u8; max_payload]; + memory.store_private_inputs(inputs.clone()).unwrap(); + + let data = memory + .load_bytes( + PRIVATE_INPUT_START_INDEX + PRIVATE_INPUT_LENGTH_PREFIX_BYTES as u64, + inputs.len() as u64, + ) + .unwrap(); + assert_eq!(data, inputs); + } + + #[test] + fn store_private_inputs_rejects_oversized_payload() { + let mut memory = Memory::default(); + let oversized = + (MAX_PRIVATE_INPUT_SIZE - PRIVATE_INPUT_LENGTH_PREFIX_BYTES as u64 + 1) as usize; + let err = memory + .store_private_inputs(vec![0u8; oversized]) + .expect_err("payload that would spill past the region must error"); + assert!(matches!(err, MemoryError::PrivateInputSizeExceeded)); + } + + #[test] + fn set_bytes_aligned_zero_pads_trailing_partial_word() { + let mut memory = Memory::default(); + // Pre-fill so a stale nonzero byte would surface if the trailing + // partial word weren't zeroed. + memory.store_word(0x1000, 0xFFFF_FFFF).unwrap(); + memory.set_bytes_aligned(0x1000, &[0xAB]).unwrap(); + assert_eq!(memory.load_word(0x1000).unwrap(), 0x0000_00AB); + } } diff --git a/prover/src/continuation.rs b/prover/src/continuation.rs index d0e123f9d..169cd7278 100644 --- a/prover/src/continuation.rs +++ b/prover/src/continuation.rs @@ -7,11 +7,14 @@ //! //! The global proof's genesis anchor is bound to the ELF: for ELF/runtime pages the //! verifier recomputes the per-page preprocessed init commitment from the ELF in -//! `verify_global`, so the starting memory cannot be prover-supplied. Private-input -//! pages are the one exception — their genesis is committed (non-preprocessed), exactly -//! as the monolithic prover does, with correctness enforced by the GlobalMemory bus -//! rather than ELF recomputation, so the raw private input is neither carried in the -//! proof bundle nor reconstructed by the verifier. +//! `verify_global` by default, so the starting memory cannot be prover-supplied. +//! `verify_continuation_with_roots` lets a caller supply these roots verbatim +//! instead, deferring binding to the caller's downstream recompute-and-compare +//! (like the monolithic prover's supplied-roots path). Private-input pages are the +//! one exception — their genesis is committed (non-preprocessed), exactly as the +//! monolithic prover does, with correctness enforced by the GlobalMemory bus rather +//! than ELF recomputation, so the raw private input is neither carried in the proof +//! bundle nor reconstructed by the verifier. //! //! Scope of the privacy guarantee: this is NOT zero-knowledge. Like every non-ZK STARK //! column, the committed private genesis is opened at FRI query positions, so this does @@ -55,6 +58,7 @@ use stark::constraints::builder::{ConstraintBuilder, ConstraintSet, EmptyConstra use stark::lookup::{AirWithBuses, AuxiliaryTraceBuildData, NullBoundaryConstraintBuilder}; use stark::proof::options::ProofOptions; use stark::proof::stark::MultiProof; +use stark::proof::view::MultiProofView; use stark::prover::{IsStarkProver, Prover}; use stark::trace::TraceTable; use stark::traits::AIR; @@ -69,7 +73,7 @@ use crate::tables::types::{GoldilocksExtension, GoldilocksField}; use crate::tables::{MaxRowsConfig, global_memory}; use crate::{ Error, FIXED_TABLE_COUNT, RuntimePageRange, TableCounts, VmAirs, - compute_expected_commit_bus_balance, verify_l2g_commitment_binding, + compute_expected_commit_bus_balance_view, verify_l2g_commitment_binding_view, }; type F = GoldilocksField; @@ -151,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). @@ -209,9 +213,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 +234,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 +299,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 @@ -357,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, } @@ -398,12 +447,149 @@ 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 /// 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 +598,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 +614,7 @@ fn build_epoch_airs( false, page_configs, table_counts, - None, + decode_commitment, is_final, None, None, @@ -480,6 +667,7 @@ fn prove_epoch( start.register_init, ®_fini, is_final, + None, ); let label = start.label; @@ -528,26 +716,33 @@ fn prove_epoch( }) } -/// Verify one epoch using ONLY the [`EpochProof`] bundle plus the verifier-derived -/// `register_init` (epoch 0: from the ELF; epoch i>0: from the previous epoch's -/// `reg_fini`), `is_final`, and `label`. Rebuilds the AIRs and transcript -/// from the bundle's statement values and indexes commits from the carried x254 -/// (`register_init[X254_INDEX]`), never from the prover's memory. PAGE is skipped for -/// continuation epochs, so the AIRs are built with no page configs (the bundle does -/// not get to supply any). Returns `true` iff the proof verifies and its committed -/// L2G root matches the claimed one. +/// Verify one epoch using ONLY the epoch's public statement fields (via +/// [`EpochProofView`]) plus the verifier-derived `register_init` (epoch 0: +/// from the ELF; epoch i>0: from the previous epoch's `reg_fini`), `is_final`, +/// and `label`. Rebuilds the AIRs and transcript from the bundle's statement +/// values and indexes commits from the carried x254 +/// (`register_init[X254_INDEX]`), never from the prover's memory. PAGE is +/// skipped for continuation epochs, so the AIRs are built with no page configs +/// (the bundle does not get to supply any). Returns `Ok(true)` iff the proof +/// verifies and its committed L2G root matches the claimed one; `Err` iff a +/// small metadata field failed to materialize off an archived bundle. +/// +/// `epoch` is zero-copy either way: owned or archived (see the two callers). +#[allow(clippy::too_many_arguments)] fn verify_epoch( elf: &Elf, elf_bytes: &[u8], - epoch: &EpochProof, + epoch: EpochProofView<'_>, register_init: &[u32], is_final: bool, label: u64, opts: &ProofOptions, -) -> bool { + decode_commitment: Option, +) -> Result { + let table_counts = epoch.table_counts()?; // Reject degenerate table counts (mirrors the monolithic verifier). - if epoch.table_counts.validate().is_err() { - return false; + if table_counts.validate().is_err() { + return Ok(false); } // Cross-check table_counts before building AIRs from bundle data. Continuation @@ -558,19 +753,25 @@ fn verify_epoch( } else { FIXED_TABLE_COUNT - 1 }; - let expected_proof_count = epoch.table_counts.total() + fixed_tables + 1; - if expected_proof_count != epoch.proof.proofs.len() { - return false; + let proof = epoch.proof(); + let expected_proof_count = table_counts.total() + fixed_tables + 1; + if expected_proof_count != proof.len() { + return Ok(false); } + let reg_fini = epoch.reg_fini()?; + let runtime_page_ranges = epoch.runtime_page_ranges()?; + let public_output = epoch.public_output(); + let airs = build_epoch_airs( elf, opts, &[], - &epoch.table_counts, + &table_counts, register_init, - &epoch.reg_fini, + ®_fini, is_final, + decode_commitment, ); let l2g_air = l2g_memory_air(opts, label); let mut refs = airs.air_refs(); @@ -579,9 +780,9 @@ fn verify_epoch( let seed = || { epoch_transcript( elf_bytes, - &epoch.public_output, - &epoch.table_counts, - &epoch.runtime_page_ranges, + public_output, + &table_counts, + &runtime_page_ranges, label, opts.fri_final_poly_log_degree, ) @@ -594,29 +795,24 @@ fn verify_epoch( .copied() .unwrap_or(0) as u64; - let expected = match compute_expected_commit_bus_balance( + let expected = match compute_expected_commit_bus_balance_view( &refs, - &epoch.proof, - &epoch.public_output, + proof, + public_output, commit_start_index, &mut seed(), ) { Some(expected) => expected, - None => return false, + None => return Ok(false), }; - if !Verifier::multi_verify(&refs, &epoch.proof, &mut seed(), &expected) { - return false; + if !Verifier::multi_verify_views(&refs, proof, &mut seed(), &expected) { + return Ok(false); } // The claimed L2G root must be the one this proof actually committed (it is what - // verify_l2g_commitment_binding later ties to the global proof). - epoch - .proof - .proofs - .last() - .map(|p| p.lde_trace_main_merkle_root) - == Some(epoch.l2g_root) + // verify_l2g_commitment_binding_view later ties to the global proof). + Ok(proof.last().map(|p| *p.lde_trace_main_merkle_root()) == Some(epoch.l2g_root())) } /// Build the cross-epoch global memory proof: every epoch's L2G sub-table on the @@ -670,7 +866,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 +893,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: MultiProofView<'_, F, E, ()>, 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 +917,47 @@ 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 raw page_base, same as the monolithic path's `page_commitments` + // lookup (`lib.rs`). + let supplied: HashMap = page_genesis_commitments + .map(|s| s.iter().copied().collect()) + .unwrap_or_default(); + // A missing entry here would leave `global_memory_air` to recompute over the + // classify-only (empty) `init_values`, yielding the zero-init root instead of + // the real genesis — an honest proof would then fail `multi_verify`, but + // silently and confusingly. Reject explicitly instead. + if page_genesis_commitments.is_some() + && gm_configs + .iter() + .filter(|c| !c.is_private_input && c.init_values.is_some()) + .any(|c| !supplied.contains_key(&c.page_base)) + { + return false; + } + 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(&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,7 +965,7 @@ fn verify_global( refs.push(air as AirRef); } - Verifier::multi_verify( + Verifier::multi_verify_views( &refs, proof, &mut global_transcript( @@ -925,22 +1160,87 @@ pub fn verify_continuation( 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). +pub fn verify_continuation_with_roots( + elf_bytes: &[u8], + bundle: &ContinuationProof, + opts: &ProofOptions, + decode_commitment: Option, + page_genesis_commitments: Option<&[(u64, Commitment)]>, +) -> Result>, Error> { + let result = verify_continuation_view( + ContinuationProofView::Owned(bundle), + elf_bytes, + opts, + decode_commitment, + page_genesis_commitments, + )?; + Ok(result.map(|(public_output, _entry_point)| public_output)) +} + +/// [`verify_continuation_with_roots`]'s zero-copy counterpart, for the +/// recursion `continuation` guest: reads every per-epoch/global proof in +/// place via [`ContinuationProofView::Archived`] instead of deserializing an +/// owned [`MultiProof`]. Only small per-epoch metadata is materialized. Roots +/// are always supplied here (the guest never recomputes from the ELF in-VM). +/// +/// Also returns `entry_point` so callers can fold a `program_id` via +/// [`crate::recursion::program_id_from_digest`] without a second `Elf::load`. +pub(crate) fn verify_continuation_archived( + archived: &ArchivedContinuationProof, + elf_bytes: &[u8], + opts: &ProofOptions, + decode_commitment: Commitment, + page_genesis_commitments: &[(u64, Commitment)], +) -> Result, u64)>, Error> { + verify_continuation_view( + ContinuationProofView::Archived(archived), + elf_bytes, + opts, + Some(decode_commitment), + Some(page_genesis_commitments), + ) +} + +/// Shared implementation behind [`verify_continuation_with_roots`] (owned) and +/// [`verify_continuation_archived`] (archived), operating on a +/// [`ContinuationProofView`] rather than either's concrete type — the same +/// split [`crate::verify_recursion_blob`] uses for the monolithic path. +/// Returns the public output plus `entry_point` (see [`verify_continuation_archived`]). +fn verify_continuation_view( + bundle: ContinuationProofView<'_>, + elf_bytes: &[u8], + opts: &ProofOptions, + decode_commitment: Option, + page_genesis_commitments: Option<&[(u64, Commitment)]>, +) -> Result, u64)>, Error> { // Bound the claimed private-input page count before using it to size/allocate AIRs // (mirrors `verify_with_options`). The count is also bound into the global proof's // Fiat-Shamir statement (`absorb_continuation_global_statement`), so any wrong value // diverges the verifier's challenges and `verify_global`'s `multi_verify` rejects — // on top of the committed-AIR-shape mismatch a wrong count causes on a touched page. let max_private_input_pages = page::max_private_input_pages(); - if bundle.num_private_input_pages > max_private_input_pages { + let num_private_input_pages = bundle.num_private_input_pages(); + if num_private_input_pages > max_private_input_pages { return Err(Error::InvalidTableCounts(format!( - "num_private_input_pages ({}) exceeds max ({max_private_input_pages})", - bundle.num_private_input_pages + "num_private_input_pages ({num_private_input_pages}) exceeds max ({max_private_input_pages})", ))); } let elf = Elf::load(elf_bytes).map_err(|e| Error::ElfLoad(format!("{e}")))?; - let n = bundle.epochs.len(); + let n = bundle.num_epochs(); if n == 0 { return Ok(None); } @@ -948,11 +1248,11 @@ pub fn verify_continuation( // 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); } @@ -962,9 +1262,11 @@ pub fn verify_continuation( let mut epoch_roots: Vec = Vec::with_capacity(n); let mut public_output: Vec = Vec::new(); - for (index, epoch) in bundle.epochs.iter().enumerate() { + for (index, epoch) in bundle.epochs().enumerate() { let is_final = index == n - 1; let label = local_to_global::epoch_label(index as u64); + let l2g_root = epoch.l2g_root(); + let epoch_public_output = epoch.public_output(); if !verify_epoch( &elf, @@ -974,25 +1276,29 @@ pub fn verify_continuation( 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 - // (no private bytes), so the starting memory cannot be prover-chosen; the bus - // telescopes fini→init. Private-input pages are committed, non-preprocessed (genesis - // not bundled/ELF-recomputed), bus-enforced. The verifier needs only the epoch count and the + // (no private bytes) by default, so the starting memory cannot be prover-chosen — + // unless `page_genesis_commitments` supplies it verbatim, deferring binding to the + // caller's recompute-and-compare. Either way the bus telescopes fini→init. + // Private-input pages are committed, non-preprocessed (genesis not + // bundled/ELF-recomputed), bus-enforced. The verifier needs only the epoch count and the // 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 @@ -1011,24 +1317,70 @@ pub fn verify_continuation( "touched_page_bases contains a non-page-aligned entry".to_string(), )); } + // Caller-supplied (not bundle) bases feed the same raw-page_base matching; + // an unaligned one needs the same rejection. + if let Some(commitments) = page_genesis_commitments + && commitments + .iter() + .any(|&(base, _)| base != page::page_base_for_address(base)) + { + return Err(Error::MalformedContinuationBundle( + "page_genesis_commitments contains a non-page-aligned entry".to_string(), + )); + } + let global_proof = bundle.global(); if !verify_global( n, &page_bases, - &bundle.global, + global_proof, &elf, elf_bytes, - bundle.num_private_input_pages, + num_private_input_pages, opts, + page_genesis_commitments, ) { return Ok(None); } // Each epoch's committed L2G table is the same one the global proof used. - if !verify_l2g_commitment_binding(&epoch_roots, &bundle.global) { + if !verify_l2g_commitment_binding_view(&epoch_roots, global_proof) { return Ok(None); } - Ok(Some(public_output)) + Ok(Some((public_output, elf.entry_point))) +} + +/// Precompute the ELF-derived roots [`verify_continuation_with_roots`] accepts: +/// 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> { + // Same bound as `verify_continuation_with_roots`: `bundle` is untrusted + // (rkyv-deserialized), and `num_private_input_pages` feeds a `* page_size` + // multiplication downstream. + let max_private_input_pages = page::max_private_input_pages(); + if bundle.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 + ))); + } + + 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). @@ -1130,6 +1482,120 @@ mod tests { ); } + // Supplied genesis roots must verify identically to the trustless recompute, + // and a tampered root (DECODE or a page) must be rejected. `data_page_touch` + // touches a real ELF `.data` page, unlike this file's stack-only fixtures. + #[test] + fn test_verify_continuation_with_supplied_roots() { + let elf_bytes = asm_elf_bytes("data_page_touch"); + 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(); + assert!( + !page_commitments.is_empty(), + "fixture must touch at least one ELF data page" + ); + 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_page_commitments = page_commitments.clone(); + tampered_page_commitments[0].1[0] ^= 0xFF; + let rejected = verify_continuation_with_roots( + &elf_bytes, + &bundle, + &opts, + Some(decode_commitment), + Some(&tampered_page_commitments), + ) + .unwrap(); + assert!( + rejected.is_none(), + "a tampered supplied page genesis root must be rejected" + ); + + let mut zeroed_page_commitments = page_commitments.clone(); + zeroed_page_commitments[0].1 = [0u8; 32]; + let rejected = verify_continuation_with_roots( + &elf_bytes, + &bundle, + &opts, + Some(decode_commitment), + Some(&zeroed_page_commitments), + ) + .unwrap(); + assert!( + rejected.is_none(), + "an all-zero supplied page genesis root must be rejected" + ); + + 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" + ); + } + + // Locks in the equivalence `verify_global`'s supplied-roots path relies on: + // `global_memory_configs_classify_only` (range-overlap) must classify each page + // identically (same private/data/zero-init kind) to `global_memory_configs` + // (byte-level image), for both a data-touching and a stack-only fixture. + #[test] + fn test_classify_only_matches_byte_level_classification() { + for name in ["data_page_touch", "all_loadstore_32"] { + let elf_bytes = asm_elf_bytes(name); + let opts = ProofOptions::default_test_options(); + let bundle = prove_continuation(&elf_bytes, &[], 3, &opts).unwrap(); + let elf = Elf::load(&elf_bytes).unwrap(); + let page_bases = canonical_page_bases(&bundle.touched_page_bases); + + let byte_level = + global_memory_configs(&page_bases, &elf, bundle.num_private_input_pages); + let classify_only = global_memory_configs_classify_only( + &page_bases, + &elf, + bundle.num_private_input_pages, + ); + + assert_eq!(byte_level.len(), classify_only.len(), "fixture: {name}"); + for (a, b) in byte_level.iter().zip(classify_only.iter()) { + assert_eq!(a.page_base, b.page_base, "fixture: {name}"); + assert_eq!(a.is_private_input, b.is_private_input, "fixture: {name}"); + assert_eq!( + a.init_values.is_some(), + b.init_values.is_some(), + "fixture: {name}, page_base: {}", + a.page_base + ); + } + } + } + // 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 @@ -1692,10 +2158,11 @@ mod tests { ); } - // Negative: corrupting an epoch's claimed L2G table root must be rejected — - // `verify_l2g_commitment_binding` compares each epoch's `l2g_root` against the - // corresponding sub-proof root in the global proof, so a mismatched root causes - // the binding to fail. Guards the L2G root↔global commitment binding. + // Negative: corrupting an epoch's claimed L2G table root must be rejected. This + // tamper is caught by `verify_epoch`'s own root-consistency check (the epoch's + // claimed `l2g_root` no longer matches what its own proof committed) before the + // cross-epoch `verify_l2g_commitment_binding_view` ever runs — see + // `test_split_verify_rejects_global_proof_from_a_different_run` for that. #[test] fn test_split_verify_rejects_tampered_l2g_root() { let _ = env_logger::builder().is_test(true).try_init(); @@ -1713,4 +2180,124 @@ mod tests { .is_none() ); } + + // Same tamper as `test_split_verify_rejects_tampered_l2g_root`, but through the + // zero-copy blob path (`verify_continuation_and_attest`) rather than + // `verify_continuation`. Guards the archived path's per-epoch root check against + // the same corruption the owned path already catches. + #[test] + fn test_continuation_blob_rejects_tampered_l2g_root() { + let _ = env_logger::builder().is_test(true).try_init(); + let elf_bytes = asm_elf_bytes("all_loadstore_32"); + let mut bundle = + prove_continuation(&elf_bytes, &[], 3, &crate::recursion::MIN_PROOF_OPTIONS).unwrap(); + assert!( + bundle.epochs.len() >= 2, + "need multiple epochs to exercise the binding" + ); + bundle.epochs[0].l2g_root[0] ^= 0xFF; + + let blob = crate::recursion::encode_continuation_guest_input( + bundle, + &elf_bytes, + &crate::recursion::MIN_PROOF_OPTIONS, + ) + .expect("encode_continuation_guest_input failed"); + + let result = crate::recursion::verify_continuation_and_attest( + &blob, + &crate::recursion::MIN_PROOF_OPTIONS, + ) + .expect("verify_continuation_and_attest errored"); + assert!( + result.is_none(), + "a tampered l2g_root must be rejected over the archived blob path too" + ); + } + + // Negative: `verify_l2g_commitment_binding_view`'s own reject branch, which the two + // tests above don't reach (they're caught earlier by `verify_epoch`'s per-epoch root + // check). Two bundles proved from the same ELF/epoch size with different + // same-length private inputs share every shape value (`n`, `table_counts`, + // `touched_page_bases`, `num_private_input_pages`) but commit different actual L2G + // data, so splicing one's `global` proof onto the other's epochs leaves every + // per-epoch check and `verify_global`'s own `multi_verify` passing (each half is + // independently valid for that exact shape) while the per-epoch claimed roots no + // longer match what the spliced-in global proof's L2G sub-tables actually commit. + #[test] + fn test_split_verify_rejects_global_proof_from_a_different_run() { + let _ = env_logger::builder().is_test(true).try_init(); + let elf_bytes = asm_elf_bytes("test_private_input_xpage"); + let opts = ProofOptions::default_test_options(); + + let input_a: Vec = (0u8..16).collect(); + let input_b: Vec = (0u8..16).map(|b| b ^ 0xFF).collect(); + + let mut bundle_a = prove_continuation(&elf_bytes, &input_a, 2, &opts).unwrap(); + let bundle_b = prove_continuation(&elf_bytes, &input_b, 2, &opts).unwrap(); + assert!( + verify_continuation(&elf_bytes, &bundle_a, &opts) + .unwrap() + .is_some(), + "bundle_a must verify standalone before splicing" + ); + assert!( + verify_continuation(&elf_bytes, &bundle_b, &opts) + .unwrap() + .is_some(), + "bundle_b must verify standalone before splicing" + ); + assert_eq!( + bundle_a.epochs.len(), + bundle_b.epochs.len(), + "same ELF/epoch size/input length must yield the same epoch split" + ); + assert_eq!( + bundle_a.touched_page_bases, bundle_b.touched_page_bases, + "same-length private inputs must touch the same pages" + ); + assert_ne!( + bundle_a.epochs[0].l2g_root, bundle_b.epochs[0].l2g_root, + "different private-input bytes must commit different L2G data" + ); + + bundle_a.global = bundle_b.global; + + assert!( + verify_continuation(&elf_bytes, &bundle_a, &opts) + .unwrap() + .is_none(), + "a global proof spliced in from a different run must be rejected" + ); + } + + // Same construction as `test_split_verify_rejects_global_proof_from_a_different_run`, + // but through the zero-copy blob path — guards + // `verify_l2g_commitment_binding_view`'s archived call site. + #[test] + fn test_continuation_blob_rejects_global_proof_from_a_different_run() { + let _ = env_logger::builder().is_test(true).try_init(); + let elf_bytes = asm_elf_bytes("test_private_input_xpage"); + let opts = crate::recursion::MIN_PROOF_OPTIONS; + + let input_a: Vec = (0u8..16).collect(); + let input_b: Vec = (0u8..16).map(|b| b ^ 0xFF).collect(); + + let mut bundle_a = prove_continuation(&elf_bytes, &input_a, 2, &opts).unwrap(); + let bundle_b = prove_continuation(&elf_bytes, &input_b, 2, &opts).unwrap(); + assert_eq!(bundle_a.epochs.len(), bundle_b.epochs.len()); + assert_eq!(bundle_a.touched_page_bases, bundle_b.touched_page_bases); + assert_ne!(bundle_a.epochs[0].l2g_root, bundle_b.epochs[0].l2g_root); + + bundle_a.global = bundle_b.global; + + let blob = crate::recursion::encode_continuation_guest_input(bundle_a, &elf_bytes, &opts) + .expect("encode_continuation_guest_input failed"); + let result = crate::recursion::verify_continuation_and_attest(&blob, &opts) + .expect("verify_continuation_and_attest errored"); + assert!( + result.is_none(), + "a global proof spliced in from a different run must be rejected over the archived blob path too" + ); + } } diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 77c534d48..ff9601bb4 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -66,7 +66,7 @@ use crate::test_utils::{ pub use stark::config::Commitment; pub use stark::proof::options::{GoldilocksCubicProofOptions, ProofOptions}; use stark::proof::stark::MultiProof; -use stark::proof::view::StarkProofView; +use stark::proof::view::{MultiProofView, ProofViewSource}; /// A run-length encoded range of contiguous zero-initialized 4KB pages. /// @@ -267,6 +267,39 @@ pub fn recursion_archive_bytes(blob: &[u8]) -> Option<&[u8]> { Some(&blob[RECURSION_INPUT_PREFIX_LEN..]) } +/// Validate a recursion-input blob's wire prefix and bytecheck-validate its +/// archive, in place. Shared by [`verify_recursion_blob`] and +/// [`crate::recursion::verify_continuation_and_attest`]. +/// +/// Returns the archived value, the original (possibly-unaligned) archive +/// bytes, and the base pointer `archived` reads from — callers rebasing +/// zero-copy subslices back onto `blob` need that base. +pub(crate) fn access_recursion_archive<'s, 'a: 's, T>( + blob: &'a [u8], + aligned_fallback: &'s mut rkyv::util::AlignedVec, +) -> Result<(&'s T, &'a [u8], *const u8), Error> +where + T: rkyv::Portable + + for<'b> rkyv::bytecheck::CheckBytes>, +{ + let archive_bytes: &'a [u8] = recursion_archive_bytes(blob) + .ok_or_else(|| Error::Execution(String::from("recursion blob: bad magic or version")))?; + + let archive: &'s [u8] = + if (archive_bytes.as_ptr() as usize).is_multiple_of(RECURSION_INPUT_ALIGN) { + archive_bytes + } else { + aligned_fallback.extend_from_slice(archive_bytes); + aligned_fallback + }; + let archive_base = archive.as_ptr(); + + let archived: &'s T = rkyv::access::(archive).map_err(|e| { + Error::Execution(format!("recursion blob: bytecheck validation failed: {e}")) + })?; + Ok((archived, archive_bytes, archive_base)) +} + /// Result of a recursion-blob verification: the verdict plus the inner /// proof's committed public output (zero-copy from the blob), which the /// recursion guest folds into `program_id(...) ‖ public_output`. @@ -310,31 +343,15 @@ pub fn verify_recursion_blob<'a>( ) -> Result, Error> { use rkyv::rancor::Error as RkyvError; - // Validate + strip the aligning magic/version prefix. In the guest the - // returned slice starts at the 16-aligned archive base (the prefix exists - // precisely so the archive lands aligned at + // In the guest the blob's archive starts at the 16-aligned archive base + // (the wire prefix exists precisely so the archive lands aligned at // `PRIVATE_INPUT_START + 4 + PREFIX_LEN`), so the in-place doubleword - // loads do not trap. - let archive_bytes = recursion_archive_bytes(blob) - .ok_or_else(|| Error::Execution(String::from("recursion blob: bad magic or version")))?; - - // A host caller's buffer carries no alignment guarantee (`Vec` is - // align-1) — in-place access there would be UB. Fall back to one aligned - // copy when the base is misaligned; the guest path is aligned by - // construction and stays zero-copy. - let mut aligned_fallback = rkyv::util::AlignedVec::<{ RECURSION_INPUT_ALIGN }>::new(); - let archive: &[u8] = if (archive_bytes.as_ptr() as usize).is_multiple_of(RECURSION_INPUT_ALIGN) - { - archive_bytes - } else { - aligned_fallback.extend_from_slice(archive_bytes); - &aligned_fallback - }; - - // `blob` is untrusted; validate before the zero-copy access. - let archived = rkyv::access::(archive).map_err(|e| { - Error::Execution(format!("recursion blob: bytecheck validation failed: {e}")) - })?; + // loads do not trap. A host caller's buffer carries no such guarantee + // (`Vec` is align-1), so `access_recursion_archive` falls back to one + // aligned copy in `aligned_fallback` when the base is misaligned. + let mut aligned_fallback = rkyv::util::AlignedVec::::new(); + let (archived, archive_bytes, archive_base): (&ArchivedGuestInput, &[u8], *const u8) = + access_recursion_archive(blob, &mut aligned_fallback)?; // Materialize only the small metadata; the proof stays in the buffer. let table_counts: TableCounts = @@ -356,11 +373,11 @@ pub fn verify_recursion_blob<'a>( let public_output: &[u8] = archived.vm_proof.public_output.as_slice(); let decode_commitment: Commitment = archived.decode_commitment; - // Rebase the returned slices onto the caller's buffer: `archive` may be - // the aligned fallback copy, whose lifetime ends with this call. Same - // bytes at the same offsets in both buffers. + // Rebase the returned slices onto the caller's buffer: `archived` may + // point into the aligned fallback copy, whose lifetime ends with this + // call. Same bytes at the same offsets in both buffers. let rebase = |s: &[u8]| -> &'a [u8] { - let offset = s.as_ptr() as usize - archive.as_ptr() as usize; + let offset = s.as_ptr() as usize - archive_base as usize; &archive_bytes[offset..offset + s.len()] }; let inner_elf_rebased = rebase(inner_elf); @@ -372,16 +389,8 @@ pub fn verify_recursion_blob<'a>( let program = Elf::load(inner_elf).map_err(|e| Error::ElfLoad(format!("{e}")))?; let elf_digest = statement::elf_digest(inner_elf); - let views: Vec> = archived - .vm_proof - .proof - .proofs - .as_slice() - .iter() - .map(StarkProofView::Archived) - .collect(); let ok = verify_proof_parts( - &views, + MultiProofView::Archived(&archived.vm_proof.proof), &table_counts, &runtime_page_ranges, num_private_input_pages, @@ -872,26 +881,6 @@ impl VmAirs { // Bus Balance Target: Verifier-Computed COMMIT Output Bus // ============================================================================= -/// Replay the prover's Phase A (main trace commitments) to recover the shared -/// LogUp challenges (z, alpha). Creates a fresh transcript, appends all main -/// trace commitments in the same order as the prover, then samples two -/// challenge elements. -pub(crate) fn replay_transcript_phase_a( - airs: &[&dyn AIR], - multi_proof: &MultiProof, - transcript: &mut DefaultTranscript, -) -> (FieldElement, FieldElement) { - for (air, proof) in airs.iter().zip(&multi_proof.proofs) { - if air.is_preprocessed() { - transcript.append_bytes(&air.precomputed_commitment()); - } - transcript.append_bytes(&proof.lde_trace_main_merkle_root); - } - let z: FieldElement = transcript.sample_field_element(); - let alpha: FieldElement = transcript.sample_field_element(); - (z, alpha) -} - /// Compute the bus balance offset for the COMMIT[index, value] bus. /// /// For each public output byte at index `i` with value `v`: @@ -941,31 +930,15 @@ pub(crate) fn compute_commit_bus_offset( ) } -/// Compute the expected COMMIT bus balance for a `MultiProof`. -/// -/// Replays Phase A of the transcript to recover (z, alpha), then computes -/// the offset from the given public output bytes. Call this after `multi_prove` -/// and before `multi_verify`. -pub(crate) fn compute_expected_commit_bus_balance( - airs: &[&dyn AIR], - proof: &MultiProof, - public_output_bytes: &[u8], - start_index: u64, - transcript: &mut DefaultTranscript, -) -> Option> { - let (z, alpha) = replay_transcript_phase_a(airs, proof, transcript); - compute_commit_bus_offset(public_output_bytes, start_index, &z, &alpha) -} - -/// View counterpart of [`replay_transcript_phase_a`]: replays Phase A over a -/// proof view (owned or archived-in-place), with no `MultiProof` -/// deserialization required either way. -pub(crate) fn replay_transcript_phase_a_view( +/// Replay the prover's Phase A (main trace commitments) to recover the shared +/// LogUp challenges (z, alpha), over a proof view (owned or archived-in-place) +/// — no `MultiProof` deserialization required either way. +pub(crate) fn replay_transcript_phase_a_view<'p>( airs: &[&dyn AIR], - proofs: &[StarkProofView], + proofs: impl ProofViewSource<'p, F, E, ()>, transcript: &mut DefaultTranscript, ) -> (FieldElement, FieldElement) { - for (air, proof) in airs.iter().zip(proofs) { + for (air, proof) in airs.iter().zip(proofs.view_iter()) { if air.is_preprocessed() { transcript.append_bytes(&air.precomputed_commitment()); } @@ -976,11 +949,11 @@ pub(crate) fn replay_transcript_phase_a_view( (z, alpha) } -/// View counterpart of [`compute_expected_commit_bus_balance`]: operates on a -/// proof view slice (owned or archived-in-place). -pub(crate) fn compute_expected_commit_bus_balance_view( +/// Computes the expected COMMIT bus balance for a proof view slice (owned or +/// archived-in-place). +pub(crate) fn compute_expected_commit_bus_balance_view<'p>( airs: &[&dyn AIR], - proofs: &[StarkProofView], + proofs: impl ProofViewSource<'p, F, E, ()>, public_output_bytes: &[u8], start_index: u64, transcript: &mut DefaultTranscript, @@ -992,22 +965,25 @@ pub(crate) fn compute_expected_commit_bus_balance_view( /// Bind the final cross-epoch GlobalMemory proof to the per-epoch proofs. /// /// The final proof commits one local-to-global sub-table per epoch as its first -/// `N` tables, so `final_proof.proofs[i].lde_trace_main_merkle_root` is epoch +/// `N` tables, so `final_proof.get(i).lde_trace_main_merkle_root()` is epoch /// `i`'s L2G commitment. `epoch_l2g_roots[i]` is the same root as committed in /// epoch `i`'s own proof. Equal roots prove the cross-epoch matching ran over /// the very same L2G tables the epochs committed (shared commitments). /// -/// Called by `continuation::verify_continuation`; also exercised by the +/// `final_proof` is a [`MultiProofView`] (owned or archived-in-place), so this +/// reads straight off either representation with no `MultiProof` deserialization. +/// +/// Called by `continuation::verify_continuation_view`; also exercised by the /// local-to-global bus tests. -pub(crate) fn verify_l2g_commitment_binding( +pub(crate) fn verify_l2g_commitment_binding_view( epoch_l2g_roots: &[Commitment], - final_proof: &MultiProof, + final_proof: MultiProofView<'_, F, E, ()>, ) -> bool { - final_proof.proofs.len() >= epoch_l2g_roots.len() + final_proof.len() >= epoch_l2g_roots.len() && epoch_l2g_roots .iter() .enumerate() - .all(|(i, root)| final_proof.proofs[i].lde_trace_main_merkle_root == *root) + .all(|(i, root)| *final_proof.get(i).lde_trace_main_merkle_root() == *root) } // ============================================================================= @@ -1300,15 +1276,8 @@ pub(crate) fn verify_prepared( decode_commitment: Option, page_commitments: Option<&[(u64, Commitment)]>, ) -> Result { - let views: Vec> = vm_proof - .proof - .proofs - .iter() - .map(StarkProofView::Owned) - .collect(); - verify_proof_parts( - &views, + MultiProofView::Owned(&vm_proof.proof), &vm_proof.table_counts, &vm_proof.runtime_page_ranges, vm_proof.num_private_input_pages, @@ -1324,12 +1293,12 @@ pub(crate) fn verify_prepared( /// The single VM-proof verification implementation, given the proof's /// metadata fields plus an already-parsed ELF and its digest. Both /// [`verify_prepared`] (owned proof) and [`verify_recursion_blob`] (guest -/// blob, zero-copy) funnel here, passing a [`StarkProofView`] slice over -/// their respective (owned or archived) proof data — no serialization, no +/// blob, zero-copy) funnel here, passing a [`MultiProofView`] over their +/// respective (owned or archived) proof data — no serialization, no /// duplicated verification logic, and no repeated `Elf::load`/digest. #[allow(clippy::too_many_arguments)] fn verify_proof_parts( - proofs: &[StarkProofView], + proofs: MultiProofView<'_, F, E, ()>, table_counts: &TableCounts, runtime_page_ranges: &[RuntimePageRange], num_private_input_pages: usize, diff --git a/prover/src/recursion.rs b/prover/src/recursion.rs index 6bb3b1247..654b58fa4 100644 --- a/prover/src/recursion.rs +++ b/prover/src/recursion.rs @@ -223,6 +223,116 @@ pub fn verify_and_attest_blob( Ok(Some(attestation)) } +/// The continuation guest's private-input layout (the `continuation` guest +/// feature). Mirrors [`crate::GuestInput`] with the monolithic proof replaced +/// by the bundle and the PAGE roots replaced by the global-memory genesis +/// roots (see [`crate::continuation::continuation_precomputed_commitments`]). +/// Rkyv-archived on the same magic-prefixed wire format as the monolithic +/// blob ([`crate::encode_recursion_input`]); the guest is feature-pinned to +/// one layout, and a blob of the other kind fails the bytecheck validation. +#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +pub struct ContinuationGuestInput { + pub bundle: crate::continuation::ContinuationProof, + pub inner_elf: Vec, + pub decode_commitment: Commitment, + pub page_commitments: Vec<(u64, Commitment)>, +} + +/// Build the continuation guest's private-input blob for `bundle` of +/// `inner_elf`: precomputes the roots and rkyv-encodes a +/// [`ContinuationGuestInput`] behind the standard aligning prefix. Takes the +/// bundle by value (it is large; the encoder is its last consumer). +pub fn encode_continuation_guest_input( + bundle: crate::continuation::ContinuationProof, + inner_elf: &[u8], + opts: &ProofOptions, +) -> Result, Error> { + let (decode_commitment, page_commitments) = + crate::continuation::continuation_precomputed_commitments(inner_elf, &bundle, opts)?; + let input = ContinuationGuestInput { + bundle, + inner_elf: inner_elf.to_vec(), + decode_commitment, + page_commitments, + }; + let archive = rkyv::to_bytes::(&input) + .map_err(|e| Error::Execution(format!("rkyv encode failed: {e}")))?; + let mut blob = Vec::with_capacity(crate::RECURSION_INPUT_PREFIX_LEN + archive.len()); + blob.extend_from_slice(&crate::RECURSION_INPUT_MAGIC); + blob.extend_from_slice(&crate::RECURSION_INPUT_VERSION.to_le_bytes()); + blob.extend_from_slice(&[0u8; 4]); // reserved + debug_assert_eq!(blob.len(), crate::RECURSION_INPUT_PREFIX_LEN); + blob.extend_from_slice(&archive); + Ok(blob) +} + +/// [`verify_and_attest_blob`]'s logic for a continuation bundle: takes the +/// wire-format blob ([`encode_continuation_guest_input`]) and does the +/// intended `continuation` guest's whole job in one call — verify every +/// epoch + the global memory proof against the supplied roots, then attest +/// `program_id(elf, roots) || public_output`. Uses the same [`program_id`] as +/// the monolithic path over the continuation's root set (DECODE + touched +/// data-page genesis roots), so a consumer re-binds with +/// [`crate::continuation::continuation_precomputed_commitments`] over the +/// bundle it holds — the touched-page set is bundle-dependent, unlike the +/// monolithic path's ELF-only page set. The archive is bytecheck-validated, +/// then verified zero-copy via +/// [`crate::continuation::verify_continuation_archived`] — no owned +/// deserialize of the (large) bundle, same as [`crate::verify_recursion_blob`] +/// for the monolithic proof. +pub fn verify_continuation_and_attest( + blob: &[u8], + proof_options: &ProofOptions, +) -> Result>, Error> { + use rkyv::rancor::Error as RkyvError; + + let archive_bytes = crate::recursion_archive_bytes(blob).ok_or_else(|| { + Error::Execution(String::from( + "continuation recursion blob: bad magic or version", + )) + })?; + // Host callers' Vec carries no alignment guarantee; the guest slice is + // aligned by construction (same prefix arithmetic as the monolithic blob). + let mut aligned_fallback = rkyv::util::AlignedVec::<{ crate::RECURSION_INPUT_ALIGN }>::new(); + let archive: &[u8] = + if (archive_bytes.as_ptr() as usize).is_multiple_of(crate::RECURSION_INPUT_ALIGN) { + archive_bytes + } else { + aligned_fallback.extend_from_slice(archive_bytes); + &aligned_fallback + }; + let archived = rkyv::access::(archive) + .map_err(|e| Error::Execution(format!("continuation blob validation failed: {e}")))?; + + // Only small metadata here; the bundle's proofs stay in the archive (read + // in place by `verify_continuation_archived`). + let page_commitments: Vec<(u64, Commitment)> = rkyv::deserialize::< + Vec<(u64, Commitment)>, + RkyvError, + >(&archived.page_commitments) + .map_err(|e| Error::Execution(format!("rkyv deserialize page commitments failed: {e}")))?; + let decode_commitment: Commitment = archived.decode_commitment; + let inner_elf: &[u8] = archived.inner_elf.as_slice(); + + let Some((public_output, entry_point)) = crate::continuation::verify_continuation_archived( + &archived.bundle, + inner_elf, + proof_options, + decode_commitment, + &page_commitments, + )? + else { + return Ok(None); + }; + + // Avoids a second `Elf::load` (already done by `verify_continuation_archived`). + let digest = elf_digest(inner_elf); + let id = program_id_from_digest(&digest, entry_point, &decode_commitment, &page_commitments); + let mut attestation = id.to_vec(); + attestation.extend_from_slice(&public_output); + Ok(Some(attestation)) +} + /// Split committed attestation bytes into `(program_id, inner_public_output)`. /// `None` if too short to contain an id. pub fn split_attestation(committed: &[u8]) -> Option<([u8; 32], &[u8])> { diff --git a/prover/src/tests/local_to_global_bus_tests.rs b/prover/src/tests/local_to_global_bus_tests.rs index 2234208df..8025596d6 100644 --- a/prover/src/tests/local_to_global_bus_tests.rs +++ b/prover/src/tests/local_to_global_bus_tests.rs @@ -18,6 +18,7 @@ use stark::lookup::{ }; use stark::proof::options::ProofOptions; use stark::proof::stark::MultiProof; +use stark::proof::view::MultiProofView; use stark::trace::TraceTable; use stark::traits::AIR; use stark::verifier::{IsStarkVerifier, Verifier}; @@ -537,7 +538,10 @@ fn test_l2g_binding_holds() { let final_proof = prove_global(&boundaries); let roots: Vec = boundaries.iter().map(|b| l2g_root(b)).collect(); - assert!(crate::verify_l2g_commitment_binding(&roots, &final_proof)); + assert!(crate::verify_l2g_commitment_binding_view( + &roots, + MultiProofView::Owned(&final_proof) + )); } #[test] @@ -560,7 +564,10 @@ fn test_l2g_binding_rejects_mismatch() { tampered[0][0].fini.value = 999; let final_proof = prove_global(&tampered); - assert!(!crate::verify_l2g_commitment_binding(&roots, &final_proof)); + assert!(!crate::verify_l2g_commitment_binding_view( + &roots, + MultiProofView::Owned(&final_proof) + )); } // ========================================================================= diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index 864e4e3f9..ffe9071b2 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -18,6 +18,7 @@ use math::field::element::FieldElement; use stark::constraints::builder::EmptyConstraints; use stark::lookup::{AirWithBuses, AuxiliaryTraceBuildData}; use stark::proof::options::ProofOptions; +use stark::proof::view::{MultiProofView, StarkProofView}; use stark::traits::AIR; use stark::verifier::{IsStarkVerifier, Verifier}; @@ -75,10 +76,15 @@ fn prove_and_verify_vm_minimal(elf: &Elf, traces: &mut Traces) -> bool { }; // Compute the verifier-side expected COMMIT bus balance from public output bytes + let views: Vec> = multi_proof + .proofs + .iter() + .map(StarkProofView::Owned) + .collect(); let mut replay_transcript = DefaultTranscript::::new(&[]); - let expected_bus_balance = crate::compute_expected_commit_bus_balance( + let expected_bus_balance = crate::compute_expected_commit_bus_balance_view( &airs.air_refs(), - &multi_proof, + &views, &traces.public_output_bytes, 0, &mut replay_transcript, @@ -86,9 +92,9 @@ fn prove_and_verify_vm_minimal(elf: &Elf, traces: &mut Traces) -> bool { .expect("fingerprint collision in test"); // Verify using centralized air_refs() which includes all tables - Verifier::multi_verify( + Verifier::multi_verify_views( &airs.air_refs(), - &multi_proof, + &views, &mut DefaultTranscript::::new(&[]), &expected_bus_balance, ) @@ -163,18 +169,24 @@ fn verify_vm_minimal(vm_proof: &VmProof, elf_bytes: &[u8]) -> bool { None, ); let air_refs = airs.air_refs(); + let views: Vec> = vm_proof + .proof + .proofs + .iter() + .map(StarkProofView::Owned) + .collect(); let mut replay_transcript = DefaultTranscript::::new(&[]); - let expected_bus_balance = crate::compute_expected_commit_bus_balance( + let expected_bus_balance = crate::compute_expected_commit_bus_balance_view( &air_refs, - &vm_proof.proof, + &views, &vm_proof.public_output, 0, &mut replay_transcript, ) .expect("fingerprint collision in test"); - Verifier::multi_verify( + Verifier::multi_verify_views( &air_refs, - &vm_proof.proof, + &views, &mut DefaultTranscript::::new(&[]), &expected_bus_balance, ) @@ -1378,19 +1390,21 @@ fn test_prove_elfs_test_commit_4_wrong_pages_rejected() { None, ); let verifier_air_refs = verifier_airs.air_refs(); + let views: Vec> = + proof.proofs.iter().map(StarkProofView::Owned).collect(); let mut replay_transcript = DefaultTranscript::::new(&[]); - let expected_bus_balance = crate::compute_expected_commit_bus_balance( + let expected_bus_balance = crate::compute_expected_commit_bus_balance_view( &verifier_air_refs, - &proof, + &views, &traces.public_output_bytes, 0, &mut replay_transcript, ) .expect("fingerprint collision in test"); - let verified = Verifier::multi_verify( + let verified = Verifier::multi_verify_views( &verifier_air_refs, - &proof, + &views, &mut DefaultTranscript::::new(&[]), &expected_bus_balance, ); @@ -2133,19 +2147,21 @@ fn test_deep_stack_runtime_pages_roundtrip() { None, ); let verifier_air_refs = verifier_airs.air_refs(); + let views: Vec> = + proof.proofs.iter().map(StarkProofView::Owned).collect(); let mut replay_transcript = DefaultTranscript::::new(&[]); - let expected_bus_balance = crate::compute_expected_commit_bus_balance( + let expected_bus_balance = crate::compute_expected_commit_bus_balance_view( &verifier_air_refs, - &proof, + &views, &traces.public_output_bytes, 0, &mut replay_transcript, ) .expect("fingerprint collision in test"); - let verified = Verifier::multi_verify( + let verified = Verifier::multi_verify_views( &verifier_air_refs, - &proof, + &views, &mut DefaultTranscript::::new(&[]), &expected_bus_balance, ); @@ -2206,19 +2222,21 @@ fn test_deep_stack_missing_pages_rejected() { None, ); let verifier_air_refs = verifier_airs.air_refs(); + let views: Vec> = + proof.proofs.iter().map(StarkProofView::Owned).collect(); let mut replay_transcript = DefaultTranscript::::new(&[]); - let expected_bus_balance = crate::compute_expected_commit_bus_balance( + let expected_bus_balance = crate::compute_expected_commit_bus_balance_view( &verifier_air_refs, - &proof, + &views, &traces.public_output_bytes, 0, &mut replay_transcript, ) .expect("fingerprint collision in test"); - let verified = Verifier::multi_verify( + let verified = Verifier::multi_verify_views( &verifier_air_refs, - &proof, + &views, &mut DefaultTranscript::::new(&[]), &expected_bus_balance, ); @@ -2314,19 +2332,21 @@ fn test_heap_alloc_runtime_pages_roundtrip() { None, ); let verifier_air_refs = verifier_airs.air_refs(); + let views: Vec> = + proof.proofs.iter().map(StarkProofView::Owned).collect(); let mut replay_transcript = DefaultTranscript::::new(&[]); - let expected_bus_balance = crate::compute_expected_commit_bus_balance( + let expected_bus_balance = crate::compute_expected_commit_bus_balance_view( &verifier_air_refs, - &proof, + &views, &traces.public_output_bytes, 0, &mut replay_transcript, ) .expect("fingerprint collision in test"); - let verified = Verifier::multi_verify( + let verified = Verifier::multi_verify_views( &verifier_air_refs, - &proof, + &views, &mut DefaultTranscript::::new(&[]), &expected_bus_balance, ); @@ -2915,7 +2935,7 @@ fn test_count_elements_nonzero() { /// not terminate, so it is proven with the HALT table excluded (`include_halt = false`). #[test] fn test_prove_first_epoch_without_halt() { - use crate::compute_expected_commit_bus_balance; + use crate::compute_expected_commit_bus_balance_view; use crate::tables::trace_builder::build_initial_image; use crate::test_utils::asm_elf_bytes; @@ -2972,10 +2992,15 @@ fn test_prove_first_epoch_without_halt() { ) .expect("first epoch failed to prove"); + let views: Vec> = multi_proof + .proofs + .iter() + .map(StarkProofView::Owned) + .collect(); let mut replay = DefaultTranscript::::new(&[]); - let expected_bus_balance = compute_expected_commit_bus_balance( + let expected_bus_balance = compute_expected_commit_bus_balance_view( &airs.air_refs(), - &multi_proof, + &views, &traces.public_output_bytes, 0, &mut replay, @@ -2983,9 +3008,9 @@ fn test_prove_first_epoch_without_halt() { .expect("fingerprint collision in test"); assert!( - Verifier::multi_verify( + Verifier::multi_verify_views( &airs.air_refs(), - &multi_proof, + &views, &mut DefaultTranscript::::new(&[]), &expected_bus_balance, ), @@ -2998,7 +3023,7 @@ fn test_prove_first_epoch_without_halt() { /// does not terminate (HALT excluded). #[test] fn test_prove_second_epoch_from_snapshot() { - use crate::compute_expected_commit_bus_balance; + use crate::compute_expected_commit_bus_balance_view; use crate::tables::register; use crate::test_utils::asm_elf_bytes; @@ -3056,10 +3081,15 @@ fn test_prove_second_epoch_from_snapshot() { ) .expect("second epoch failed to prove"); + let views: Vec> = multi_proof + .proofs + .iter() + .map(StarkProofView::Owned) + .collect(); let mut replay = DefaultTranscript::::new(&[]); - let expected_bus_balance = compute_expected_commit_bus_balance( + let expected_bus_balance = compute_expected_commit_bus_balance_view( &airs.air_refs(), - &multi_proof, + &views, &traces.public_output_bytes, 0, &mut replay, @@ -3067,9 +3097,9 @@ fn test_prove_second_epoch_from_snapshot() { .expect("fingerprint collision in test"); assert!( - Verifier::multi_verify( + Verifier::multi_verify_views( &airs.air_refs(), - &multi_proof, + &views, &mut DefaultTranscript::::new(&[]), &expected_bus_balance, ), @@ -3083,7 +3113,7 @@ fn test_prove_second_epoch_from_snapshot() { /// will bind to. The cross-epoch GlobalMemory matching is proven separately. #[test] fn test_epoch_proof_commits_l2g() { - use crate::compute_expected_commit_bus_balance; + use crate::compute_expected_commit_bus_balance_view; use crate::tables::local_to_global; use crate::tables::register; use crate::tables::trace_builder::{build_initial_image, epoch_touched_cells}; @@ -3167,10 +3197,15 @@ fn test_epoch_proof_commits_l2g() { let mut refs = airs.air_refs(); refs.push(&inert_l2g_air); + let views: Vec> = multi_proof + .proofs + .iter() + .map(StarkProofView::Owned) + .collect(); let mut replay = DefaultTranscript::::new(&[]); - let expected_bus_balance = compute_expected_commit_bus_balance( + let expected_bus_balance = compute_expected_commit_bus_balance_view( &refs, - &multi_proof, + &views, &traces.public_output_bytes, 0, &mut replay, @@ -3178,9 +3213,9 @@ fn test_epoch_proof_commits_l2g() { .expect("fingerprint collision in test"); assert!( - Verifier::multi_verify( + Verifier::multi_verify_views( &refs, - &multi_proof, + &views, &mut DefaultTranscript::::new(&[]), &expected_bus_balance, ), @@ -3210,7 +3245,7 @@ fn test_epoch_proof_commits_l2g() { /// argument. #[test] fn test_continuation_pipeline_end_to_end() { - use crate::compute_expected_commit_bus_balance; + use crate::compute_expected_commit_bus_balance_view; use crate::tables::local_to_global; use crate::tables::register; use crate::tables::trace_builder::{build_initial_image, epoch_touched_cells}; @@ -3323,19 +3358,24 @@ fn test_continuation_pipeline_end_to_end() { let mut refs = airs.air_refs(); refs.push(&inert_l2g_air); + let views: Vec> = multi_proof + .proofs + .iter() + .map(StarkProofView::Owned) + .collect(); let mut replay = DefaultTranscript::::new(&[]); - let expected_bus_balance = compute_expected_commit_bus_balance( + let expected_bus_balance = compute_expected_commit_bus_balance_view( &refs, - &multi_proof, + &views, &traces.public_output_bytes, 0, &mut replay, ) .expect("fingerprint collision in test"); assert!( - Verifier::multi_verify( + Verifier::multi_verify_views( &refs, - &multi_proof, + &views, &mut DefaultTranscript::::new(&[]), &expected_bus_balance, ), @@ -3361,7 +3401,10 @@ fn test_continuation_pipeline_end_to_end() { // epoch proof exposed equals the per-epoch L2G sub-table root in the final proof. let final_proof = crate::tests::local_to_global_bus_tests::prove_global(&boundaries); assert!( - crate::verify_l2g_commitment_binding(&epoch_roots, &final_proof), + crate::verify_l2g_commitment_binding_view( + &epoch_roots, + MultiProofView::Owned(&final_proof) + ), "final proof must be bound to the real per-epoch L2G roots" ); } @@ -3372,7 +3415,7 @@ fn test_continuation_pipeline_end_to_end() { /// `Memory` bus still nets to zero — L2G has replaced PAGE as the bookend. #[test] fn test_epoch_memory_bus_with_l2g_bookend() { - use crate::compute_expected_commit_bus_balance; + use crate::compute_expected_commit_bus_balance_view; use crate::tables::local_to_global; use crate::tables::register; use crate::tables::trace_builder::build_initial_image; @@ -3458,10 +3501,15 @@ fn test_epoch_memory_bus_with_l2g_bookend() { let mut refs = airs.air_refs(); refs.push(&l2g_air); + let views: Vec> = multi_proof + .proofs + .iter() + .map(StarkProofView::Owned) + .collect(); let mut replay = DefaultTranscript::::new(&[]); - let expected_bus_balance = compute_expected_commit_bus_balance( + let expected_bus_balance = compute_expected_commit_bus_balance_view( &refs, - &multi_proof, + &views, &traces.public_output_bytes, 0, &mut replay, @@ -3469,9 +3517,9 @@ fn test_epoch_memory_bus_with_l2g_bookend() { .expect("fingerprint collision in test"); assert!( - Verifier::multi_verify( + Verifier::multi_verify_views( &refs, - &multi_proof, + &views, &mut DefaultTranscript::::new(&[]), &expected_bus_balance, ), diff --git a/prover/src/tests/recursion_smoke_test.rs b/prover/src/tests/recursion_smoke_test.rs index 15817df3f..bd1244c30 100644 --- a/prover/src/tests/recursion_smoke_test.rs +++ b/prover/src/tests/recursion_smoke_test.rs @@ -531,6 +531,69 @@ fn test_recursion_blob_decodes_and_verifies_on_host() { assert!(v.ok, "misaligned-buffer verify must also succeed"); } +/// Continuation flavor of the roundtrip guard: prove the empty program via +/// continuations (tiny epochs so the bundle is genuinely multi-epoch), encode +/// the [`recursion::ContinuationGuestInput`] blob, decode it exactly as the +/// intended `continuation`-feature guest would, and mirror its +/// `verify_continuation_and_attest` call — a cheap host-side check of the +/// encode/decode/verify/attest contract without running the VM. +#[test] +fn test_recursion_continuation_blob_decodes_and_verifies_on_host() { + let root = workspace_root(); + let fib_elf_bytes = read_guest_elf(&root, "fibonacci"); + let inner_input = 10u64.to_le_bytes(); + + let bundle = crate::continuation::prove_continuation( + &fib_elf_bytes, + &inner_input, + 4, + &MIN_PROOF_OPTIONS, + ) + .expect("continuation prove should succeed"); + assert!( + bundle.num_epochs() > 1, + "epoch=2^4 must split fibonacci(10) into multiple epochs for this test to bite" + ); + // Ground truth: the trustless recompute path must accept the bundle. + let expected_output = + crate::continuation::verify_continuation(&fib_elf_bytes, &bundle, &MIN_PROOF_OPTIONS) + .expect("verify_continuation errored") + .expect("bundle must verify with recomputed roots"); + // Consumer re-bind values, computed before the encode consumes the bundle: + // recompute the roots from the bundle + trusted ELF and compare ids (the + // continuation analog of check_attestation). + let (expected_decode, expected_pages) = + crate::continuation::continuation_precomputed_commitments( + &fib_elf_bytes, + &bundle, + &MIN_PROOF_OPTIONS, + ) + .expect("continuation_precomputed_commitments errored"); + let expected_id = + recursion::program_id_from_elf(&fib_elf_bytes, &expected_decode, &expected_pages) + .expect("program_id_from_elf errored"); + + let blob = + recursion::encode_continuation_guest_input(bundle, &fib_elf_bytes, &MIN_PROOF_OPTIONS) + .expect("encode_continuation_guest_input failed"); + + // Verify exactly as the guest does (built with `continuation` + `min`): + // prefix validation + rkyv access + deserialize + verify + attest. + let attestation = recursion::verify_continuation_and_attest(&blob, &MIN_PROOF_OPTIONS) + .expect("verify_continuation_and_attest errored") + .expect("continuation proof did not survive the rkyv round-trip"); + let (id, output) = recursion::split_attestation(&attestation).expect("attestation too short"); + assert_eq!( + id, expected_id, + "attested id must match the honest recompute" + ); + assert_eq!( + output, + &expected_output[..], + "supplied-roots output must match the recompute path's output" + ); +} + /// Corrupting a private-input commitment on an *honest* proof makes /// verification fail (`Ok(false)`). Necessary but not sufficient alone — a /// custom prover can supply consistent mismatched roots (see