From 5c40e069a7ac4381898efd7ff4072e30b42d1395 Mon Sep 17 00:00:00 2001 From: Mario Rugiero Date: Fri, 26 Jun 2026 18:03:52 -0300 Subject: [PATCH] feat: cache verifying key + commitments (recursion opt) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `VmVerifyingKey` (prover/src/vkey.rs): host-derived cache of the five preprocessed-table Merkle commitments (BITWISE, DECODE, REGISTER, KECCAK_RC, per-PAGE). `VmAirs::new_with_vkey` / `verify_with_options_with_vkey` take the cached commitments instead of recomputing them — recomputation is ~87% of verifier cycles inside the recursion guest. Soundness is preserved by Fiat-Shamir. The recursion and deserialize-only guests and the smoke test now encode the vkey into the postcard blob `(VmProof, elf, opts, vkey)`. --- bench_vs/lambda/deserialize-only/src/main.rs | 9 +- bench_vs/lambda/recursion/Cargo.lock | 76 ++++---- bench_vs/lambda/recursion/src/main.rs | 23 ++- prover/Cargo.toml | 1 + prover/src/lib.rs | 100 +++++++++-- prover/src/tables/page.rs | 20 +++ prover/src/tests/mod.rs | 2 + prover/src/tests/recursion_smoke_test.rs | 13 +- prover/src/tests/vkey_tests.rs | 180 +++++++++++++++++++ prover/src/vkey.rs | 126 +++++++++++++ 10 files changed, 484 insertions(+), 66 deletions(-) create mode 100644 prover/src/tests/vkey_tests.rs create mode 100644 prover/src/vkey.rs diff --git a/bench_vs/lambda/deserialize-only/src/main.rs b/bench_vs/lambda/deserialize-only/src/main.rs index 8627776a1..e2cecc938 100644 --- a/bench_vs/lambda/deserialize-only/src/main.rs +++ b/bench_vs/lambda/deserialize-only/src/main.rs @@ -1,7 +1,7 @@ //! Deserialize-only counterpart to the recursion guest. //! //! Reads the same private-input blob as `recursion-bench`, postcard-decodes -//! `(VmProof, Vec, ProofOptions)`, then commits success +//! `(VmProof, Vec, ProofOptions, VmVerifyingKey)`, then commits success //! and halts — without ever calling `verify_with_options`. The cycle delta //! between this guest and `recursion-bench` is the actual cost of the STARK //! verifier inside the VM (everything else being equal). @@ -16,7 +16,7 @@ use core::arch::asm; use core::panic::PanicInfo; use embedded_alloc::TlsfHeap as Heap; -use lambda_vm_prover::{ProofOptions, VmProof}; +use lambda_vm_prover::{ProofOptions, VmProof, VmVerifyingKey}; // Required to pull in the riscv crate's critical-section implementation. use riscv as _; @@ -75,7 +75,7 @@ pub fn main() -> ! { init_allocator(); let blob = read_private_input(); - let decoded: (VmProof, Vec, ProofOptions) = + let decoded: (VmProof, Vec, ProofOptions, VmVerifyingKey) = postcard::from_bytes(blob).expect("failed to deserialize"); // Force the commit byte to depend on the actually-decoded value. Without @@ -86,7 +86,8 @@ pub fn main() -> ! { // to a deep field of the decoded value, the decode has to run. let proof_options_byte = decoded.2.blowup_factor; let inner_elf_byte = *decoded.1.first().unwrap_or(&0); - let marker = proof_options_byte ^ inner_elf_byte; + let vkey_byte = decoded.3.bitwise[0]; + let marker = proof_options_byte ^ inner_elf_byte ^ vkey_byte; commit(&[marker]); halt() diff --git a/bench_vs/lambda/recursion/Cargo.lock b/bench_vs/lambda/recursion/Cargo.lock index 08f8ebf4f..c19590031 100644 --- a/bench_vs/lambda/recursion/Cargo.lock +++ b/bench_vs/lambda/recursion/Cargo.lock @@ -16,9 +16,9 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.5.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "base16ct" @@ -43,9 +43,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.3" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "cfg-if" @@ -115,9 +115,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", @@ -154,9 +154,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "elliptic-curve" @@ -250,9 +250,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.9" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", @@ -303,12 +303,13 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", + "once_cell", "wasm-bindgen", ] @@ -341,6 +342,7 @@ dependencies = [ "hashbrown", "log", "math", + "postcard", "serde", "sha3", "stark", @@ -366,9 +368,9 @@ checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" [[package]] name = "log" -version = "0.4.33" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "math" @@ -475,9 +477,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.46" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -539,7 +541,7 @@ checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -597,7 +599,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -663,9 +665,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -689,14 +691,14 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] name = "typenum" -version = "1.20.1" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "unicode-ident" @@ -724,9 +726,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -737,9 +739,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -747,44 +749,44 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] diff --git a/bench_vs/lambda/recursion/src/main.rs b/bench_vs/lambda/recursion/src/main.rs index 459f12716..a226ea225 100644 --- a/bench_vs/lambda/recursion/src/main.rs +++ b/bench_vs/lambda/recursion/src/main.rs @@ -8,7 +8,7 @@ use core::arch::asm; use core::panic::PanicInfo; use embedded_alloc::TlsfHeap as Heap; -use lambda_vm_prover::{ProofOptions, VmProof}; +use lambda_vm_prover::{ProofOptions, VmProof, VmVerifyingKey}; // Required to pull in the riscv crate's critical-section implementation. use riscv as _; @@ -67,18 +67,29 @@ fn halt() -> ! { } /// Private input layout (postcard-encoded): -/// (VmProof, Vec, ProofOptions) -/// where the `Vec` holds the inner program's ELF bytes and the -/// `ProofOptions` specifies the parameters the inner prover used. +/// (VmProof, Vec, ProofOptions, VmVerifyingKey) +/// where the `Vec` holds the inner program's ELF bytes, the +/// `ProofOptions` specifies the parameters the inner prover used, and the +/// `VmVerifyingKey` carries the host-derived bitwise preprocessed commitment +/// so the guest can skip the ~87% of verifier cycles that would otherwise be +/// spent recomputing it from scratch. #[unsafe(no_mangle)] pub fn main() -> ! { init_allocator(); let blob = read_private_input(); - let (vm_proof, inner_elf, options): (VmProof, Vec, ProofOptions) = + let (vm_proof, inner_elf, options, vkey): (VmProof, Vec, ProofOptions, VmVerifyingKey) = postcard::from_bytes(blob).expect("failed to deserialize recursion input"); - let ok = lambda_vm_prover::verify_with_options(&vm_proof, &inner_elf, &options, None, None) + let ok = + lambda_vm_prover::verify_with_options_with_vkey( + &vm_proof, + &inner_elf, + &options, + None, + None, + Some(&vkey), + ) .expect("verify errored"); assert!(ok, "inner proof failed verification"); diff --git a/prover/Cargo.toml b/prover/Cargo.toml index 8913b5eff..6b5b28cf1 100644 --- a/prover/Cargo.toml +++ b/prover/Cargo.toml @@ -30,6 +30,7 @@ rayon = { version = "1.8.0", optional = true } sysinfo = { version = "0.31", default-features = false, features = ["system"], optional = true } log = "0.4" sha3 = { version = "0.10.8", default-features = false } +postcard = { version = "1.0", default-features = false, features = ["alloc"] } [dev-dependencies] env_logger = "*" diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 235f12c90..23d95ae23 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -31,6 +31,9 @@ pub mod tables; pub mod test_utils; #[cfg(test)] pub mod tests; +pub mod vkey; + +pub use vkey::VmVerifyingKey; use alloc::format; use alloc::string::String; @@ -434,6 +437,32 @@ impl VmAirs { table_counts: &TableCounts, decode_commitment: Option, page_commitments: Option<&[(u64, Commitment)]>, + ) -> Self { + Self::new_with_vkey( + elf, + proof_options, + minimal_bitwise, + page_configs, + table_counts, + decode_commitment, + page_commitments, + None, + ) + } + + /// Same as [`Self::new`] but accepts a precomputed [`VmVerifyingKey`]. + /// When `vkey` is `Some`, the bitwise preprocessed commitment is taken + /// from it instead of being recomputed from `proof_options` — that + /// recomputation is ~87% of verifier cycles inside the recursion guest. + pub fn new_with_vkey( + elf: &Elf, + proof_options: &ProofOptions, + minimal_bitwise: bool, + page_configs: &[crate::tables::page::PageConfig], + table_counts: &TableCounts, + decode_commitment: Option, + page_commitments: Option<&[(u64, Commitment)]>, + vkey: Option<&VmVerifyingKey>, ) -> Self { let cpus: Vec<_> = (0..table_counts.cpu) .map(|i| create_cpu_air(proof_options).with_name(&format!("CPU[{}]", i))) @@ -441,10 +470,12 @@ impl VmAirs { let bitwise = if minimal_bitwise { create_bitwise_air(proof_options) } else { - create_bitwise_air(proof_options).with_preprocessed( - bitwise::preprocessed_commitment(proof_options), - bitwise::NUM_PRECOMPUTED_COLS, - ) + let commitment = match vkey { + Some(vk) => vk.bitwise, + None => bitwise::preprocessed_commitment(proof_options), + }; + create_bitwise_air(proof_options) + .with_preprocessed(commitment, bitwise::NUM_PRECOMPUTED_COLS) }; let lts: Vec<_> = (0..table_counts.lt) .map(|i| create_lt_air(proof_options).with_name(&format!("LT[{}]", i))) @@ -461,10 +492,12 @@ impl VmAirs { let loads: Vec<_> = (0..table_counts.load) .map(|i| create_load_air(proof_options).with_name(&format!("LOAD[{}]", i))) .collect(); - let decode_root = decode_commitment.unwrap_or_else(|| { - decode::commitment_from_elf(elf, proof_options) - .expect("Failed to compute decode commitment") - }); + let decode_root = decode_commitment + .or_else(|| vkey.map(|vk| vk.decode)) + .unwrap_or_else(|| { + decode::commitment_from_elf(elf, proof_options) + .expect("Failed to compute decode commitment") + }); let decode = create_decode_air(proof_options) .with_preprocessed(decode_root, decode::NUM_PRECOMPUTED_COLS); let muls: Vec<_> = (0..table_counts.mul) @@ -480,17 +513,21 @@ impl VmAirs { let commit = create_commit_air(proof_options); let keccak = create_keccak_air(proof_options); let keccak_rnd = create_keccak_rnd_air(proof_options); + let keccak_rc_commitment = vkey + .map(|vk| vk.keccak_rc) + .unwrap_or_else(|| tables::keccak_rc::preprocessed_commitment(proof_options)); let keccak_rc = create_keccak_rc_air(proof_options).with_preprocessed( - tables::keccak_rc::preprocessed_commitment(proof_options), + keccak_rc_commitment, tables::keccak_rc::NUM_PRECOMPUTED_COLS, ); let ecsm = create_ecsm_air(proof_options); let ec_scalar = create_ec_scalar_air(proof_options); let ecdas = create_ecdas_air(proof_options); - let register = create_register_air(proof_options).with_preprocessed( - register::preprocessed_commitment(proof_options, elf.entry_point), - register::NUM_PREPROCESSED_COLS, - ); + let register_commitment = vkey + .map(|vk| vk.register) + .unwrap_or_else(|| register::preprocessed_commitment(proof_options, elf.entry_point)); + let register = create_register_air(proof_options) + .with_preprocessed(register_commitment, 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 // (blowup, coset) — all fixed here. Compute it once (static const @@ -501,7 +538,8 @@ impl VmAirs { let pages: Vec<_> = page_configs .iter() - .map(|config| { + .enumerate() + .map(|(index, 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). @@ -510,16 +548,21 @@ impl VmAirs { air } else if config.init_values.is_none() { // Zero-init pages: the shared commitment computed once above. + // `vkey.pages` caches the same static value for these slots, + // so the local lookup is equivalent and equally cheap. air.with_preprocessed(zero_init_commitment, page::NUM_PREPROCESSED_COLS) } else { // 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. + // (recursion guest), then the vkey's cached per-page root + // (indexed parallel to `page_configs`); otherwise recompute + // from the ELF. let commitment = page_commitments .unwrap_or(&[]) .iter() .find(|(pb, _)| *pb == config.page_base) .map(|(_, c)| *c) + .or_else(|| vkey.map(|vk| vk.pages[index])) .unwrap_or_else(|| { page::compute_precomputed_commitment(config, proof_options) }); @@ -904,6 +947,30 @@ pub fn verify_with_options( proof_options: &ProofOptions, decode_commitment: Option, page_commitments: Option<&[(u64, Commitment)]>, +) -> Result { + verify_with_options_with_vkey( + vm_proof, + elf_bytes, + proof_options, + decode_commitment, + page_commitments, + None, + ) +} + +/// Same as [`verify_with_options`] but accepts a precomputed +/// [`VmVerifyingKey`]. When `vkey` is `Some`, the bitwise preprocessed +/// commitment is taken from it instead of being recomputed inside +/// `VmAirs::new`. A tampered vkey is caught by Fiat-Shamir: the verifier +/// feeds the supplied commitment into the transcript, derives different +/// challenges from what the prover used, and the openings stop matching. +pub fn verify_with_options_with_vkey( + vm_proof: &VmProof, + elf_bytes: &[u8], + proof_options: &ProofOptions, + decode_commitment: Option, + page_commitments: Option<&[(u64, Commitment)]>, + vkey: Option<&VmVerifyingKey>, ) -> Result { // Validate table_counts before constructing AIRs. // A malicious prover could set counts to 0, removing entire constraint sets. @@ -944,7 +1011,7 @@ pub fn verify_with_options( ))); } - let airs = VmAirs::new( + let airs = VmAirs::new_with_vkey( &program, proof_options, false, @@ -952,6 +1019,7 @@ pub fn verify_with_options( &vm_proof.table_counts, decode_commitment, page_commitments, + vkey, ); // Recompute the COMMIT output bus offset from VmProof.public_output. diff --git a/prover/src/tables/page.rs b/prover/src/tables/page.rs index bde24a9ef..edb9c8f36 100644 --- a/prover/src/tables/page.rs +++ b/prover/src/tables/page.rs @@ -338,6 +338,26 @@ pub fn compute_precomputed_commitment(config: &PageConfig, options: &ProofOption tree.root } +/// Returns a page's preprocessed commitment, preferring the cheap path. +/// +/// Zero-init pages (INIT is all-zero) share a single commitment that depends +/// only on `(blowup, coset)`, so they resolve to the static lookup in +/// [`zero_init_preprocessed_commitment`] instead of rebuilding the FFT + +/// Merkle tree. ELF data pages have program-specific INIT and fall through +/// to [`compute_precomputed_commitment`]. This mirrors the per-page choice +/// made in `VmAirs::new_with_vkey`, so a vkey built from this function caches +/// exactly the commitments the verifier expects. +/// +/// Private-input pages have no preprocessed commitment; callers must skip +/// them before calling this. +pub fn precomputed_commitment_cached(config: &PageConfig, options: &ProofOptions) -> Commitment { + if config.init_values.is_none() { + zero_init_preprocessed_commitment(options) + } else { + compute_precomputed_commitment(config, options) + } +} + /// Returns the zero-init PAGE preprocessed commitment. /// /// Looks up `blowup_factor` in [`static_zero_page_commitment`] when diff --git a/prover/src/tests/mod.rs b/prover/src/tests/mod.rs index 32a55ae59..b253dd543 100644 --- a/prover/src/tests/mod.rs +++ b/prover/src/tests/mod.rs @@ -76,3 +76,5 @@ pub mod templates_tests; pub mod trace_builder_tests; #[cfg(test)] pub mod trace_test_helpers; +#[cfg(test)] +pub mod vkey_tests; diff --git a/prover/src/tests/recursion_smoke_test.rs b/prover/src/tests/recursion_smoke_test.rs index 478be3344..87948f7fb 100644 --- a/prover/src/tests/recursion_smoke_test.rs +++ b/prover/src/tests/recursion_smoke_test.rs @@ -58,7 +58,7 @@ const MIN_PROOF_OPTIONS: stark::proof::options::ProofOptions = }; /// Prove `inner_elf` (fed `inner_input`) under `opts`, then package -/// `(proof, elf, opts)` into the postcard blob the recursion and +/// `(proof, elf, opts, vkey)` into the postcard blob the recursion and /// deserialize-only guests consume as their private input. `tag` prefixes the /// progress lines. Returns the inner proof — callers that re-verify it on the /// host need it — next to the encoded blob. @@ -80,7 +80,14 @@ fn prove_inner_and_encode_blob( ) .expect("inner prove should succeed"); - let blob = postcard::to_allocvec(&(&inner_proof, &inner_elf, opts)) + let elf_for_vkey = executor::elf::Elf::load(inner_elf).expect("ELF load failed"); + let page_configs = crate::tables::trace_builder::Traces::page_configs_from_elf_and_runtime( + &elf_for_vkey, + &inner_proof.runtime_page_ranges, + inner_proof.num_private_input_pages, + ); + let vkey = crate::VmVerifyingKey::from_elf_and_options(&elf_for_vkey, opts, &page_configs); + let blob = postcard::to_allocvec(&(&inner_proof, &inner_elf, opts, &vkey)) .expect("postcard encode failed"); eprintln!("[{tag}] postcard blob: {} bytes", blob.len()); (inner_proof, blob) @@ -794,7 +801,7 @@ fn test_host_verify_step_timings() { /// Diagnostic: cycle count for the **deserialize-only** counterpart of the /// recursion guest. Same input layout -/// (`(VmProof, Vec, ProofOptions)`) and same proof, but +/// (`(VmProof, Vec, ProofOptions, VmVerifyingKey)`) and same proof, but /// the guest just postcard-decodes the blob and halts — it never calls /// `verify_with_options`. /// diff --git a/prover/src/tests/vkey_tests.rs b/prover/src/tests/vkey_tests.rs new file mode 100644 index 000000000..aba3420d0 --- /dev/null +++ b/prover/src/tests/vkey_tests.rs @@ -0,0 +1,180 @@ +//! Tests for [`crate::VmVerifyingKey`] and the vkey-aware verify path. + +use executor::elf::Elf; +use stark::proof::options::{GoldilocksCubicProofOptions, ProofOptions}; + +use crate::VmVerifyingKey; +use crate::tables::page::PageConfig; +use crate::tables::trace_builder::Traces; +use crate::test_utils::asm_elf_bytes; +use crate::vkey::VKEY_VERSION; +use crate::{VmProof, prove}; + +fn default_options() -> ProofOptions { + GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 is always valid") +} + +/// Derive the same `page_configs` slice the verifier would reconstruct from +/// `vm_proof`. This is exactly what `verify_with_options_with_vkey` does +/// internally, lifted into the test so the test-side and verifier-side +/// `vkey.pages` indexing line up. +fn page_configs_from_proof(elf: &Elf, vm_proof: &VmProof) -> Vec { + Traces::page_configs_from_elf_and_runtime( + elf, + &vm_proof.runtime_page_ranges, + vm_proof.num_private_input_pages, + ) +} + +#[test] +fn test_vkey_roundtrip() { + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + + let vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); + assert_eq!(vkey.version, VKEY_VERSION, "version field must be set"); + assert_eq!( + vkey.pages.len(), + page_configs.len(), + "vkey.pages must have one entry per page config", + ); + let digest_before = vkey.compute_digest(); + + // Two host derivations on the same inputs must produce the same vkey; + // the per-table commitment caches should not change between calls. + let vkey_again = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); + assert_eq!(vkey, vkey_again, "vkey derivation must be deterministic"); + + // postcard round-trip preserves every field. + let encoded = postcard::to_allocvec(&vkey).expect("postcard encode"); + let decoded: VmVerifyingKey = postcard::from_bytes(&encoded).expect("postcard decode"); + assert_eq!(vkey, decoded, "postcard round-trip must preserve the vkey"); + assert_eq!( + decoded.compute_digest(), + digest_before, + "digest must be stable across serialization" + ); +} + +#[test] +fn test_vkey_verify_equivalence() { + // Prove a tiny program once with the full (non-minimal) bitwise table, + // then verify it both ways: with and without a precomputed vkey. + // Both paths must accept the proof. This is the core correctness + // guarantee — the vkey shortcut produces identical results to the + // recompute-from-scratch path. + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + let vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); + + let baseline = crate::verify_with_options(&vm_proof, &elf_bytes, &options, None, None) + .expect("baseline verify errored"); + assert!(baseline, "baseline verify must accept the proof"); + + let with_vkey = + crate::verify_with_options_with_vkey(&vm_proof, &elf_bytes, &options, None, None, Some(&vkey)) + .expect("vkey verify errored"); + assert!(with_vkey, "vkey verify must accept the same proof"); +} + +#[test] +fn test_vkey_mismatch_rejects() { + // Tamper with vkey.bitwise. Without an explicit `vk_digest` field on + // VmProof (deferred to a later PR), rejection comes from Fiat-Shamir: + // the verifier feeds the tampered commitment into the transcript, + // derives different challenges from what the prover used, and the + // proof's openings stop matching. + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + let mut vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); + + vkey.bitwise[0] ^= 0xFF; + + let result = crate::verify_with_options_with_vkey(&vm_proof, &elf_bytes, &options, None, None, Some(&vkey)) + .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); + assert!(!result, "tampered bitwise commitment must cause rejection"); +} + +#[test] +fn test_vkey_page_mismatch_rejects() { + // Same shape as `test_vkey_mismatch_rejects`, but tampers with the page + // table that gets it first non-private-input slot. Fiat-Shamir rejects + // the same way: the page commitment is in the verifier's transcript + // exactly like the bitwise one. + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + let mut vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); + + let target = page_configs + .iter() + .position(|c| !c.is_private_input) + .expect("test ELF must produce at least one non-private-input page"); + vkey.pages[target][0] ^= 0xFF; + + let result = crate::verify_with_options_with_vkey(&vm_proof, &elf_bytes, &options, None, None, Some(&vkey)) + .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); + assert!(!result, "tampered page commitment must cause rejection"); +} + +#[test] +fn test_vkey_decode_mismatch_rejects() { + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + let mut vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); + + vkey.decode[0] ^= 0xFF; + + let result = crate::verify_with_options_with_vkey(&vm_proof, &elf_bytes, &options, None, None, Some(&vkey)) + .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); + assert!(!result, "tampered decode commitment must cause rejection"); +} + +#[test] +fn test_vkey_register_mismatch_rejects() { + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + let mut vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); + + vkey.register[0] ^= 0xFF; + + let result = crate::verify_with_options_with_vkey(&vm_proof, &elf_bytes, &options, None, None, Some(&vkey)) + .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); + assert!(!result, "tampered register commitment must cause rejection"); +} + +#[test] +fn test_vkey_keccak_rc_mismatch_rejects() { + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + let mut vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); + + vkey.keccak_rc[0] ^= 0xFF; + + let result = crate::verify_with_options_with_vkey(&vm_proof, &elf_bytes, &options, None, None, Some(&vkey)) + .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); + assert!( + !result, + "tampered keccak_rc commitment must cause rejection" + ); +} diff --git a/prover/src/vkey.rs b/prover/src/vkey.rs new file mode 100644 index 000000000..a81d31bb3 --- /dev/null +++ b/prover/src/vkey.rs @@ -0,0 +1,126 @@ +//! Verifying key for the lambda-vm STARK verifier. +//! +//! Caches preprocessed-table Merkle commitments that the verifier would +//! otherwise recompute on every call. Mirrors the SP1 `MachineVerifyingKey` +//! pattern (preprocessed commitments derived once at setup, never recomputed +//! per-proof) and the prover-side companion in +//! (which caches the +//! same data on the prover side). +//! +//! ## Current scope +//! +//! All five preprocessed tables — BITWISE, DECODE, REGISTER, KECCAK_RC, and +//! every non-private-input PAGE — are cached here. `VmAirs::new_with_vkey` +//! prefers the vkey-supplied commitment over recomputing when a vkey is +//! provided. The `version` field exists so a vkey serialized against an +//! older layout produces a different `compute_digest()` and stops +//! validating. +//! +//! ## Security +//! +//! For this PR the verifying key is only a performance shortcut. The +//! verifier still relies on Fiat-Shamir: every preprocessed commitment the +//! prover used is bound into the proof's challenges, so a verifier that +//! consumes a tampered `vkey` field derives different challenges, the +//! openings stop matching, and verification fails. A future PR will +//! additionally embed `vkey.compute_digest()` in `VmProof` so vkey +//! substitution surfaces as an explicit error before any STARK work runs. + +use alloc::vec::Vec; + +use executor::elf::Elf; +use sha3::{Digest, Keccak256}; +use stark::config::Commitment; +use stark::proof::options::ProofOptions; + +use crate::tables::bitwise; +use crate::tables::decode; +use crate::tables::keccak_rc; +use crate::tables::page::{self, PageConfig}; +use crate::tables::register; + +/// Current `VmVerifyingKey` layout version. Bump whenever fields are added, +/// removed, or reordered so that vkeys serialized against an older layout +/// produce a different `compute_digest()` and stop validating. +pub const VKEY_VERSION: u32 = 3; + +/// Placeholder commitment stored in [`VmVerifyingKey::pages`] for +/// private-input page slots, where there is no preprocessed commitment to +/// cache. The verifier never reads these slots (private-input pages have no +/// `with_preprocessed(...)` call in `VmAirs::new`). +const PRIVATE_INPUT_PAGE_PLACEHOLDER: Commitment = [0u8; 32]; + +/// Cached preprocessed-table commitments the verifier would otherwise +/// recompute on every call. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct VmVerifyingKey { + /// Layout version. See [`VKEY_VERSION`]. + pub version: u32, + /// Merkle root over the LDE of the bitwise preprocessed columns. + /// Program-independent; depends only on `ProofOptions`. + pub bitwise: Commitment, + /// Merkle root over the LDE of the decode preprocessed columns. + /// Program-dependent: derived from the inner ELF's instruction stream. + pub decode: Commitment, + /// Merkle root over the LDE of the register preprocessed columns. + /// Program-dependent via the ELF's entry point. + pub register: Commitment, + /// Merkle root over the LDE of the keccak round-constants preprocessed + /// columns. Program-independent; depends only on `ProofOptions`. + pub keccak_rc: Commitment, + /// Per-page preprocessed Merkle roots, indexed parallel to the + /// `page_configs` slice the verifier reconstructs from the proof via + /// [`crate::tables::trace_builder::Traces::page_configs_from_elf_and_runtime`]. + /// Private-input slots hold a zero placeholder and are never read by the + /// verifier — they exist only to keep the index aligned with + /// `page_configs`, which interleaves preprocessed and private-input pages. + pub pages: Vec, +} + +impl VmVerifyingKey { + /// Derive the verifying key on the host. + /// + /// `elf` is read to derive the program-dependent commitments (DECODE + /// from the instruction stream, REGISTER from `elf.entry_point`). + /// + /// `page_configs` must match exactly what the verifier will reconstruct + /// from the proof — i.e. the output of + /// `Traces::page_configs_from_elf_and_runtime(elf, runtime_page_ranges, + /// num_private_input_pages)`. The host can call that helper with the + /// values it already has after producing the inner proof. + pub fn from_elf_and_options( + elf: &Elf, + options: &ProofOptions, + page_configs: &[PageConfig], + ) -> Self { + let pages = page_configs + .iter() + .map(|config| { + if config.is_private_input { + PRIVATE_INPUT_PAGE_PLACEHOLDER + } else { + page::precomputed_commitment_cached(config, options) + } + }) + .collect(); + Self { + version: VKEY_VERSION, + bitwise: bitwise::preprocessed_commitment(options), + decode: decode::commitment_from_elf(elf, options) + .expect("decode commitment must compute"), + register: register::preprocessed_commitment(options, elf.entry_point), + keccak_rc: keccak_rc::preprocessed_commitment(options), + pages, + } + } + + /// Keccak256 fingerprint of the postcard-serialized vkey. Stable as long + /// as the field layout (and [`VKEY_VERSION`]) does not change. + pub fn compute_digest(&self) -> [u8; 32] { + let bytes = postcard::to_allocvec(self) + .expect("postcard serialization of VmVerifyingKey must succeed"); + let mut hasher = Keccak256::new(); + hasher.update(&bytes); + hasher.finalize().into() + } +}