Skip to content

Commit 60fdb39

Browse files
committed
feat(recursion): supply DECODE/global-memory-genesis roots via private input for continuation verify
Mirrors #782's monolithic mechanism for the continuation path: a caller (the recursion guest) can supply the DECODE preprocessed root and each touched data page's genesis root instead of the verifier recomputing them from the ELF, skipping the in-VM FFT + Merkle build. Supplied roots are used verbatim; the binding shifts to the consumer's recompute-and-compare of the folded identity, exactly like the monolithic path. verify_global's genesis-page classification (ELF-backed vs zero-init) is derived from ELF segment address ranges instead of materializing a full byte-level image when roots are supplied, since the real bytes are never read once a root covers that page. Page lookups are keyed by page number (page_base >> log2(page_size)) rather than the raw page-aligned address, since the low bits are always zero. verify_continuation keeps its existing signature (trustless recompute); verify_continuation_with_roots is the new supplied-roots entry point, and continuation_precomputed_commitments lets a caller derive the roots to supply for a given bundle.
1 parent 18f3b8f commit 60fdb39

2 files changed

Lines changed: 199 additions & 9 deletions

File tree

prover/src/continuation.rs

Lines changed: 187 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
212216
fn 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)]
407453
fn 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)]
539588
fn 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)]
700752
fn 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.
10361170
pub 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

prover/src/tables/page.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,18 @@ use super::types::{BusId, FE, GoldilocksExtension, GoldilocksField, VmTable};
4949
/// Default page size in bytes (256KB).
5050
pub const DEFAULT_PAGE_SIZE: usize = 1 << 18;
5151

52+
/// `page_base` is always a multiple of `DEFAULT_PAGE_SIZE`, which is a power of
53+
/// two — so a page is identified just as cheaply by its shifted page NUMBER,
54+
/// with no low always-zero bits to waste hash entropy on.
55+
const _: () = assert!(DEFAULT_PAGE_SIZE.is_power_of_two());
56+
pub(crate) const PAGE_SIZE_LOG2: u32 = DEFAULT_PAGE_SIZE.trailing_zeros();
57+
58+
/// Shift a page-aligned address down to its page number.
59+
pub(crate) fn page_number(page_base: u64) -> u64 {
60+
debug_assert_eq!(page_base % DEFAULT_PAGE_SIZE as u64, 0, "not page-aligned");
61+
page_base >> PAGE_SIZE_LOG2
62+
}
63+
5264
/// Stack top address (where SP starts). Re-exported from executor.
5365
pub use executor::vm::registers::STACK_TOP;
5466

0 commit comments

Comments
 (0)