Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bin/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,7 @@ fn cmd_verify(proof_path: PathBuf, elf_path: PathBuf, blowup: Option<u8>, 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),
};
Expand Down
14 changes: 10 additions & 4 deletions crypto/stark/src/prover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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| {
Expand Down
23 changes: 15 additions & 8 deletions prover/src/bin/compute_static_commitments.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
//! 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.
//!
//! Run with:
//! 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;

Expand All @@ -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,
Expand All @@ -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),
);
}
}
64 changes: 53 additions & 11 deletions prover/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,18 +337,29 @@ 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,
minimal_bitwise: bool,
page_configs: &[crate::tables::page::PageConfig],
table_counts: &TableCounts,
decode_commitment: Option<Commitment>,
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)))
Expand Down Expand Up @@ -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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low – undocumented first-match-wins behaviour

If page_commitments contains two entries with the same page_base (e.g. a caller accidentally duplicates an entry), .find() silently uses the first one. A wrong commitment will still cause Fiat-Shamir rejection, so this can't silently accept a bad proof, but it can cause a head-scratcher where a caller updates the second copy of a duplicate and wonders why nothing changes.

Consider documenting the contract ("each page_base must appear at most once") in the verify_with_options / VmAirs::new doc-comments, or adding a debug_assert! that the slice has no duplicate page_base values.

.map(|(_, c)| *c)
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The find scan is O(pages × list_len). Fine today — with DEFAULT_PAGE_SIZE = 256 KB, typical ELF programs have only a handful of data pages so the total work is tiny. If the ELF-page count ever grows materially, a pre-built HashMap<u64, Commitment> outside the iterator would be the fix.

.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();
Expand Down Expand Up @@ -663,6 +691,7 @@ pub fn prove_with_options_and_inputs(
&traces.page_configs,
&table_counts,
None,
None,
);

#[cfg(feature = "instruments")]
Expand Down Expand Up @@ -735,6 +764,7 @@ pub fn verify(vm_proof: &VmProof, elf_bytes: &[u8]) -> Result<bool, Error> {
elf_bytes,
&GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 is always valid"),
None,
None,
)
}

Expand All @@ -751,8 +781,18 @@ pub fn verify(vm_proof: &VmProof, elf_bytes: &[u8]) -> Result<bool, Error> {
/// 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.
Expand All @@ -761,6 +801,7 @@ pub fn verify_with_options(
elf_bytes: &[u8],
proof_options: &ProofOptions,
decode_commitment: Option<Commitment>,
page_commitments: Option<&[(u64, Commitment)]>,
) -> Result<bool, Error> {
// Validate table_counts before constructing AIRs.
// A malicious prover could set counts to 0, removing entire constraint sets.
Expand Down Expand Up @@ -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.
Expand Down
94 changes: 80 additions & 14 deletions prover/src/tables/page.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Commitment> = 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<Commitment> {
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)]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor naming nit: this function is compute_precomputed_commitment while the new public wrapper below is preprocessed_commitment — "precomputed" vs "preprocessed". The inconsistency is understandable (preserving the old name as #[doc(hidden)]), but a reader encountering both names for the first time may wonder if they compute different things. A brief one-liner comment on this function noting it's the raw computation behind preprocessed_commitment would make the relationship explicit.

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");
Expand Down Expand Up @@ -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)
}

// =========================================================================
Expand Down
11 changes: 7 additions & 4 deletions prover/src/tests/decode_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,7 @@ fn test_decode_soundness_same_elf_accepted() {
&traces.page_configs,
&table_counts,
None,
None,
);

let proof = multi_prove_ram(
Expand All @@ -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::<E>::new(&[]);
Expand Down Expand Up @@ -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");
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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!(
Expand Down
Loading
Loading