Skip to content
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
30 changes: 19 additions & 11 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
//! `static_commitment` match bodies in
//! `prover/src/tables/{bitwise,keccak_rc}.rs`. The
//! `static_commitments_tests` test suite pins the values so any drift in
//! 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`
//! and the `static_zero_page_commitment` match body in `prover/src/tables/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 `static_zero_page_commitment` in `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 @@ -34,10 +35,13 @@ 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"
"// Paste these match arms into the `static_commitment` match bodies\n\
// in `prover/src/tables/{{bitwise,keccak_rc}}.rs` and the\n\
// `static_zero_page_commitment` match body in `prover/src/tables/page.rs`.\n"
);

let zero_page_config = page::PageConfig::zero_init(0);

for &blowup in STATIC_BLOWUP_FACTORS {
let options = match GoldilocksCubicProofOptions::with_blowup(blowup) {
Ok(o) => o,
Expand All @@ -49,15 +53,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:\n \
{blowup} => Some({zero_page_fmt}),\n",
bitwise_fmt = format_commitment(&bitwise),
keccak_fmt = format_commitment(&keccak_rc),
zero_page_fmt = format_commitment(&zero_page),
);
}
}
75 changes: 54 additions & 21 deletions prover/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,18 +337,30 @@ 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::zero_init_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 @@ -404,31 +416,38 @@ impl VmAirs {
register::NUM_PREPROCESSED_COLS,
);
// Every zero-init page shares one preprocessed commitment: OFFSET is
// page-relative and INIT is all-zero, so it depends only on the proof
// options. Compute it once here rather than once per zero-init page.
// Every program has at least one zero-init page (the stack is
// zero-initialized), so this commitment is always used.
let zero_init_commitment =
page::compute_precomputed_commitment(&page::PageConfig::zero_init(0), proof_options);
// page-relative and INIT is all-zero, so it depends only on
// (blowup, coset) — all fixed here. Compute it once (static const
// when shipped, else a single recompute) rather than per page. Every
// program has at least one zero-init page (the stack is zero-
// initialized), so this commitment is always used.
let zero_init_commitment = page::zero_init_preprocessed_commitment(proof_options);

let pages: Vec<_> = page_configs
.iter()
.map(|config| {
let air = create_page_air(proof_options, config.page_base);
if config.is_private_input {
// Private-input pages: all columns are main trace (not preprocessed).
// The verifier doesn't see the init values; correctness is enforced
// by the memory bus constraints.
create_page_air(proof_options, config.page_base)
air
} else if config.init_values.is_none() {
// Zero-init pages: the shared commitment computed once above.
create_page_air(proof_options, config.page_base)
.with_preprocessed(zero_init_commitment, page::NUM_PREPROCESSED_COLS)
air.with_preprocessed(zero_init_commitment, page::NUM_PREPROCESSED_COLS)
} else {
// ELF data pages: INIT is program-specific, so recompute per page.
create_page_air(proof_options, config.page_base).with_preprocessed(
page::compute_precomputed_commitment(config, proof_options),
page::NUM_PREPROCESSED_COLS,
)
// ELF data pages: INIT is program-specific, so the commitment is
// per-page. Prefer a caller-supplied `(page_base, commitment)`
// (recursion guest); otherwise recompute from the ELF.
let commitment = page_commitments
.unwrap_or(&[])
.iter()
.find(|(pb, _)| *pb == config.page_base)
.map(|(_, c)| *c)
.unwrap_or_else(|| {
page::compute_precomputed_commitment(config, proof_options)
});
air.with_preprocessed(commitment, page::NUM_PREPROCESSED_COLS)
}
})
.collect();
Expand Down Expand Up @@ -674,6 +693,7 @@ pub fn prove_with_options_and_inputs(
&traces.page_configs,
&table_counts,
None,
None,
);

#[cfg(feature = "instruments")]
Expand Down Expand Up @@ -746,6 +766,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 @@ -762,8 +783,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 — take the normal compute path
/// (zero-init pages hit a compile-time constant via
/// `page::zero_init_preprocessed_commitment`; ELF data pages recompute
/// from the ELF). 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 @@ -772,6 +803,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 @@ -818,6 +850,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
80 changes: 80 additions & 0 deletions prover/src/tables/page.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,10 +214,65 @@ pub fn generate_page_trace(
// Preprocessed commitment
// =========================================================================

/// Returns the static zero-init PAGE preprocessed commitment for
/// `blowup_factor`, or `None` if no value is shipped for it. 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.
///
/// Because OFFSET is page-relative (`0..DEFAULT_PAGE_SIZE-1`) and INIT is
/// uniformly zero for zero-init pages, the commitment depends only on the
/// blowup factor — not on `page_base` or the program being verified. A
/// single entry covers every zero-init page 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.
pub(crate) fn static_zero_page_commitment(blowup_factor: u8) -> Option<Commitment> {
match blowup_factor {
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,
]),
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,
]),
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.
///
/// For zero-init pages, prefer [`zero_init_preprocessed_commitment`], which
/// returns a compile-time constant for the standard proof options instead
/// of rebuilding the FFT + Merkle tree.
pub fn compute_precomputed_commitment(config: &PageConfig, options: &ProofOptions) -> Commitment {
let page_size = DEFAULT_PAGE_SIZE;
let num_rows = page_size;
Expand Down Expand Up @@ -275,6 +330,31 @@ pub fn compute_precomputed_commitment(config: &PageConfig, options: &ProofOption
tree.root
}

/// Returns the zero-init PAGE preprocessed commitment.
///
/// Looks up `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 `blowup_factor` outside the shipped
/// match arms — logs a warning and recomputes from scratch. ELF data pages
/// have program-dependent INIT columns and no static entry; compute their
/// commitments with [`compute_precomputed_commitment`] directly.
pub fn zero_init_preprocessed_commitment(options: &ProofOptions) -> Commitment {
if options.coset_offset == 3
&& let Some(commitment) = static_zero_page_commitment(options.blowup_factor)
{
return commitment;
}
log::warn!(
"zero-init page preprocessed commitment not static for \
(blowup={}, coset={}); falling back to recompute. Add a match \
arm to `static_zero_page_commitment` by running \
`cargo run --bin compute_static_commitments --release`.",
options.blowup_factor,
options.coset_offset,
);
compute_precomputed_commitment(&PageConfig::zero_init(0), options)
}

// =========================================================================
// Bus interactions
// =========================================================================
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