@@ -209,9 +209,14 @@ fn l2g_memory_air(
209209/// verifier. Correctness is enforced by the GlobalMemory bus (the genesis token must
210210/// telescope into the epochs' reads), not by ELF recomputation. (Not a ZK/hiding claim —
211211/// the committed column is still opened at STARK query positions.)
212+ /// `preprocessed`, when `Some`, is used directly instead of recomputing the
213+ /// genesis commitment from `config.init_values` — the recursion guest's
214+ /// supplied roots skip the in-VM FFT + Merkle build (see `verify_global`).
215+ /// `None` recomputes from `config` as before.
212216fn global_memory_air (
213217 opts : & ProofOptions ,
214218 config : & PageConfig ,
219+ preprocessed : Option < Commitment > ,
215220) -> AirWithBuses < F , E , NullBoundaryConstraintBuilder , ( ) , EmptyConstraints > {
216221 let air = AirWithBuses :: new (
217222 global_memory:: cols:: NUM_COLUMNS ,
@@ -225,11 +230,13 @@ fn global_memory_air(
225230 if config. is_private_input {
226231 return air;
227232 }
228- let commitment = if config. init_values . is_some ( ) {
229- page:: compute_precomputed_commitment ( config, opts)
230- } else {
231- page:: zero_init_preprocessed_commitment ( opts)
232- } ;
233+ let commitment = preprocessed. unwrap_or_else ( || {
234+ if config. init_values . is_some ( ) {
235+ page:: compute_precomputed_commitment ( config, opts)
236+ } else {
237+ page:: zero_init_preprocessed_commitment ( opts)
238+ }
239+ } ) ;
233240 air. with_preprocessed ( commitment, global_memory:: NUM_PREPROCESSED_COLS )
234241}
235242
@@ -288,6 +295,44 @@ fn global_memory_configs(
288295 )
289296}
290297
298+ /// [`global_memory_configs`], but classification-only: whether each page is
299+ /// ELF-backed (an address-range check against `elf.data` segments) or zero-init
300+ /// — never materializing any byte. Correct ONLY when a supplied genesis root
301+ /// covers every classified-ELF-backed page (see `verify_global`'s caller).
302+ fn global_memory_configs_classify_only (
303+ page_bases : & [ u64 ] ,
304+ elf : & Elf ,
305+ num_private_input_pages : usize ,
306+ ) -> Vec < PageConfig > {
307+ page_bases
308+ . iter ( )
309+ . map ( |& page_base| {
310+ if page:: is_private_input_page ( page_base, num_private_input_pages) {
311+ PageConfig :: with_private_input ( page_base, Vec :: new ( ) )
312+ } else if elf_page_has_data ( elf, page_base) {
313+ PageConfig :: with_data ( page_base, Vec :: new ( ) )
314+ } else {
315+ PageConfig :: zero_init ( page_base)
316+ }
317+ } )
318+ . collect ( )
319+ }
320+
321+ /// Whether any ELF segment overlaps the byte range `[page_base, page_base + DEFAULT_PAGE_SIZE)`.
322+ /// `elf.data` is small (a handful of `PT_LOAD` segments) and sorted by `base_addr`, so this
323+ /// is cheap without needing a full byte-level image.
324+ fn elf_page_has_data ( elf : & Elf , page_base : u64 ) -> bool {
325+ // Saturating: `page_base` can be the stack's page, right at `STACK_TOP =
326+ // 0xFFFFFFFFFFFFFFF0` — `page_base + DEFAULT_PAGE_SIZE` overflows there.
327+ let page_end = page_base. saturating_add ( page:: DEFAULT_PAGE_SIZE as u64 ) ;
328+ elf. data . iter ( ) . any ( |segment| {
329+ let seg_start = segment. base_addr ;
330+ // 4 bytes/word (`Segment::values: Vec<u32>`); `executor::elf::WORD_SIZE` is crate-private.
331+ let seg_end = seg_start. saturating_add ( segment. values . len ( ) as u64 * 4 ) ;
332+ seg_start < page_end && page_base < seg_end
333+ } )
334+ }
335+
291336/// Shared genesis-config builder for prover and verifier, one `PageConfig` per page base
292337/// in `page_bases` (which must be canonical: sorted + deduped). `init_page_data` holds
293338/// each page's genesis bytes (ELF + private input on the prover side; ELF only on the
@@ -404,6 +449,7 @@ impl ContinuationProof {
404449/// INIT = `register_init` and FINI = `reg_fini`. Continuation epochs
405450/// use the L2G bookend, so PAGE is skipped and `page_configs` is empty. The
406451/// epoch-local L2G air is built separately by the caller (it needs the `label`).
452+ #[ allow( clippy:: too_many_arguments) ]
407453fn build_epoch_airs (
408454 elf : & Elf ,
409455 opts : & ProofOptions ,
@@ -412,6 +458,7 @@ fn build_epoch_airs(
412458 register_init : & [ u32 ] ,
413459 reg_fini : & [ u32 ] ,
414460 is_final : bool ,
461+ decode_commitment : Option < Commitment > ,
415462) -> VmAirs {
416463 // Continuation epochs preprocess FINI = R_{i+1} too (not just INIT = R_i), so the
417464 // final register file is a verifier-known public value bound by the REG-C2
@@ -427,7 +474,7 @@ fn build_epoch_airs(
427474 false ,
428475 page_configs,
429476 table_counts,
430- None ,
477+ decode_commitment ,
431478 is_final,
432479 None ,
433480 None ,
@@ -480,6 +527,7 @@ fn prove_epoch(
480527 start. register_init ,
481528 & reg_fini,
482529 is_final,
530+ None ,
483531 ) ;
484532
485533 let label = start. label ;
@@ -536,6 +584,7 @@ fn prove_epoch(
536584/// continuation epochs, so the AIRs are built with no page configs (the bundle does
537585/// not get to supply any). Returns `true` iff the proof verifies and its committed
538586/// L2G root matches the claimed one.
587+ #[ allow( clippy:: too_many_arguments) ]
539588fn verify_epoch (
540589 elf : & Elf ,
541590 elf_bytes : & [ u8 ] ,
@@ -544,6 +593,7 @@ fn verify_epoch(
544593 is_final : bool ,
545594 label : u64 ,
546595 opts : & ProofOptions ,
596+ decode_commitment : Option < Commitment > ,
547597) -> bool {
548598 // Reject degenerate table counts (mirrors the monolithic verifier).
549599 if epoch. table_counts . validate ( ) . is_err ( ) {
@@ -571,6 +621,7 @@ fn verify_epoch(
571621 register_init,
572622 & epoch. reg_fini ,
573623 is_final,
624+ decode_commitment,
574625 ) ;
575626 let l2g_air = l2g_memory_air ( opts, label) ;
576627 let mut refs = airs. air_refs ( ) ;
@@ -670,7 +721,7 @@ fn prove_global(
670721 . collect ( ) ;
671722 let gm_airs: Vec < _ > = gm_configs
672723 . iter ( )
673- . map ( |config| global_memory_air ( opts, config) )
724+ . map ( |config| global_memory_air ( opts, config, None ) )
674725 . collect ( ) ;
675726
676727 let mut pairs: Vec < ( AirRef , & mut TraceTable < F , E > , & ( ) ) > = l2g_airs
@@ -697,6 +748,7 @@ fn prove_global(
697748 . map_err ( |e| Error :: Prover ( format ! ( "{e:?}" ) ) )
698749}
699750
751+ #[ allow( clippy:: too_many_arguments) ]
700752fn verify_global (
701753 num_epochs : usize ,
702754 page_bases : & [ u64 ] ,
@@ -705,6 +757,7 @@ fn verify_global(
705757 elf_bytes : & [ u8 ] ,
706758 num_private_input_pages : usize ,
707759 opts : & ProofOptions ,
760+ page_genesis_commitments : Option < & [ ( u64 , Commitment ) ] > ,
708761) -> bool {
709762 // One L2G air per epoch, each with its own 1-based `fini_epoch` constant —
710763 // must match the order/labels the global proof committed in `prove_global`.
@@ -719,10 +772,48 @@ fn verify_global(
719772 // recomputes their genesis from the ELF; the GlobalMemory bus enforces them. A
720773 // wrong `num_private_input_pages` flips a touched page's preprocessed mode, so the
721774 // rebuilt AIR no longer matches the committed trace and `multi_verify` rejects.
722- let gm_configs = global_memory_configs ( page_bases, elf, num_private_input_pages) ;
775+ //
776+ // `page_genesis_commitments` (the recursion guest's supplied roots) skips the
777+ // per-data-page recompute; a supplied root shifts the genesis binding to the
778+ // attestation fold + consumer recompute, exactly like the monolithic guest's
779+ // `page_commitments`. Zero-init pages always share one commitment, computed
780+ // once here rather than per touched page.
781+ let gm_configs = if page_genesis_commitments. is_some ( ) {
782+ global_memory_configs_classify_only ( page_bases, elf, num_private_input_pages)
783+ } else {
784+ global_memory_configs ( page_bases, elf, num_private_input_pages)
785+ } ;
786+ // Keyed by page NUMBER, not raw page-aligned address: the low PAGE_SIZE_LOG2 bits
787+ // of a page base are always zero, so shifting them off first avoids wasting hash
788+ // input entropy on bits that never vary.
789+ let supplied: HashMap < u64 , Commitment > = page_genesis_commitments
790+ . map ( |s| {
791+ s. iter ( )
792+ . map ( |& ( base, c) | ( page:: page_number ( base) , c) )
793+ . collect ( )
794+ } )
795+ . unwrap_or_default ( ) ;
796+ // A missing entry here would silently recompute (slow) instead of failing loudly.
797+ debug_assert ! (
798+ page_genesis_commitments. is_none_or( |_| gm_configs
799+ . iter( )
800+ . filter( |c| !c. is_private_input && c. init_values. is_some( ) )
801+ . all( |c| supplied. contains_key( & page:: page_number( c. page_base) ) ) ) ,
802+ "page_genesis_commitments is missing an entry for a touched data page" ,
803+ ) ;
804+ let zero_init_root = page:: zero_init_preprocessed_commitment ( opts) ;
723805 let gm_airs: Vec < _ > = gm_configs
724806 . iter ( )
725- . map ( |config| global_memory_air ( opts, config) )
807+ . map ( |config| {
808+ let preprocessed = if config. is_private_input {
809+ None
810+ } else if config. init_values . is_some ( ) {
811+ supplied. get ( & page:: page_number ( config. page_base ) ) . copied ( )
812+ } else {
813+ Some ( zero_init_root)
814+ } ;
815+ global_memory_air ( opts, config, preprocessed)
816+ } )
726817 . collect ( ) ;
727818
728819 let mut refs: Vec < AirRef > = l2g_airs. iter ( ) . map ( |a| a as AirRef ) . collect ( ) ;
@@ -924,6 +1015,25 @@ pub fn verify_continuation(
9241015 elf_bytes : & [ u8 ] ,
9251016 bundle : & ContinuationProof ,
9261017 opts : & ProofOptions ,
1018+ ) -> Result < Option < Vec < u8 > > , Error > {
1019+ verify_continuation_with_roots ( elf_bytes, bundle, opts, None , None )
1020+ }
1021+
1022+ /// [`verify_continuation`] with caller-supplied ELF-derived roots: the DECODE
1023+ /// preprocessed root (shared by every epoch) and the global-memory genesis
1024+ /// roots for touched data pages. Supplied roots are used VERBATIM — they are
1025+ /// NOT bound to `elf_bytes` here, exactly like `verify_with_options`' supplied
1026+ /// roots on the monolithic path. The recursion guest supplies them via private
1027+ /// input to skip the in-VM FFT + Merkle recomputes; on success it folds them
1028+ /// into the attestation's `program_id`, and the consumer's recompute+compare
1029+ /// is what restores the binding. `None` = recompute from the ELF (the
1030+ /// trustless host path).
1031+ pub fn verify_continuation_with_roots (
1032+ elf_bytes : & [ u8 ] ,
1033+ bundle : & ContinuationProof ,
1034+ opts : & ProofOptions ,
1035+ decode_commitment : Option < Commitment > ,
1036+ page_genesis_commitments : Option < & [ ( u64 , Commitment ) ] > ,
9271037) -> Result < Option < Vec < u8 > > , Error > {
9281038 // Bound the claimed private-input page count before using it to size/allocate AIRs
9291039 // (mirrors `verify_with_options`). The count is also bound into the global proof's
@@ -974,6 +1084,7 @@ pub fn verify_continuation(
9741084 is_final,
9751085 label,
9761086 opts,
1087+ decode_commitment,
9771088 ) {
9781089 return Ok ( None ) ;
9791090 }
@@ -1019,6 +1130,7 @@ pub fn verify_continuation(
10191130 elf_bytes,
10201131 bundle. num_private_input_pages ,
10211132 opts,
1133+ page_genesis_commitments,
10221134 ) {
10231135 return Ok ( None ) ;
10241136 }
@@ -1031,6 +1143,28 @@ pub fn verify_continuation(
10311143 Ok ( Some ( public_output) )
10321144}
10331145
1146+ /// Precompute the ELF-derived roots [`verify_continuation_with_roots`] accepts:
1147+ /// the DECODE preprocessed root and one genesis root per touched non-private
1148+ /// data page (the same set `verify_global` would rebuild from the ELF). These
1149+ /// are what a caller packs as a continuation recursion guest's private input,
1150+ /// and what a consumer recomputes to re-bind the guest's attestation.
1151+ pub fn continuation_precomputed_commitments (
1152+ elf_bytes : & [ u8 ] ,
1153+ bundle : & ContinuationProof ,
1154+ opts : & ProofOptions ,
1155+ ) -> Result < ( Commitment , Vec < ( u64 , Commitment ) > ) , Error > {
1156+ let elf = Elf :: load ( elf_bytes) . map_err ( |e| Error :: ElfLoad ( format ! ( "{e}" ) ) ) ?;
1157+ let decode_commitment = crate :: tables:: decode:: commitment_from_elf ( & elf, opts)
1158+ . map_err ( |e| Error :: Recursion ( format ! ( "DECODE commitment from ELF: {e}" ) ) ) ?;
1159+ let page_bases = canonical_page_bases ( & bundle. touched_page_bases ) ;
1160+ let page_commitments = global_memory_configs ( & page_bases, & elf, bundle. num_private_input_pages )
1161+ . iter ( )
1162+ . filter ( |c| !c. is_private_input && c. init_values . is_some ( ) )
1163+ . map ( |c| ( c. page_base , page:: compute_precomputed_commitment ( c, opts) ) )
1164+ . collect ( ) ;
1165+ Ok ( ( decode_commitment, page_commitments) )
1166+ }
1167+
10341168/// Convenience wrapper: prove then verify in one call (the original integrated API).
10351169/// Returns `Ok(Some(public_output))` iff the continuation proves and verifies.
10361170pub fn prove_and_verify_continuation (
@@ -1130,6 +1264,50 @@ mod tests {
11301264 ) ;
11311265 }
11321266
1267+ // Supplied genesis roots (the recursion guest's path) must verify identically
1268+ // to the trustless recompute, and a tampered supplied root must be rejected.
1269+ #[ test]
1270+ fn test_verify_continuation_with_supplied_roots ( ) {
1271+ let elf_bytes = asm_elf_bytes ( "all_loadstore_32" ) ;
1272+ let opts = ProofOptions :: default_test_options ( ) ;
1273+ let bundle = prove_continuation ( & elf_bytes, & [ ] , 3 , & opts) . unwrap ( ) ;
1274+
1275+ let expected = verify_continuation ( & elf_bytes, & bundle, & opts)
1276+ . unwrap ( )
1277+ . expect ( "trustless verify must accept an honest bundle" ) ;
1278+
1279+ let ( decode_commitment, page_commitments) =
1280+ continuation_precomputed_commitments ( & elf_bytes, & bundle, & opts) . unwrap ( ) ;
1281+ let got = verify_continuation_with_roots (
1282+ & elf_bytes,
1283+ & bundle,
1284+ & opts,
1285+ Some ( decode_commitment) ,
1286+ Some ( & page_commitments) ,
1287+ )
1288+ . unwrap ( )
1289+ . expect ( "supplied-roots verify must accept the same honest bundle" ) ;
1290+ assert_eq ! (
1291+ got, expected,
1292+ "supplied-roots output must match the recompute path"
1293+ ) ;
1294+
1295+ let mut tampered_decode = decode_commitment;
1296+ tampered_decode[ 0 ] ^= 0xFF ;
1297+ let rejected = verify_continuation_with_roots (
1298+ & elf_bytes,
1299+ & bundle,
1300+ & opts,
1301+ Some ( tampered_decode) ,
1302+ Some ( & page_commitments) ,
1303+ )
1304+ . unwrap ( ) ;
1305+ assert ! (
1306+ rejected. is_none( ) ,
1307+ "a tampered supplied DECODE root must be rejected"
1308+ ) ;
1309+ }
1310+
11331311 // Regression for touched-cell prediction from carried registers. A syscall
11341312 // whose operand pointers live in registers (ECSM reads a0/a1/a2) can have those
11351313 // registers set in an EARLIER epoch than the call. `test_ecsm_split` sets
0 commit comments