From 94532557c6814f6a782769a6eec22cacecfd28d2 Mon Sep 17 00:00:00 2001 From: Nicole Date: Tue, 2 Jun 2026 18:10:31 -0300 Subject: [PATCH 1/3] Add warn, rename function, add tets --- bin/cli/src/main.rs | 2 +- prover/src/lib.rs | 64 +++++++++++++--- prover/src/tests/decode_tests.rs | 11 ++- prover/src/tests/disk_spill_tests.rs | 8 +- prover/src/tests/page_tests.rs | 109 +++++++++++++++++++++++++++ prover/src/tests/prove_elfs_tests.rs | 34 ++++++--- 6 files changed, 199 insertions(+), 29 deletions(-) diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index 66ee8325a..7067eda68 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -500,7 +500,7 @@ fn cmd_verify(proof_path: PathBuf, elf_path: PathBuf, blowup: Option, time: return ExitCode::FAILURE; } }; - prover::verify_with_options(&proof, &elf_data, &opts, None) + prover::verify_with_options(&proof, &elf_data, &opts, None, None) } None => prover::verify(&proof, &elf_data), }; diff --git a/prover/src/lib.rs b/prover/src/lib.rs index aaefc60ed..e1ea7e13e 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -337,11 +337,21 @@ impl VmAirs { /// constant (e.g. the recursion guest, where the in-VM recompute is too /// expensive). When `None`, the commitment is computed from the ELF. /// - /// The trust anchor for `decode_commitment` is the caller's compiled - /// binary — never accept prover-supplied bytes here. A wrong value is - /// rejected, never silently accepted: it either mismatches the prover's - /// committed precomputed root (an explicit verifier check) or yields - /// diverging Fiat-Shamir challenges. + /// `page_commitments` is an optional list of precomputed ELF-data-page + /// preprocessed commitments, keyed by `page_base`. For each ELF data page + /// the verifier constructs, if a matching `(page_base, commitment)` pair + /// is supplied, it is used directly and that page's FFT + Merkle build is + /// skipped. Pages not in the list — including all zero-init pages and + /// pages without a match — take the normal compute path (zero-init pages + /// hit a compile-time constant via `page::preprocessed_commitment`, + /// ELF data pages recompute from the ELF). When `None`, every ELF data + /// page recomputes from scratch. + /// + /// The trust anchor for both `decode_commitment` and `page_commitments` + /// is the caller's compiled binary — never accept prover-supplied bytes + /// here. A wrong value is rejected, never silently accepted: it either + /// mismatches the prover's committed precomputed root (an explicit + /// verifier check) or yields diverging Fiat-Shamir challenges. pub fn new( elf: &Elf, proof_options: &ProofOptions, @@ -349,6 +359,7 @@ impl VmAirs { page_configs: &[crate::tables::page::PageConfig], table_counts: &TableCounts, decode_commitment: Option, + page_commitments: Option<&[(u64, Commitment)]>, ) -> Self { let cpus: Vec<_> = (0..table_counts.cpu) .map(|i| create_cpu_air(proof_options).with_name(&format!("CPU[{}]", i))) @@ -411,13 +422,30 @@ impl VmAirs { // The verifier doesn't see the init values; correctness is enforced // by the memory bus constraints. create_page_air(proof_options, config.page_base) - } else { - // ELF and zero-init pages: OFFSET + INIT are preprocessed. - // The verifier independently recomputes the commitment from public data. + } else if config.init_values.is_none() { + // Zero-init pages: OFFSET + INIT are preprocessed and uniformly zero. + // `preprocessed_commitment` returns the compile-time constant + // for standard `(page_size, blowup_factor)` pairs and recomputes + // otherwise — caller-supplied values aren't useful here because the + // const is already cheaper than reading bytes through the API. create_page_air(proof_options, config.page_base).with_preprocessed( - page::precomputed_commitment_cached(config, proof_options), + page::preprocessed_commitment(config, proof_options), page::NUM_PREPROCESSED_COLS, ) + } else { + // ELF data pages: INIT depends on the program's bytes. If the caller + // supplied a matching `(page_base, commitment)` pair via + // `page_commitments`, use it directly and skip the FFT + Merkle build; + // otherwise recompute from the ELF data. + let commitment = page_commitments + .and_then(|list| { + list.iter() + .find(|(pb, _)| *pb == config.page_base) + .map(|(_, c)| *c) + }) + .unwrap_or_else(|| page::preprocessed_commitment(config, proof_options)); + create_page_air(proof_options, config.page_base) + .with_preprocessed(commitment, page::NUM_PREPROCESSED_COLS) } }) .collect(); @@ -663,6 +691,7 @@ pub fn prove_with_options_and_inputs( &traces.page_configs, &table_counts, None, + None, ); #[cfg(feature = "instruments")] @@ -735,6 +764,7 @@ pub fn verify(vm_proof: &VmProof, elf_bytes: &[u8]) -> Result { elf_bytes, &GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 is always valid"), None, + None, ) } @@ -751,8 +781,18 @@ pub fn verify(vm_proof: &VmProof, elf_bytes: &[u8]) -> Result { /// commitment as a compile-time constant to avoid the in-VM recompute /// cost. When `None`, the verifier computes the commitment from the ELF. /// -/// Trust model: `decode_commitment`, when supplied, must come from the -/// caller's compiled binary (e.g. a `const [u8; 32]`), never from prover- +/// `page_commitments` is an optional list of precomputed ELF-data-page +/// preprocessed commitments, keyed by `page_base`. For each ELF data page +/// the verifier constructs, if a matching `(page_base, commitment)` pair is +/// supplied, the FFT + Merkle build for that page is skipped. Pages without +/// a match — including all zero-init pages — fall through to +/// `page::preprocessed_commitment`, which returns a compile-time +/// constant for zero-init pages and recomputes for ELF data pages. When +/// `None`, every ELF data page recomputes from scratch. +/// +/// Trust model: both `decode_commitment` and `page_commitments`, when +/// supplied, must come from the caller's compiled binary (e.g. a +/// `const [u8; 32]` and a `const [(u64, [u8; 32])]`), never from prover- /// supplied bytes. A wrong value is rejected, never silently accepted: it /// either mismatches the prover's committed precomputed root (an explicit /// verifier check) or yields diverging Fiat-Shamir challenges. @@ -761,6 +801,7 @@ pub fn verify_with_options( elf_bytes: &[u8], proof_options: &ProofOptions, decode_commitment: Option, + page_commitments: Option<&[(u64, Commitment)]>, ) -> Result { // Validate table_counts before constructing AIRs. // A malicious prover could set counts to 0, removing entire constraint sets. @@ -807,6 +848,7 @@ pub fn verify_with_options( &page_configs, &vm_proof.table_counts, decode_commitment, + page_commitments, ); // Recompute the COMMIT output bus offset from VmProof.public_output. diff --git a/prover/src/tests/decode_tests.rs b/prover/src/tests/decode_tests.rs index 84ae8ff3a..0a1f323de 100644 --- a/prover/src/tests/decode_tests.rs +++ b/prover/src/tests/decode_tests.rs @@ -1051,6 +1051,7 @@ fn test_decode_soundness_same_elf_accepted() { &traces.page_configs, &table_counts, None, + None, ); let proof = multi_prove_ram( @@ -1068,6 +1069,7 @@ fn test_decode_soundness_same_elf_accepted() { &traces.page_configs, &table_counts, None, + None, ); let verifier_air_refs = verifier_airs.air_refs(); let mut replay_transcript = DefaultTranscript::::new(&[]); @@ -1175,9 +1177,9 @@ fn decode_commitment_some_matches_default_path() { let decode_c = commitment_from_elf(&elf, &options).expect("decode commitment"); - let default_ok = verify_with_options(&vm_proof, &elf_bytes, &options, None) + let default_ok = verify_with_options(&vm_proof, &elf_bytes, &options, None, None) .expect("verify with None should not error"); - let explicit_ok = verify_with_options(&vm_proof, &elf_bytes, &options, Some(decode_c)) + let explicit_ok = verify_with_options(&vm_proof, &elf_bytes, &options, Some(decode_c), None) .expect("verify with Some(correct) should not error"); assert!(default_ok, "default path must accept the proof"); @@ -1198,7 +1200,7 @@ fn decode_commitment_wrong_value_rejects() { let mut wrong = commitment_from_elf(&elf, &options).expect("decode commitment"); wrong[0] ^= 0xFF; - let result = verify_with_options(&vm_proof, &elf_bytes, &options, Some(wrong)) + let result = verify_with_options(&vm_proof, &elf_bytes, &options, Some(wrong), None) .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); assert!( !result, @@ -1214,7 +1216,7 @@ fn decode_commitment_zero_bytes_rejects() { // [0u8; 32] is the most plausible accidental default — passing it must // not pass verification. - let result = verify_with_options(&vm_proof, &elf_bytes, &options, Some([0u8; 32])) + let result = verify_with_options(&vm_proof, &elf_bytes, &options, Some([0u8; 32]), None) .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); assert!( !result, @@ -1245,6 +1247,7 @@ fn decode_commitment_compile_time_const_accepts() { &elf_bytes, &options, Some(SUB_DECODE_COMMITMENT_BLOWUP_2), + None, ) .expect("verify must not return Err"); assert!( diff --git a/prover/src/tests/disk_spill_tests.rs b/prover/src/tests/disk_spill_tests.rs index 2d55a35b9..a03575ba7 100644 --- a/prover/src/tests/disk_spill_tests.rs +++ b/prover/src/tests/disk_spill_tests.rs @@ -25,14 +25,14 @@ fn test_disk_spill_prove_verify_and_roundtrip_small() { let proof = crate::prove_with_options(&elf_bytes, &opts, &MaxRowsConfig::default()) .expect("prove failed"); assert!( - crate::verify_with_options(&proof, &elf_bytes, &opts, None).expect("verify failed"), + crate::verify_with_options(&proof, &elf_bytes, &opts, None, None).expect("verify failed"), "verification returned false" ); let bytes = bincode::serialize(&proof).expect("serialize failed"); let proof2: VmProof = bincode::deserialize(&bytes).expect("deserialize failed"); assert!( - crate::verify_with_options(&proof2, &elf_bytes, &opts, None).expect("verify failed"), + crate::verify_with_options(&proof2, &elf_bytes, &opts, None, None).expect("verify failed"), "verification failed after serialization roundtrip" ); } @@ -45,14 +45,14 @@ fn test_disk_spill_prove_verify_and_roundtrip_chunked() { let proof = crate::prove_with_options(&elf_bytes, &opts, &MaxRowsConfig::small()) .expect("prove failed"); assert!( - crate::verify_with_options(&proof, &elf_bytes, &opts, None).expect("verify failed"), + crate::verify_with_options(&proof, &elf_bytes, &opts, None, None).expect("verify failed"), "verification returned false" ); let bytes = bincode::serialize(&proof).expect("serialize failed"); let proof2: VmProof = bincode::deserialize(&bytes).expect("deserialize failed"); assert!( - crate::verify_with_options(&proof2, &elf_bytes, &opts, None).expect("verify failed"), + crate::verify_with_options(&proof2, &elf_bytes, &opts, None, None).expect("verify failed"), "verification failed after serialization roundtrip (chunked)" ); } diff --git a/prover/src/tests/page_tests.rs b/prover/src/tests/page_tests.rs index 23978c123..1d1abf7c9 100644 --- a/prover/src/tests/page_tests.rs +++ b/prover/src/tests/page_tests.rs @@ -1,7 +1,13 @@ //! Tests for the PAGE table. +use executor::elf::Elf; +use stark::proof::options::GoldilocksCubicProofOptions; + use crate::tables::page::*; +use crate::tables::trace_builder::Traces; use crate::tables::types::*; +use crate::test_utils::asm_elf_bytes; +use crate::{prove, verify_with_options}; #[test] fn test_page_base_for_address() { @@ -105,3 +111,106 @@ fn test_bus_interactions_high_address() { let interactions = bus_interactions(stack_page); assert_eq!(interactions.len(), 3); } + +// ========================================================================= +// verify_with_options: optional page_commitments parameter +// ========================================================================= + +/// Compute the correct ELF-data-page commitments for the given ELF + options. +/// Returns `(page_base, commitment)` pairs for every non-private, non-zero-init +/// page in the verifier's reconstructed page set. +fn elf_data_page_commitments( + elf_bytes: &[u8], + vm_proof: &crate::VmProof, + options: &stark::proof::options::ProofOptions, +) -> Vec<(u64, stark::config::Commitment)> { + let elf = Elf::load(elf_bytes).expect("ELF load"); + let page_configs = Traces::page_configs_from_elf_and_runtime( + &elf, + &vm_proof.runtime_page_ranges, + vm_proof.num_private_input_pages, + ); + page_configs + .iter() + .filter(|c| !c.is_private_input && c.init_values.is_some()) + .map(|c| (c.page_base, compute_precomputed_commitment(c, options))) + .collect() +} + +#[test] +fn page_commitments_some_matches_default_path() { + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("prove failed"); + let options = GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 valid"); + + let list = elf_data_page_commitments(&elf_bytes, &vm_proof, &options); + assert!( + !list.is_empty(), + "test ELF must have at least one ELF data page for this test to be meaningful", + ); + + let default_ok = verify_with_options(&vm_proof, &elf_bytes, &options, None, None) + .expect("verify with None should not error"); + let explicit_ok = verify_with_options(&vm_proof, &elf_bytes, &options, None, Some(&list)) + .expect("verify with Some(correct) should not error"); + + assert!(default_ok, "default path must accept the proof"); + assert!( + explicit_ok, + "Some(correct_page_commitments) must accept the proof" + ); +} + +#[test] +fn page_commitments_wrong_value_rejects() { + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("prove failed"); + let options = GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 valid"); + + let mut list = elf_data_page_commitments(&elf_bytes, &vm_proof, &options); + assert!(!list.is_empty(), "test ELF must have ≥ 1 ELF data page"); + // Flip a byte in the first page's commitment so the Fiat-Shamir transcripts diverge. + list[0].1[0] ^= 0xFF; + + let result = verify_with_options(&vm_proof, &elf_bytes, &options, None, Some(&list)) + .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); + assert!( + !result, + "tampered page commitment must cause Fiat-Shamir rejection", + ); +} + +#[test] +fn page_commitments_zero_bytes_rejects() { + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("prove failed"); + let options = GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 valid"); + + let mut list = elf_data_page_commitments(&elf_bytes, &vm_proof, &options); + assert!(!list.is_empty(), "test ELF must have ≥ 1 ELF data page"); + // [0u8; 32] is the most plausible accidental default — passing it must + // not pass verification. + list[0].1 = [0u8; 32]; + + let result = verify_with_options(&vm_proof, &elf_bytes, &options, None, Some(&list)) + .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); + assert!( + !result, + "all-zero page commitment must cause Fiat-Shamir rejection", + ); +} + +#[test] +fn page_commitments_empty_list_matches_none() { + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("prove failed"); + let options = GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 valid"); + + let empty: [(u64, stark::config::Commitment); 0] = []; + let result = verify_with_options(&vm_proof, &elf_bytes, &options, None, Some(&empty)) + .expect("verify with empty list should not error"); + assert!( + result, + "empty page_commitments slice must behave like None — every page falls through to recompute", + ); +} diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index 5b9ec0f8a..d4b9325d3 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -59,6 +59,7 @@ fn prove_and_verify_vm_minimal(elf: &Elf, traces: &mut Traces) -> bool { &traces.page_configs, &table_counts, None, + None, ); // Build air_trace_pairs for all tables @@ -109,6 +110,7 @@ fn prove_vm_minimal(elf_bytes: &[u8], private_inputs: &[u8], max_rows: &MaxRowsC &traces.page_configs, &table_counts, None, + None, ); let runtime_page_ranges = traces.runtime_page_ranges(); let proof = multi_prove_ram( @@ -148,6 +150,7 @@ fn verify_vm_minimal(vm_proof: &VmProof, elf_bytes: &[u8]) -> bool { &page_configs, &vm_proof.table_counts, None, + None, ); let air_refs = airs.air_refs(); let mut replay_transcript = DefaultTranscript::::new(&[]); @@ -1164,6 +1167,7 @@ fn test_prove_elfs_test_commit_4_wrong_pages_rejected() { &traces.page_configs, &table_counts, None, + None, ); let proof = multi_prove_ram( prover_airs.air_trace_pairs(&mut traces), @@ -1180,6 +1184,7 @@ fn test_prove_elfs_test_commit_4_wrong_pages_rejected() { &wrong_configs, &table_counts, None, + None, ); let verifier_air_refs = verifier_airs.air_refs(); let mut replay_transcript = DefaultTranscript::::new(&[]); @@ -1210,7 +1215,7 @@ fn test_verify_rejects_tampered_public_output() { let vm_proof = crate::prove_with_options(&elf_bytes, &proof_options, &Default::default()) .expect("Prover should succeed for test_commit_4"); assert!( - crate::verify_with_options(&vm_proof, &elf_bytes, &proof_options, None) + crate::verify_with_options(&vm_proof, &elf_bytes, &proof_options, None, None) .expect("Valid commit proof should verify"), "Baseline proof should verify before tampering" ); @@ -1222,8 +1227,9 @@ fn test_verify_rejects_tampered_public_output() { ..vm_proof }; - let verified = crate::verify_with_options(&tampered_proof, &elf_bytes, &proof_options, None) - .expect("Verifier should not error on tampered public output"); + let verified = + crate::verify_with_options(&tampered_proof, &elf_bytes, &proof_options, None, None) + .expect("Verifier should not error on tampered public output"); assert!( !verified, "Verifier should reject proof when VmProof.public_output is tampered" @@ -1910,6 +1916,7 @@ fn test_deep_stack_runtime_pages_roundtrip() { &traces.page_configs, &table_counts, None, + None, ); let proof = multi_prove_ram( prover_airs.air_trace_pairs(&mut traces), @@ -1925,6 +1932,7 @@ fn test_deep_stack_runtime_pages_roundtrip() { &verifier_configs, &table_counts, None, + None, ); let verifier_air_refs = verifier_airs.air_refs(); let mut replay_transcript = DefaultTranscript::::new(&[]); @@ -1974,6 +1982,7 @@ fn test_deep_stack_missing_pages_rejected() { &traces.page_configs, &table_counts, None, + None, ); let proof = multi_prove_ram( prover_airs.air_trace_pairs(&mut traces), @@ -1989,6 +1998,7 @@ fn test_deep_stack_missing_pages_rejected() { &wrong_configs, &table_counts, None, + None, ); let verifier_air_refs = verifier_airs.air_refs(); let mut replay_transcript = DefaultTranscript::::new(&[]); @@ -2073,6 +2083,7 @@ fn test_heap_alloc_runtime_pages_roundtrip() { &traces.page_configs, &table_counts, None, + None, ); let proof = multi_prove_ram( prover_airs.air_trace_pairs(&mut traces), @@ -2088,6 +2099,7 @@ fn test_heap_alloc_runtime_pages_roundtrip() { &verifier_configs, &table_counts, None, + None, ); let verifier_air_refs = verifier_airs.air_refs(); let mut replay_transcript = DefaultTranscript::::new(&[]); @@ -2148,7 +2160,7 @@ fn test_verify_rejects_zero_table_counts() { .expect("Prover should succeed on valid program"); assert!( - crate::verify_with_options(&vm_proof, &elf_bytes, &proof_options, None) + crate::verify_with_options(&vm_proof, &elf_bytes, &proof_options, None, None) .expect("Verification should not error on valid proof"), "Valid proof should verify" ); @@ -2169,7 +2181,8 @@ fn test_verify_rejects_zero_table_counts() { ..vm_proof }; - let result = crate::verify_with_options(&tampered_proof, &elf_bytes, &proof_options, None); + let result = + crate::verify_with_options(&tampered_proof, &elf_bytes, &proof_options, None, None); assert!(result.is_err(), "Got {:?}", result); } @@ -2190,7 +2203,8 @@ fn test_verify_rejects_zero_cpu_count() { ..vm_proof }; - let result = crate::verify_with_options(&tampered_proof, &elf_bytes, &proof_options, None); + let result = + crate::verify_with_options(&tampered_proof, &elf_bytes, &proof_options, None, None); assert!(result.is_err(), "Got {:?}", result); } @@ -2211,7 +2225,8 @@ fn test_verify_rejects_zero_memw_count() { ..vm_proof }; - let result = crate::verify_with_options(&tampered_proof, &elf_bytes, &proof_options, None); + let result = + crate::verify_with_options(&tampered_proof, &elf_bytes, &proof_options, None, None); assert!(result.is_err(), "Got {:?}", result); } @@ -2234,7 +2249,7 @@ fn test_crafted_zero_count_proof_must_not_verify() { branch: 0, memw_register: 0, }; - let airs = VmAirs::new(&elf, &proof_options, true, &[], &zero_counts, None); + let airs = VmAirs::new(&elf, &proof_options, true, &[], &zero_counts, None, None); let verifier_air_refs = airs.air_refs(); assert_eq!(verifier_air_refs.len(), 8); @@ -2338,7 +2353,8 @@ fn test_verify_rejects_inflated_table_counts() { ..vm_proof }; - let result = crate::verify_with_options(&tampered_proof, &elf_bytes, &proof_options, None); + let result = + crate::verify_with_options(&tampered_proof, &elf_bytes, &proof_options, None, None); assert!( result.is_err(), "Inflated table_counts should be rejected, got {:?}", From 9d8eec76d0e8cdfd7a5f26acb09fdfcf545de65d Mon Sep 17 00:00:00 2001 From: Nicole Date: Tue, 2 Jun 2026 19:15:28 -0300 Subject: [PATCH 2/3] zero init page commitments --- prover/src/bin/compute_static_commitments.rs | 23 +++-- prover/src/tables/page.rs | 94 +++++++++++++++++--- prover/src/tests/static_commitments_tests.rs | 80 ++++++++++++++++- 3 files changed, 174 insertions(+), 23 deletions(-) diff --git a/prover/src/bin/compute_static_commitments.rs b/prover/src/bin/compute_static_commitments.rs index 54276edaf..2d46df530 100644 --- a/prover/src/bin/compute_static_commitments.rs +++ b/prover/src/bin/compute_static_commitments.rs @@ -1,7 +1,7 @@ -//! Prints static `(bitwise, keccak_rc)` preprocessed-table commitments for -//! a fixed set of `blowup_factor` values. The output is pasted into the +//! Prints static `(bitwise, keccak_rc, zero_page)` preprocessed-table commitments +//! for a fixed set of `blowup_factor` values. The output is pasted into the //! `static_commitment` match bodies in -//! `prover/src/tables/{bitwise,keccak_rc}.rs`. The +//! `prover/src/tables/{bitwise,keccak_rc,page}.rs`. The //! `static_commitments_tests` test suite pins the values so any drift in //! the AIR or FFT pipeline is caught at test time. //! @@ -9,10 +9,11 @@ //! cargo run --bin compute_static_commitments --release //! //! ⚠️ Do not run this just to silence a failing drift test — see the -//! "Regenerating" section on `static_commitment` in `bitwise.rs` and -//! `keccak_rc.rs` for when it's actually appropriate to bless new bytes. +//! "Regenerating" section on `static_commitment` in `bitwise.rs`, +//! `keccak_rc.rs`, and `page.rs` for when it's actually appropriate to +//! bless new bytes. -use lambda_vm_prover::tables::{STATIC_BLOWUP_FACTORS, bitwise, keccak_rc}; +use lambda_vm_prover::tables::{STATIC_BLOWUP_FACTORS, bitwise, keccak_rc, page}; use stark::config::Commitment; use stark::proof::options::GoldilocksCubicProofOptions; @@ -35,9 +36,11 @@ fn format_commitment(commitment: &Commitment) -> String { fn main() { println!( "// Paste these match arms into the `static_commitment` match body\n\ - // in `prover/src/tables/{{bitwise,keccak_rc}}.rs`.\n" + // in `prover/src/tables/{{bitwise,keccak_rc,page}}.rs`.\n" ); + let zero_page_config = page::PageConfig::zero_init(0, page::DEFAULT_PAGE_SIZE); + for &blowup in STATIC_BLOWUP_FACTORS { let options = match GoldilocksCubicProofOptions::with_blowup(blowup) { Ok(o) => o, @@ -49,15 +52,19 @@ fn main() { let bitwise = bitwise::compute_preprocessed_commitment(&options); let keccak_rc = keccak_rc::compute_preprocessed_commitment(&options); + let zero_page = page::compute_precomputed_commitment(&zero_page_config, &options); println!( "// blowup_factor = {blowup}\n\ // ---- bitwise:\n \ {blowup} => Some({bitwise_fmt}),\n\ // ---- keccak_rc:\n \ - {blowup} => Some({keccak_fmt}),\n", + {blowup} => Some({keccak_fmt}),\n\ + // ---- zero_page (page_size = DEFAULT_PAGE_SIZE):\n \ + (DEFAULT_PAGE_SIZE, {blowup}) => Some({zero_page_fmt}),\n", bitwise_fmt = format_commitment(&bitwise), keccak_fmt = format_commitment(&keccak_rc), + zero_page_fmt = format_commitment(&zero_page), ); } } diff --git a/prover/src/tables/page.rs b/prover/src/tables/page.rs index 0e73153dd..702afd1f6 100644 --- a/prover/src/tables/page.rs +++ b/prover/src/tables/page.rs @@ -31,7 +31,6 @@ //! | PAGE-C4 | Memory | `[0, address, timestamp, fini]` | 1 (sender) | use std::collections::HashMap; -use std::sync::OnceLock; use math::fft::bit_reversing::in_place_bit_reverse_permute; use math::polynomial::Polynomial; @@ -225,17 +224,67 @@ pub fn generate_page_trace( // Preprocessed commitment // ========================================================================= -/// Cached commitment for zero-initialized 4KB pages. -/// All zero-init pages of the same size have identical OFFSET and INIT columns. +/// Returns the static zero-init PAGE preprocessed commitment for +/// `(page_size, blowup_factor)`, or `None` if no value is shipped for that +/// pair. Values were generated by the `compute_static_commitments` binary at +/// the project's standard `coset_offset = 3` (the value every in-tree +/// `ProofOptions` constructor pins) and pinned by +/// `zero_page_static_matches_recompute_for_all_blowups` so any drift in the +/// AIR or FFT pipeline is caught at test time. The verifier reads these +/// from its compiled binary — no input data is trusted. /// -/// INVARIANT: All callers within a process must use identical `ProofOptions`. -/// The cache is keyed only by page content, not by options. -static ZERO_PAGE_4K_COMMITMENT: OnceLock = OnceLock::new(); +/// Because OFFSET is page-relative (0..page_size-1) and INIT is uniformly +/// zero for zero-init pages, the commitment depends only on geometry — not +/// on `page_base` or the program being verified. A single entry covers +/// every zero-init page of that `page_size` in the system. +/// +/// # Regenerating +/// +/// Only regenerate these match arms after a *deliberate, reviewed* change +/// to the PAGE table layout, the AIR's preprocessed column count, or the +/// FFT / LDE / Merkle pipeline. Run: +/// +/// ```text +/// cargo run --bin compute_static_commitments --release +/// ``` +/// +/// and paste the printed match arms over the ones below. +/// +/// **If a drift test failed, do not regenerate first.** The drift tests +/// exist to force a human to ask "why did this change?" before the new +/// bytes get blessed. Re-pasting on a drift failure silently launders an +/// unintended table change into the verifier's compiled-in trust anchor. +fn static_zero_page_commitment(page_size: usize, blowup_factor: u8) -> Option { + match (page_size, blowup_factor) { + (DEFAULT_PAGE_SIZE, 2) => Some([ + 0xf9, 0x80, 0x0e, 0x45, 0x72, 0x5a, 0x8e, 0x8e, 0x5e, 0xd7, 0x5b, 0x60, 0xce, 0xd0, + 0x8e, 0xa3, 0x27, 0x3b, 0x8a, 0xb5, 0x98, 0xc0, 0xe3, 0x16, 0xf6, 0x86, 0x75, 0x39, + 0x4c, 0xe5, 0x88, 0x5e, + ]), + (DEFAULT_PAGE_SIZE, 4) => Some([ + 0x0f, 0xb5, 0x0c, 0xa8, 0x3b, 0x69, 0x4f, 0x91, 0x60, 0xbf, 0x0d, 0x0d, 0xd3, 0x33, + 0x25, 0x38, 0x11, 0xbb, 0xf8, 0xfd, 0x54, 0xbd, 0x06, 0x7d, 0xd1, 0xeb, 0xa3, 0x58, + 0xe8, 0x37, 0x45, 0x56, + ]), + (DEFAULT_PAGE_SIZE, 8) => Some([ + 0x4a, 0xfb, 0xc9, 0x6d, 0x46, 0x29, 0xa3, 0xc2, 0x36, 0x14, 0xd8, 0x24, 0x3e, 0xef, + 0x97, 0x3f, 0xe1, 0xda, 0x2b, 0xf7, 0x87, 0xb6, 0x54, 0xe1, 0xc6, 0x46, 0xc0, 0x85, + 0x96, 0x7f, 0x7f, 0x48, + ]), + _ => None, + } +} /// Computes the Merkle root commitment over the LDE of PAGE precomputed columns. /// /// The commitment covers OFFSET (0..page_size-1) and INIT (from config). /// Each page may have different INIT data, producing a different commitment. +/// +/// Exposed for the `compute_static_commitments` binary and the +/// drift-detection tests in `static_commitments_tests`. Production callers +/// should go through [`preprocessed_commitment`] so the static const-table +/// shortcut is used when applicable. +#[doc(hidden)] pub fn compute_precomputed_commitment(config: &PageConfig, options: &ProofOptions) -> Commitment { let page_size = config.page_size; assert!(page_size.is_power_of_two(), "Page size must be power of 2"); @@ -294,16 +343,33 @@ pub fn compute_precomputed_commitment(config: &PageConfig, options: &ProofOption tree.root } -/// Returns the preprocessed commitment for a PAGE table, with caching for zero-init pages. +/// Returns the preprocessed commitment for a PAGE table. /// -/// Zero-init pages of DEFAULT_PAGE_SIZE share a cached commitment. -/// ELF data pages compute their commitment fresh. -pub fn precomputed_commitment_cached(config: &PageConfig, options: &ProofOptions) -> Commitment { - if config.init_values.is_none() && config.page_size == DEFAULT_PAGE_SIZE { - *ZERO_PAGE_4K_COMMITMENT.get_or_init(|| compute_precomputed_commitment(config, options)) - } else { - compute_precomputed_commitment(config, options) +/// For zero-init pages, looks up `(page_size, blowup_factor)` in +/// [`static_zero_page_commitment`] when `coset_offset == 3` (the value the +/// static bytes were generated for); on miss — either a non-3 coset or a +/// `(page_size, blowup_factor)` pair outside the shipped match arms — +/// logs a warning and recomputes from scratch. ELF data pages always +/// recompute (no warning): their INIT column is program-dependent. +pub fn preprocessed_commitment(config: &PageConfig, options: &ProofOptions) -> Commitment { + if config.init_values.is_none() { + if options.coset_offset == 3 + && let Some(commitment) = + static_zero_page_commitment(config.page_size, options.blowup_factor) + { + return commitment; + } + log::warn!( + "zero-init page preprocessed commitment not static for \ + (page_size={}, blowup={}, coset={}); falling back to recompute. \ + Add a match arm to `static_zero_page_commitment` by running \ + `cargo run --bin compute_static_commitments --release`.", + config.page_size, + options.blowup_factor, + options.coset_offset, + ); } + compute_precomputed_commitment(config, options) } // ========================================================================= diff --git a/prover/src/tests/static_commitments_tests.rs b/prover/src/tests/static_commitments_tests.rs index c67e9baeb..f5e079caf 100644 --- a/prover/src/tests/static_commitments_tests.rs +++ b/prover/src/tests/static_commitments_tests.rs @@ -19,7 +19,7 @@ use stark::proof::options::GoldilocksCubicProofOptions; -use crate::tables::{STATIC_BLOWUP_FACTORS, bitwise, keccak_rc}; +use crate::tables::{STATIC_BLOWUP_FACTORS, bitwise, keccak_rc, page}; fn options_for(blowup: u8) -> stark::proof::options::ProofOptions { GoldilocksCubicProofOptions::with_blowup(blowup).expect("blowup must be a valid power of 2") @@ -73,6 +73,84 @@ fn keccak_rc_static_matches_recompute_for_all_blowups() { } } +/// Drift / dispatch test for the zero-init PAGE static commitments. For every +/// blowup in `STATIC_BLOWUP_FACTORS`, builds a synthetic zero-init page at +/// `DEFAULT_PAGE_SIZE` (page_base = 0 — the value doesn't affect the +/// commitment since OFFSET is page-relative and INIT is uniformly zero) and +/// asserts that `preprocessed_commitment` returns the same value as a +/// direct `compute_precomputed_commitment`. Catches AIR / FFT-pipeline drift +/// AND confirms the wrapper dispatches through `static_zero_page_commitment` +/// for the static blowups. +#[test] +fn zero_page_static_matches_recompute_for_all_blowups() { + let zero_page_config = page::PageConfig::zero_init(0, page::DEFAULT_PAGE_SIZE); + for &blowup in STATIC_BLOWUP_FACTORS { + let options = options_for(blowup); + let from_wrapper = page::preprocessed_commitment(&zero_page_config, &options); + let recomputed = page::compute_precomputed_commitment(&zero_page_config, &options); + assert_eq!( + from_wrapper, recomputed, + "zero-init page commitment drifted (or wrapper dispatch broke) for \ + blowup={blowup}; regenerate constants via \ + `cargo run --bin compute_static_commitments --release`", + ); + } +} + +/// Asserts the page wrapper's fallback path (no static entry for this +/// blowup) recomputes a commitment that matches the direct compute call. +/// Ignored by default: at NON_STATIC_BLOWUP=16, the page LDE is 2^22 rows × +/// 2 cols plus the FFT/Merkle build, which takes minutes per run. Run +/// explicitly when validating the wrapper's fallback for page. +#[test] +#[ignore = "heavy: page LDE at NON_STATIC_BLOWUP=16 is 2^22 rows × 2 cols; minutes per run"] +fn page_non_static_blowup_recomputes_via_fallback() { + assert!( + !STATIC_BLOWUP_FACTORS.contains(&NON_STATIC_BLOWUP), + "test relies on NON_STATIC_BLOWUP not being in STATIC_BLOWUP_FACTORS", + ); + let zero_page_config = page::PageConfig::zero_init(0, page::DEFAULT_PAGE_SIZE); + let options = options_for(NON_STATIC_BLOWUP); + let from_wrapper = page::preprocessed_commitment(&zero_page_config, &options); + let recomputed = page::compute_precomputed_commitment(&zero_page_config, &options); + assert_eq!( + from_wrapper, recomputed, + "page fallback returned a value that doesn't match direct compute at blowup={NON_STATIC_BLOWUP}", + ); +} + +/// Regression test for the `options.coset_offset == 3` gate in +/// `page::preprocessed_commitment`. With a non-3 coset offset, the wrapper +/// must NOT return a static value — it must recompute (matching direct +/// compute) and must NOT equal the coset-3 static commitment. Ignored by +/// default: each blowup at DEFAULT_PAGE_SIZE builds a 2^19-row × 2-col page +/// LDE, multiple seconds per blowup. Run explicitly when validating the +/// coset-3 gate for page. +#[test] +#[ignore = "heavy: 2^19-row page LDE per blowup; tens of seconds total"] +fn page_non_three_coset_recomputes_and_differs_from_static() { + let zero_page_config = page::PageConfig::zero_init(0, page::DEFAULT_PAGE_SIZE); + for &blowup in STATIC_BLOWUP_FACTORS { + let opts_coset3 = options_with_coset(blowup, STANDARD_COSET); + let opts_coset7 = options_with_coset(blowup, NON_STANDARD_COSET); + + let from_wrapper_7 = page::preprocessed_commitment(&zero_page_config, &opts_coset7); + let recomputed_7 = page::compute_precomputed_commitment(&zero_page_config, &opts_coset7); + let from_wrapper_3 = page::preprocessed_commitment(&zero_page_config, &opts_coset3); + + assert_eq!( + from_wrapper_7, recomputed_7, + "page wrapper at coset {NON_STANDARD_COSET} must take the recompute path \ + (blowup={blowup})", + ); + assert_ne!( + from_wrapper_7, from_wrapper_3, + "page commitment at coset {NON_STANDARD_COSET} must differ from coset \ + {STANDARD_COSET} static value (blowup={blowup})", + ); + } +} + /// Asserts the wrapper's fallback path (no static entry for this blowup) /// recomputes a commitment that matches the direct compute call. Uses /// keccak_rc because its table is only 32 rows, making the recompute cheap From d7587205cd129c2102b5d7bdd94c28b52506791d Mon Sep 17 00:00:00 2001 From: Nicole Date: Wed, 3 Jun 2026 15:26:24 -0300 Subject: [PATCH 3/3] change the preprocessed-commitment mismatch check from debug_assert_eq! to a real error --- crypto/stark/src/prover.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index dd26e020a..42b00f64d 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -74,6 +74,13 @@ where pub enum ProvingError { WrongParameter(String), EmptyCommitment, + /// The prover's recomputed preprocessed Merkle root did not match the + /// commitment the AIR was constructed with (e.g. a stale static constant + /// in a table module, or a wrong caller-supplied entry such as + /// `page_commitments` / `decode_commitment`). Continuing would yield a + /// proof an honest verifier always rejects — fail fast on the prover side + /// with a localized error instead. + PrecomputedCommitmentMismatch, /// I/O failure while spilling prover state (traces, LDE, Merkle trees) to disk: /// out of disk space, fd exhaustion, or mmap failure. #[cfg(feature = "disk-spill")] @@ -732,10 +739,9 @@ pub trait IsStarkProver< let (mut mult_tree, mult_root) = Self::commit_columns_bit_reversed(&columns[num_cols..]) .ok_or(ProvingError::EmptyCommitment)?; - debug_assert_eq!( - precomputed_root, expected_precomputed_root, - "Prover's precomputed commitment doesn't match hardcoded AIR commitment" - ); + if precomputed_root != expected_precomputed_root { + return Err(ProvingError::PrecomputedCommitmentMismatch); + } #[cfg(feature = "disk-spill")] if storage_mode == StorageMode::Disk { precomputed_tree.spill_nodes_to_disk().map_err(|e| {