From cfdbb692d747cefb9b0463281085cea7d812f3be Mon Sep 17 00:00:00 2001 From: diegokingston Date: Wed, 19 Aug 2026 16:14:41 -0300 Subject: [PATCH 01/15] feat(hints): replace the hint ecall with a private-input hint arena MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The non-constraining hint ecall (and its prover HINT table) is replaced by untrusted 32-byte hint slots appended to the private-input region: [u32 len][data][pad8][u32 hint_count][u32 pad][32-byte slots] Guests consume slots positionally (syscalls: hint_count/hint_slot/ next_hint/request_hint) and must still verify each hint in-circuit, falling back to software on failure or arena exhaustion. A lying host can only force fallbacks, never change the result. When hints are not known beforehand, request_hint appends (hint_id, input) to a request log above the private-input window; the host reads it back (Memory::hint_requests / ExecutionResult::hint_requests), answers with compute_hint, and re-runs with a complete arena (executor::collect_hints — the two-pass flow). - executor: encode_private_input_region (single source of truth for the wire format), hints threaded through Executor::new, ecall dispatch and HINT_* error variants removed - prover: HINT table and its trace-builder/CPU plumbing removed; hints threaded through prove_*/count_*/Traces::from_* - ethrex-crypto: field_inv/scalar_inv/decompress_r read from the arena (verify + software fallback kept) - cli: --hints on execute/prove/count-elements, execute --record-hints for the recording pass - guests: hint_arena (slot API) and ecrecover_hints (N recoveries); two-pass measurement drivers on the executor and prover (continuation) sides Measured on 30 ecrecovers: 6.41M guest cycles with software fallback vs 866k with arena hints (7.40x); continuation proof of the hinted run verifies end-to-end (14 epochs @ 2^16). --- bin/cli/src/main.rs | 148 ++- crypto/ethrex-crypto/src/lib.rs | 84 +- crypto/ethrex-crypto/src/tests/hint_tests.rs | 20 +- .../.cargo/config.toml | 0 .../programs/rust/ecrecover_hints/Cargo.lock | 1011 +++++++++++++++++ .../programs/rust/ecrecover_hints/Cargo.toml | 16 + .../programs/rust/ecrecover_hints/src/main.rs | 38 + .../.cargo/config.toml | 0 .../rust/{hint_min => hint_arena}/Cargo.lock | 96 +- .../{hint_multi => hint_arena}/Cargo.toml | 2 +- executor/programs/rust/hint_arena/src/main.rs | 35 + executor/programs/rust/hint_min/Cargo.toml | 9 - executor/programs/rust/hint_min/src/main.rs | 31 - executor/programs/rust/hint_multi/Cargo.lock | 331 ------ executor/programs/rust/hint_multi/src/main.rs | 43 - executor/src/flamegraph.rs | 4 +- executor/src/main.rs | 2 +- executor/src/tests/hint_tests.rs | 196 ---- executor/src/tests/mod.rs | 1 - executor/src/vm/execution.rs | 40 +- executor/src/vm/instruction/execution.rs | 93 +- executor/src/vm/memory.rs | 277 ++++- executor/tests/asm.rs | 12 +- executor/tests/hint_arena_ecrecover.rs | 121 ++ executor/tests/rust.rs | 2 +- prover/benches/bench_continuation.rs | 6 +- prover/src/continuation.rs | 101 +- prover/src/lib.rs | 41 +- prover/src/tables/cpu.rs | 8 - prover/src/tables/hint.rs | 373 ------ prover/src/tables/mod.rs | 1 - prover/src/tables/page.rs | 25 +- prover/src/tables/trace_builder.rs | 226 +--- prover/src/test_utils.rs | 20 +- .../tests/constraint_program_device_tests.rs | 1 - prover/src/tests/constraint_program_tests.rs | 1 - prover/src/tests/constraint_set_tests_b.rs | 16 - .../tests/count_table_lengths_drift_tests.rs | 33 +- prover/src/tests/hint_tests.rs | 171 --- prover/src/tests/mod.rs | 2 - prover/src/tests/ood_window_ir_tests.rs | 1 - prover/src/tests/page_offset_forgery_poc.rs | 8 +- prover/src/tests/prove_elfs_tests.rs | 481 ++------ prover/src/tests/recursion_smoke_test.rs | 9 +- .../src/tests/recursion_soundness_gap_poc.rs | 5 +- prover/src/tests/trace_builder_tests.rs | 28 +- prover/src/tests/trace_test_helpers.rs | 2 + prover/tests/calibration.rs | 4 +- prover/tests/ecrecover_hints_continuation.rs | 61 + prover/tests/gpu_constraint_interp_real.rs | 1 - syscalls/src/syscalls.rs | 193 +++- 51 files changed, 2267 insertions(+), 2163 deletions(-) rename executor/programs/rust/{hint_min => ecrecover_hints}/.cargo/config.toml (100%) create mode 100644 executor/programs/rust/ecrecover_hints/Cargo.lock create mode 100644 executor/programs/rust/ecrecover_hints/Cargo.toml create mode 100644 executor/programs/rust/ecrecover_hints/src/main.rs rename executor/programs/rust/{hint_multi => hint_arena}/.cargo/config.toml (100%) rename executor/programs/rust/{hint_min => hint_arena}/Cargo.lock (72%) rename executor/programs/rust/{hint_multi => hint_arena}/Cargo.toml (86%) create mode 100644 executor/programs/rust/hint_arena/src/main.rs delete mode 100644 executor/programs/rust/hint_min/Cargo.toml delete mode 100644 executor/programs/rust/hint_min/src/main.rs delete mode 100644 executor/programs/rust/hint_multi/Cargo.lock delete mode 100644 executor/programs/rust/hint_multi/src/main.rs delete mode 100644 executor/src/tests/hint_tests.rs create mode 100644 executor/tests/hint_arena_ecrecover.rs delete mode 100644 prover/src/tables/hint.rs delete mode 100644 prover/src/tests/hint_tests.rs create mode 100644 prover/tests/ecrecover_hints_continuation.rs diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index a04e920db..64607f356 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -147,6 +147,23 @@ enum Commands { /// data). #[arg(long)] cycles: bool, + + /// Path to a hint arena file (concatenated 32-byte slots) appended to + /// the private-input region; produce one with --record-hints. The + /// flamegraph path always runs hint-free (conservative profiles). + #[arg(long, value_hint = ValueHint::FilePath, conflicts_with = "flamegraph")] + hints: Option, + + /// Recording pass of the two-pass hint flow: run once with an empty + /// hint arena, answer the guest's logged hint requests host-side, and + /// write the resulting arena (concatenated 32-byte slots) to this file + /// for use with --hints on the second, provable run. + #[arg( + long, + value_hint = ValueHint::FilePath, + conflicts_with_all = ["flamegraph", "cycle_budget", "hints", "cycles"] + )] + record_hints: Option, }, /// Generate a proof for an ELF program @@ -193,6 +210,11 @@ enum Commands { long_help = "Continuation epoch size as log2(cycles); e.g. 20 means 1,048,576 cycles.\n\nDefault when omitted: 20. Values below 18 are rejected for the CLI because tiny epochs are dominated by fixed overhead. Indicative ethrex 10-transfer distinct-account peak heap from a local sweep: 19 ~= 6.9 GB, 20 ~= 9.5 GB, 21 ~= 15.8 GB, 22 ~= 26.8 GB. Higher values reduce epoch count, continuation bundle size, and fixed per-epoch overhead, but increase peak memory. For a new workload, try the highest value your machine can run without swapping." )] epoch_size_log2: Option, + + /// Path to a hint arena file (concatenated 32-byte slots) appended to + /// the private-input region; produce one with `execute --record-hints`. + #[arg(long, value_hint = ValueHint::FilePath)] + hints: Option, }, /// Verify a proof bundle @@ -227,6 +249,11 @@ enum Commands { /// Path to the private input file #[arg(long, value_hint = ValueHint::FilePath)] private_input: Option, + + /// Path to a hint arena file (concatenated 32-byte slots) appended to + /// the private-input region; produce one with `execute --record-hints`. + #[arg(long, value_hint = ValueHint::FilePath)] + hints: Option, }, } @@ -243,6 +270,8 @@ fn main() -> ExitCode { flamegraph_checkpoint_cycles, cycle_budget, cycles, + hints, + record_hints, } => cmd_execute( elf, private_input, @@ -253,6 +282,8 @@ fn main() -> ExitCode { }, cycle_budget, cycles, + hints, + record_hints, ), Commands::Prove { elf, @@ -264,6 +295,7 @@ fn main() -> ExitCode { elements, continuations, epoch_size_log2, + hints, } => { if continuations { cmd_prove_continuation( @@ -274,9 +306,10 @@ fn main() -> ExitCode { blowup, time, cycles, + hints, ) } else { - cmd_prove(elf, output, private_input, blowup, time, cycles, elements) + cmd_prove(elf, output, private_input, blowup, time, cycles, elements, hints) } } Commands::Verify { @@ -292,7 +325,11 @@ fn main() -> ExitCode { cmd_verify(proof, elf, blowup, time) } } - Commands::CountElements { elf, private_input } => cmd_count_elements(elf, private_input), + Commands::CountElements { + elf, + private_input, + hints, + } => cmd_count_elements(elf, private_input, hints), } } @@ -306,10 +343,37 @@ fn read_private_input(path: Option<&PathBuf>) -> Result, String> { } } -fn count_cycles(elf_data: &[u8], private_inputs: &[u8]) -> Result { +/// Read a hint arena file: concatenated 32-byte slots, in the guest's request +/// order (see `executor::vm::memory::encode_private_input_region`). +fn read_hints(path: Option<&PathBuf>) -> Result, String> { + match path { + Some(path) => { + eprintln!("Reading hint arena file..."); + let bytes = + std::fs::read(path).map_err(|e| format!("Failed to read hint arena file: {e}"))?; + if bytes.len() % 32 != 0 { + return Err(format!( + "Hint arena file must be whole 32-byte slots, got {} bytes", + bytes.len() + )); + } + Ok(bytes + .chunks_exact(32) + .map(|slot| slot.try_into().expect("32-byte chunk")) + .collect()) + } + None => Ok(vec![]), + } +} + +fn count_cycles( + elf_data: &[u8], + private_inputs: &[u8], + hints: &[[u8; 32]], +) -> Result { let program = Elf::load(elf_data).map_err(|e| format!("Failed to load ELF for cycle count: {e:?}"))?; - let executor = Executor::new(&program, private_inputs.to_vec()) + let executor = Executor::new(&program, private_inputs.to_vec(), hints) .map_err(|e| format!("Failed to create executor for cycle count: {e:?}"))?; executor .run() @@ -383,6 +447,8 @@ fn cmd_execute( flamegraph: FlamegraphCliOptions, cycle_budget: Option, cycles: bool, + hints_path: Option, + record_hints_path: Option, ) -> ExitCode { let elf_data = match std::fs::read(&elf_path) { Ok(data) => data, @@ -408,6 +474,36 @@ fn cmd_execute( } }; + // Recording pass of the two-pass hint flow: run hint-free, answer the + // guest's logged requests host-side, and write the arena for --hints runs. + if let Some(record_path) = record_hints_path { + let hints = match executor::vm::execution::collect_hints(&program, private_inputs) { + Ok(hints) => hints, + Err(e) => { + eprintln!("Hint recording run failed: {e:?}"); + return ExitCode::FAILURE; + } + }; + let mut bytes = Vec::with_capacity(32 * hints.len()); + for slot in &hints { + bytes.extend_from_slice(slot); + } + if let Err(e) = std::fs::write(&record_path, &bytes) { + eprintln!("Failed to write hint arena file: {e}"); + return ExitCode::FAILURE; + } + eprintln!("Recorded {} hint slots to {:?}", hints.len(), record_path); + return ExitCode::SUCCESS; + } + + let hints = match read_hints(hints_path.as_ref()) { + Ok(hints) => hints, + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + }; + // Accelerator invocation counts, tallied only in the plain streaming path // below (the flamegraph path drives execution inside the executor and does // not expose per-log data). `None` means "not counted", so the accel lines @@ -469,7 +565,7 @@ fn cmd_execute( total_cycles } else { - let mut executor = match Executor::new(&program, private_inputs) { + let mut executor = match Executor::new(&program, private_inputs, &hints) { Ok(e) => e, Err(e) => { eprintln!("Failed to create executor: {:?}", e); @@ -550,6 +646,7 @@ fn cmd_prove( time: bool, cycles: bool, elements: bool, + hints_path: Option, ) -> ExitCode { eprintln!("Reading ELF file..."); let elf_data = match std::fs::read(&elf_path) { @@ -568,11 +665,19 @@ fn cmd_prove( } }; + let hints = match read_hints(hints_path.as_ref()) { + Ok(hints) => hints, + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + }; + // Pre-pass: execute once outside the timer to count dynamic instructions. // Mirrors SP1's cycle-count pass so both provers report the same kind of // number without inflating the measured proving time. let cycle_count = if cycles { - match count_cycles(&elf_data, &private_inputs) { + match count_cycles(&elf_data, &private_inputs, &hints) { Ok(count) => Some(count), Err(e) => { eprintln!("{e}"); @@ -585,7 +690,7 @@ fn cmd_prove( // Pre-pass: build traces and count field elements without running the proof. let element_count = if elements { - match prover::count_elements(&elf_data, &private_inputs) { + match prover::count_elements(&elf_data, &private_inputs, &hints) { Ok(counts) => Some(counts), Err(e) => { eprintln!("Failed to count elements: {:?}", e); @@ -620,6 +725,7 @@ fn cmd_prove( let proof = prover::prove_with_options_and_inputs( &elf_data, &private_inputs, + &hints, &opts, &Default::default(), ); @@ -740,6 +846,7 @@ fn cmd_prove_continuation( blowup: u8, time: bool, cycles: bool, + hints_path: Option, ) -> ExitCode { eprintln!("Reading ELF file..."); let elf_data = match std::fs::read(&elf_path) { @@ -758,8 +865,16 @@ fn cmd_prove_continuation( } }; + let hints = match read_hints(hints_path.as_ref()) { + Ok(hints) => hints, + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + }; + let cycle_count = if cycles { - match count_cycles(&elf_data, &private_inputs) { + match count_cycles(&elf_data, &private_inputs, &hints) { Ok(count) => Some(count), Err(e) => { eprintln!("{e}"); @@ -800,6 +915,7 @@ fn cmd_prove_continuation( let bundle = match prover::continuation::prove_continuation( &elf_data, &private_inputs, + &hints, epoch_size_log2, &opts, ) { @@ -916,7 +1032,11 @@ fn cmd_verify_continuation( } } -fn cmd_count_elements(elf_path: PathBuf, private_input_path: Option) -> ExitCode { +fn cmd_count_elements( + elf_path: PathBuf, + private_input_path: Option, + hints_path: Option, +) -> ExitCode { let elf_data = match std::fs::read(&elf_path) { Ok(data) => data, Err(e) => { @@ -933,7 +1053,15 @@ fn cmd_count_elements(elf_path: PathBuf, private_input_path: Option) -> } }; - match prover::count_elements(&elf_data, &private_inputs) { + let hints = match read_hints(hints_path.as_ref()) { + Ok(hints) => hints, + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + }; + + match prover::count_elements(&elf_data, &private_inputs, &hints) { Ok((main, aux)) => { println!("Elements: {}", main); println!("Aux elements (EF-cols): {}", aux); diff --git a/crypto/ethrex-crypto/src/lib.rs b/crypto/ethrex-crypto/src/lib.rs index ec36b0831..4a3206b82 100644 --- a/crypto/ethrex-crypto/src/lib.rs +++ b/crypto/ethrex-crypto/src/lib.rs @@ -64,38 +64,36 @@ impl Crypto for LambdaVmEcsmCrypto { // ── ECDSA secp256k1 recovery via the ECSM precompile ──────────────────────── -/// Obtain a 32-byte big-endian hint for `x_be` via the executor `hint` ecall -/// (the host computes the modular inverse / sqrt; the value is provable via the -/// prover's HINT table). The result is UNTRUSTED — the ecall adds no correctness -/// constraint, so every caller MUST verify it in-guest (`x·inv == 1`, `y² == x³+7`) -/// AND recompute in software on any verification failure. The hint is only ever -/// allowed to save work, never to change the answer: because the prover chooses the -/// bytes, an unverified-or-rejected-outright hint would let it steer a caller's -/// accept/reject outcome (e.g. force a valid signature to look invalid). See -/// [`scalar_inv`] / [`decompress_r`] for the fallback that closes that hole. +/// Fetch a hint for `(hint_id, x_be)` from the private-input hint arena +/// (positional — one slot per request). On an exhausted arena the request is +/// appended to the guest's hint request log (the recording pass of the +/// two-pass hint flow) and zeros are returned, which fail the caller's +/// in-guest verify and trigger its software fallback. +/// +/// The hint is UNTRUSTED — the prover chooses the arena bytes, so every caller +/// MUST verify it in-guest (`x·inv == 1`, `y² == x³+7`) AND recompute in +/// software on any verification failure. The hint is only ever allowed to save +/// work, never to change the answer: an unverified-or-rejected-outright hint +/// would let the prover steer a caller's accept/reject outcome (e.g. force a +/// valid signature to look invalid). See [`scalar_inv`] / [`decompress_r`] for +/// the fallback that closes that hole. #[cfg(target_arch = "riscv64")] fn get_hint(hint_id: usize, x_be: &[u8; 32]) -> [u8; 32] { - // 8-byte-aligned output buffer so the HINT table's four 8-byte writes land on the - // aligned memory path (MEMW_A) instead of the general MEMW path. An `[u8; 32]` on - // the stack is only 1-aligned, which forces the four writes onto the unaligned - // path and inflates the trace. - #[repr(C, align(8))] - struct Aligned32([u8; 32]); - let mut out = Aligned32([0u8; 32]); - lambda_vm_syscalls::syscalls::hint(hint_id, &mut out.0, x_be); - out.0 + lambda_vm_syscalls::syscalls::request_hint(hint_id, x_be).unwrap_or([0u8; 32]) } /// Scalar-field inverse `x⁻¹ mod n`. /// -/// On riscv64 the inverse is first requested from the untrusted `hint` ecall and -/// verified in-guest (`x·inv == 1`); **on any verification failure it is recomputed -/// in software.** `x⁻¹` exists for every `x` this is called with — the only caller, -/// `ecsm_ecrecover`, guarantees `r ≠ 0` before calling — so a failed verify can only -/// mean the host lied, and the software value is authoritative. This is what keeps -/// the result independent of the prover-chosen hint: a bad hint makes the guest do -/// more work, it can never change the answer, so it cannot turn a valid signature -/// into a recovery failure. Off-target (host) it inverts in software directly. +/// On riscv64 the inverse is read from the untrusted private-input **hint +/// arena** (positional — one slot per request) and verified in-guest +/// (`x·inv == 1`); **on any verification failure or an exhausted arena it is +/// recomputed in software.** `x⁻¹` exists for every `x` this is called with — +/// the only caller, `ecsm_ecrecover`, guarantees `r ≠ 0` before calling — so a +/// failed verify can only mean the host lied, and the software value is +/// authoritative. This is what keeps the result independent of the +/// prover-chosen hint: a bad hint makes the guest do more work, it can never +/// change the answer, so it cannot turn a valid signature into a recovery +/// failure. Off-target (host) it inverts in software directly. fn scalar_inv(x: &Scalar) -> Option { #[cfg(target_arch = "riscv64")] { @@ -133,16 +131,16 @@ where /// Decompress R from its x-coordinate + parity. /// -/// On riscv64 the square root `y = sqrt(x³+7)` is first requested from the untrusted -/// `hint` ecall and verified in-guest (`y² == x³+7`), with parity selection; **on any -/// verification failure the point is recomputed with the software -/// `AffinePoint::decompress`.** Unlike the inverse, a failure here is *not* -/// necessarily a lying host: a genuine non-residue (an invalid signature) has no -/// root and must legitimately yield `None`. So the fallback is the authoritative -/// software decompress, which returns `Some` for a residue and `None` for a -/// non-residue regardless of the prover-chosen hint — the hint can only save work, -/// never steer the accept/reject outcome. Off-target it uses the software -/// decompress directly. +/// On riscv64 the square root `y = sqrt(x³+7)` is read from the untrusted +/// private-input **hint arena** and verified in-guest (`y² == x³+7`), with +/// parity selection; **on any verification failure or an exhausted arena the +/// point is recomputed with the software `AffinePoint::decompress`.** Unlike +/// the inverse, a failure here is *not* necessarily a lying host: a genuine +/// non-residue (an invalid signature) has no root and must legitimately yield +/// `None`. So the fallback is the authoritative software decompress, which +/// returns `Some` for a residue and `None` for a non-residue regardless of the +/// prover-chosen hint — the hint can only save work, never steer the +/// accept/reject outcome. Off-target it uses the software decompress directly. fn decompress_r(r_bytes: &FieldBytes, y_is_odd: bool) -> Option { #[cfg(target_arch = "riscv64")] { @@ -337,12 +335,14 @@ fn ecsm_oracle(x: &FieldElement, k: &Scalar) -> Option { /// Base-field inverse `x⁻¹ mod p`. /// -/// On riscv64 the inverse is first requested from the untrusted `hint` ecall and -/// verified in-guest (`x·inv == 1`); **on any verification failure it is recomputed -/// in software.** A bad hint can only cost the guest extra work, never change the -/// answer — it cannot steer a caller's accept/reject outcome. Off-target it inverts -/// in software directly. Returns `None` only for a genuinely non-invertible input -/// (`x = 0`), which the callers' degeneracy guards already exclude. +/// On riscv64 the inverse is read from the untrusted private-input **hint +/// arena** (positional — one slot per request) and verified in-guest +/// (`x·inv == 1`); **on any verification failure or an exhausted arena it is +/// recomputed in software.** A bad or missing hint can only cost the guest +/// extra work, never change the answer — it cannot steer a caller's +/// accept/reject outcome. Off-target it inverts in software directly. Returns +/// `None` only for a genuinely non-invertible input (`x = 0`), which the +/// callers' degeneracy guards already exclude. #[cfg(any(target_arch = "riscv64", test))] fn field_inv(x: &FieldElement) -> Option { #[cfg(target_arch = "riscv64")] diff --git a/crypto/ethrex-crypto/src/tests/hint_tests.rs b/crypto/ethrex-crypto/src/tests/hint_tests.rs index ace59f208..6bfb413e5 100644 --- a/crypto/ethrex-crypto/src/tests/hint_tests.rs +++ b/crypto/ethrex-crypto/src/tests/hint_tests.rs @@ -1,16 +1,16 @@ //! Host tests for the untrusted-hint verify-then-fallback paths (`scalar_inv`, //! `field_inv`, `decompress_r`). //! -//! The guest asks the (untrusted, prover-chosen) `hint` ecall for a modular -//! inverse / square root, then verifies it in-circuit. These tests inject the -//! oracle directly — an *honest* oracle (matching the executor's `compute_hint`) -//! and a *lying* one — and assert the software fallback makes the result identical -//! either way. That is the property the whole hint design rests on: because the -//! prover chooses the hinted bytes and the ecall adds no correctness constraint, a -//! bad hint must only be able to make the guest do more work, never change its -//! accept/reject outcome. On the guest this code is `cfg(target_arch = "riscv64")`; -//! the `test` gate on `*_with_oracle` is what lets CI compile and exercise it on -//! the host. +//! The guest reads each modular inverse / square root from the untrusted, +//! prover-chosen private-input **hint arena**, then verifies it in-circuit. +//! These tests inject the oracle directly — an *honest* oracle (matching the +//! executor's `compute_hint`) and a *lying* one — and assert the software +//! fallback makes the result identical either way. That is the property the +//! whole hint design rests on: because the prover chooses the arena bytes and +//! they are unconstrained, a bad hint must only be able to make the guest do +//! more work, never change its accept/reject outcome. On the guest this code +//! is `cfg(target_arch = "riscv64")`; the `test` gate on `*_with_oracle` is +//! what lets CI compile and exercise it on the host. use crate::*; diff --git a/executor/programs/rust/hint_min/.cargo/config.toml b/executor/programs/rust/ecrecover_hints/.cargo/config.toml similarity index 100% rename from executor/programs/rust/hint_min/.cargo/config.toml rename to executor/programs/rust/ecrecover_hints/.cargo/config.toml diff --git a/executor/programs/rust/ecrecover_hints/Cargo.lock b/executor/programs/rust/ecrecover_hints/Cargo.lock new file mode 100644 index 000000000..56d252d8c --- /dev/null +++ b/executor/programs/rust/ecrecover_hints/Cargo.lock @@ -0,0 +1,1011 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ark-bn254" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d69eab57e8d2663efa5c63135b2af4f396d66424f88954c21104125ab6b3e6bc" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-std", +] + +[[package]] +name = "ark-ec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" +dependencies = [ + "ahash", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "educe", + "fnv", + "hashbrown", + "itertools", + "num-bigint", + "num-integer", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "arrayvec", + "digest", + "educe", + "itertools", + "num-bigint", + "num-traits", + "paste", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-poly" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27" +dependencies = [ + "ahash", + "ark-ff", + "ark-serialize", + "ark-std", + "educe", + "fnv", + "hashbrown", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "arrayvec", + "digest", + "num-bigint", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" +dependencies = [ + "num-traits", + "rand 0.8.7", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bls12_381" +version = "0.8.0" +source = "git+https://github.com/lambdaclass/bls12_381?branch=expose-affine-constructors#78cad0378b17fc3157b83f514be192bf46edf9a1" +dependencies = [ + "digest", + "ff", + "group", + "pairing", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", +] + +[[package]] +name = "ecrecover_hints" +version = "0.1.0" +dependencies = [ + "ethrex-crypto", + "lambda-vm-ethrex-crypto", + "lambda-vm-syscalls", +] + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "enum-ordinalize" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "ethereum-types" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ab15ed80916029f878e0267c3a9f92b67df55e79af370bf66199059ae2b4ee3" +dependencies = [ + "fixed-hash", + "primitive-types", + "uint", +] + +[[package]] +name = "ethrex-crypto" +version = "13.0.0" +source = "git+https://github.com/lambdaclass/ethrex.git?rev=156cb8d6a3974f411d71622eecd1b249ee37ff1c#156cb8d6a3974f411d71622eecd1b249ee37ff1c" +dependencies = [ + "ark-bn254", + "ark-ec", + "ark-ff", + "bls12_381", + "ethereum-types", + "ff", + "hex-literal", + "k256", + "num-bigint", + "p256", + "ripemd", + "sha2", + "thiserror 2.0.20", + "tiny-keccak", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "bitvec", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fixed-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "byteorder", + "rustc-hex", + "static_assertions", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "sha2", +] + +[[package]] +name = "lambda-vm-ethrex-crypto" +version = "0.1.0" +dependencies = [ + "ethrex-crypto", + "k256", + "lambda-vm-syscalls", +] + +[[package]] +name = "lambda-vm-syscalls" +version = "0.1.0" +dependencies = [ + "getrandom 0.2.17", + "getrandom 0.3.4", + "lazy_static", + "rand 0.9.5", + "riscv", + "thiserror 1.0.69", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "pairing" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fec4625e73cf41ef4bb6846cafa6d44736525f442ba45e407c4a000a13996f" +dependencies = [ + "group", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "primitive-types" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d15600a7d856470b7d278b3fe0e311fe28c2526348549f8ef2ff7db3299c87f5" +dependencies = [ + "fixed-hash", + "uint", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest", +] + +[[package]] +name = "riscv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" +dependencies = [ + "critical-section", + "embedded-hal", + "paste", + "riscv-macros", + "riscv-pac", +] + +[[package]] +name = "riscv-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "riscv-pac" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" + +[[package]] +name = "rustc-hex" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uint" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f9227a75a5a540a464c832ad4a4195dbdbecd8787610a56262721fde6f04f90" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/executor/programs/rust/ecrecover_hints/Cargo.toml b/executor/programs/rust/ecrecover_hints/Cargo.toml new file mode 100644 index 000000000..c4980d5cf --- /dev/null +++ b/executor/programs/rust/ecrecover_hints/Cargo.toml @@ -0,0 +1,16 @@ +[workspace] + +[profile.release] +lto = "thin" + +[package] +name = "ecrecover_hints" +version = "0.1.0" +edition = "2024" + +[dependencies] +lambda-vm-syscalls = { path = "../../../../syscalls" } +lambda-vm-ethrex-crypto = { path = "../../../../crypto/ethrex-crypto" } +# The `Crypto` trait the guest calls through. Same rev + default-features=false +# as lambda-vm-ethrex-crypto's own dep, so feature unification adds nothing. +ethrex-crypto = { git = "https://github.com/lambdaclass/ethrex.git", rev = "156cb8d6a3974f411d71622eecd1b249ee37ff1c", package = "ethrex-crypto", default-features = false } diff --git a/executor/programs/rust/ecrecover_hints/src/main.rs b/executor/programs/rust/ecrecover_hints/src/main.rs new file mode 100644 index 000000000..2dc016044 --- /dev/null +++ b/executor/programs/rust/ecrecover_hints/src/main.rs @@ -0,0 +1,38 @@ +//! ecrecover measurement guest for the hint arena: N secp256k1 recoveries via +//! the LambdaVM crypto provider, whose inverses/sqrts come from the +//! private-input hint arena (no hint ecall). +//! +//! Private input layout: `[u32 LE count]` then `count` records of +//! `sig(64) || recid(1) || msg(32)`. The recovered addresses are XOR-folded and +//! committed. Hint consumption is positional: per recovery the guest requests +//! sqrt (decompress), the batched field inverse (lincomb), and the scalar +//! inverse, in that order — the host's arena must follow the same order. + +use ethrex_crypto::Crypto; +use lambda_vm_ethrex_crypto::LambdaVmEcsmCrypto; +use lambda_vm_syscalls as syscalls; + +pub fn main() { + let input = syscalls::syscalls::get_private_input(); + assert!(input.len() >= 4, "input too short for count"); + let count = u32::from_le_bytes(input[0..4].try_into().unwrap()) as usize; + assert_eq!(input.len(), 4 + count * 97, "input length mismatch"); + + let crypto = LambdaVmEcsmCrypto; + let mut acc = [0u8; 32]; + let mut off = 4; + for _ in 0..count { + let sig: &[u8; 64] = input[off..off + 64].try_into().unwrap(); + let recid = input[off + 64]; + let msg: &[u8; 32] = input[off + 65..off + 97].try_into().unwrap(); + let addr = crypto + .secp256k1_ecrecover(sig, recid, msg) + .expect("ecrecover failed"); + for i in 0..32 { + acc[i] ^= addr[i]; + } + off += 97; + } + + syscalls::syscalls::commit(&acc); +} diff --git a/executor/programs/rust/hint_multi/.cargo/config.toml b/executor/programs/rust/hint_arena/.cargo/config.toml similarity index 100% rename from executor/programs/rust/hint_multi/.cargo/config.toml rename to executor/programs/rust/hint_arena/.cargo/config.toml diff --git a/executor/programs/rust/hint_min/Cargo.lock b/executor/programs/rust/hint_arena/Cargo.lock similarity index 72% rename from executor/programs/rust/hint_min/Cargo.lock rename to executor/programs/rust/hint_arena/Cargo.lock index cc02eff98..3b6eb2fd1 100644 --- a/executor/programs/rust/hint_min/Cargo.lock +++ b/executor/programs/rust/hint_arena/Cargo.lock @@ -2,42 +2,18 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "const-default" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" - [[package]] name = "critical-section" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" -[[package]] -name = "embedded-alloc" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f2de9133f68db0d4627ad69db767726c99ff8585272716708227008d3f1bddd" -dependencies = [ - "const-default", - "critical-section", - "linked_list_allocator", - "rlsf", -] - [[package]] name = "embedded-hal" version = "1.0.0" @@ -68,7 +44,7 @@ dependencies = [ ] [[package]] -name = "hint_min" +name = "hint_arena" version = "0.1.0" dependencies = [ "lambda-vm-syscalls", @@ -78,7 +54,6 @@ dependencies = [ name = "lambda-vm-syscalls" version = "0.1.0" dependencies = [ - "embedded-alloc", "getrandom 0.2.17", "getrandom 0.3.4", "lazy_static", @@ -99,12 +74,6 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" -[[package]] -name = "linked_list_allocator" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" - [[package]] name = "paste" version = "1.0.15" @@ -194,7 +163,7 @@ checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -203,49 +172,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" -[[package]] -name = "rlsf" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1646a59a9734b8b7a0ac51689388a60fe1625d4b956348e9de07591a1478457a" -dependencies = [ - "cfg-if", - "const-default", - "libc", - "rustversion", - "svgbobdoc", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "svgbobdoc" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2c04b93fc15d79b39c63218f15e3fdffaa4c227830686e3b7c5f41244eb3e50" -dependencies = [ - "base64", - "proc-macro2", - "quote", - "syn 1.0.109", - "unicode-width", -] - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.119" @@ -274,7 +200,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -283,12 +209,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -312,20 +232,20 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "zerocopy" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] diff --git a/executor/programs/rust/hint_multi/Cargo.toml b/executor/programs/rust/hint_arena/Cargo.toml similarity index 86% rename from executor/programs/rust/hint_multi/Cargo.toml rename to executor/programs/rust/hint_arena/Cargo.toml index faacdb38e..3d361e1b9 100644 --- a/executor/programs/rust/hint_multi/Cargo.toml +++ b/executor/programs/rust/hint_arena/Cargo.toml @@ -1,7 +1,7 @@ [workspace] [package] -name = "hint_multi" +name = "hint_arena" version = "0.1.0" edition = "2024" diff --git a/executor/programs/rust/hint_arena/src/main.rs b/executor/programs/rust/hint_arena/src/main.rs new file mode 100644 index 000000000..8583f9cb2 --- /dev/null +++ b/executor/programs/rust/hint_arena/src/main.rs @@ -0,0 +1,35 @@ +//! Hint-arena guest: reads THREE 32-byte hints from the private-input hint +//! arena (no ecall — ordinary aligned loads from the memory-mapped region), +//! XOR-accumulates them, and commits the accumulator. +//! +//! Arena counterpart to `hint_multi`: same three logical hints (one per +//! selector's worth of values), but the bytes arrive as prover-chosen +//! private-input page data instead of executor-written ecall outputs, so the +//! proof needs no HINT table rows at all — the guest's reads are ordinary +//! MEMR loads chained to the private-input pages. + +use lambda_vm_syscalls as syscalls; + +pub fn main() { + let mut acc = [0u8; 32]; + + assert_eq!( + syscalls::syscalls::hint_count(), + 3, + "the host must supply exactly three hint slots" + ); + + for _ in 0..3 { + let hint = syscalls::syscalls::next_hint().expect("arena exhausted"); + for i in 0..32 { + acc[i] ^= hint[i]; + } + } + + // Positional consumption is one slot per request: a fourth request runs + // past the end and must yield None (the caller's cue to fall back to + // software), never a desynced stream. + assert!(syscalls::syscalls::next_hint().is_none()); + + syscalls::syscalls::commit(&acc); +} diff --git a/executor/programs/rust/hint_min/Cargo.toml b/executor/programs/rust/hint_min/Cargo.toml deleted file mode 100644 index 4bfe4614f..000000000 --- a/executor/programs/rust/hint_min/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[workspace] - -[package] -name = "hint_min" -version = "0.1.0" -edition = "2024" - -[dependencies] -lambda-vm-syscalls = { path = "../../../../syscalls" } diff --git a/executor/programs/rust/hint_min/src/main.rs b/executor/programs/rust/hint_min/src/main.rs deleted file mode 100644 index 833a01b8a..000000000 --- a/executor/programs/rust/hint_min/src/main.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! Minimal P0 guest for the Hint prover table: one `hint` ecall (field inverse of -//! a small value) + commit the result. No in-guest verify — this exercises exactly -//! the Hint table's bus surface (Ecall receive, the register read binding `out_addr` -//! to `a2`, four 8-byte MEMW writes and the output range checks; the input read is -//! deliberately not modelled) so we can get prove→verify to balance before scaling -//! to ethrex. -//! -//! Buffers are 8-byte aligned so the writes land in the aligned MEMW table — the same -//! choice the ethrex call site makes (`get_hint` in `crypto/ethrex-crypto` wraps its -//! output in an `align(8)` buffer). Alignment is a preference rather than a -//! requirement — `classify_memw` routes unaligned accesses to the general MEMW table. - -use lambda_vm_syscalls as syscalls; - -#[repr(align(8))] -struct Aligned32([u8; 32]); - -pub fn main() { - // input = 3 (big-endian), a valid invertible field element. - let mut x = Aligned32([0u8; 32]); - x.0[31] = 3; - let mut inv = Aligned32([0u8; 32]); - - syscalls::syscalls::hint( - syscalls::syscalls::HINT_FIELD_INV, - &mut inv.0, - &x.0, - ); - - syscalls::syscalls::commit(&inv.0); -} diff --git a/executor/programs/rust/hint_multi/Cargo.lock b/executor/programs/rust/hint_multi/Cargo.lock deleted file mode 100644 index 9803c875a..000000000 --- a/executor/programs/rust/hint_multi/Cargo.lock +++ /dev/null @@ -1,331 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "const-default" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" - -[[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - -[[package]] -name = "embedded-alloc" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f2de9133f68db0d4627ad69db767726c99ff8585272716708227008d3f1bddd" -dependencies = [ - "const-default", - "critical-section", - "linked_list_allocator", - "rlsf", -] - -[[package]] -name = "embedded-hal" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - -[[package]] -name = "hint_multi" -version = "0.1.0" -dependencies = [ - "lambda-vm-syscalls", -] - -[[package]] -name = "lambda-vm-syscalls" -version = "0.1.0" -dependencies = [ - "embedded-alloc", - "getrandom 0.2.17", - "getrandom 0.3.4", - "lazy_static", - "rand", - "riscv", - "thiserror", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "linked_list_allocator" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "riscv" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" -dependencies = [ - "critical-section", - "embedded-hal", - "paste", - "riscv-macros", - "riscv-pac", -] - -[[package]] -name = "riscv-macros" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "riscv-pac" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" - -[[package]] -name = "rlsf" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1646a59a9734b8b7a0ac51689388a60fe1625d4b956348e9de07591a1478457a" -dependencies = [ - "cfg-if", - "const-default", - "libc", - "rustversion", - "svgbobdoc", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "svgbobdoc" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2c04b93fc15d79b39c63218f15e3fdffaa4c227830686e3b7c5f41244eb3e50" -dependencies = [ - "base64", - "proc-macro2", - "quote", - "syn 1.0.109", - "unicode-width", -] - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "zerocopy" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] diff --git a/executor/programs/rust/hint_multi/src/main.rs b/executor/programs/rust/hint_multi/src/main.rs deleted file mode 100644 index 2a03a644d..000000000 --- a/executor/programs/rust/hint_multi/src/main.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! Multi-hint P0/P2 guest for the Hint prover table: THREE `hint` ecalls, one per -//! selector, each result read back with ordinary `LOAD`s (XOR-accumulated) and the -//! accumulator committed. -//! -//! Complements `hint_min` (one hint, read back via `commit`): this exercises the -//! parts the ethrex consumer relies on that a single-call guest does not — -//! **multiple real HINT rows** (padded to a power of two), **all three selectors** -//! (`HINT_FIELD_INV` / `HINT_SCALAR_INV` / `HINT_FIELD_SQRT`, so the AIR's -//! `selector < 3` range-check is exercised at every accepted value rather than only -//! at 0) and **read-back of the hinted output via normal `LOAD` instructions** -//! (whose MEMW reads must chain to the HINT table's writes). Buffers are 8-byte -//! aligned so the writes land in the aligned MEMW table. - -use lambda_vm_syscalls as syscalls; - -#[repr(align(8))] -struct Aligned32([u8; 32]); - -pub fn main() { - let mut acc = Aligned32([0u8; 32]); - - // One call per selector. 4 is a quadratic residue mod p, so the sqrt hint has a - // real root rather than the zeros `compute_hint` returns on a numeric failure. - for (hint_id, seed) in [ - (syscalls::syscalls::HINT_FIELD_INV, 3u8), - (syscalls::syscalls::HINT_SCALAR_INV, 5u8), - (syscalls::syscalls::HINT_FIELD_SQRT, 4u8), - ] { - let mut x = Aligned32([0u8; 32]); - x.0[31] = seed; - let mut out = Aligned32([0u8; 32]); - - syscalls::syscalls::hint(hint_id, &mut out.0, &x.0); - - // Read the hinted output back via ordinary loads and fold it in, so the - // MEMW reads of `out` must chain to the HINT table's writes. - for i in 0..32 { - acc.0[i] ^= out.0[i]; - } - } - - syscalls::syscalls::commit(&acc.0); -} diff --git a/executor/src/flamegraph.rs b/executor/src/flamegraph.rs index 2abf14942..11e5d5b38 100644 --- a/executor/src/flamegraph.rs +++ b/executor/src/flamegraph.rs @@ -353,7 +353,9 @@ pub fn run_with_flamegraph( ) -> (FlamegraphGenerator, Result) { let symbols = SymbolTable::parse(elf_bytes); let mut generator = FlamegraphGenerator::new(symbols, program.entry_point); - let mut executor = match Executor::new(program, private_inputs) { + // Profiling entry point: no hint arena (pass `&[]`); hint-consuming programs + // fall back to software paths here, which only makes profiles conservative. + let mut executor = match Executor::new(program, private_inputs, &[]) { Ok(executor) => executor, Err(e) => return (generator, Err(e.into())), }; diff --git a/executor/src/main.rs b/executor/src/main.rs index 283085fd1..dd69715df 100644 --- a/executor/src/main.rs +++ b/executor/src/main.rs @@ -9,7 +9,7 @@ fn main() -> Result<(), ExecutorError> { let elf_data = std::fs::read("./program_artifacts/rust/ethrex.elf").unwrap(); let inputs = fs::read("tests/ethrex_simple_tx.bin").unwrap(); let program = Elf::load(&elf_data).unwrap(); - let executor = Executor::new(&program, inputs)?; + let executor = Executor::new(&program, inputs, &[])?; executor.run()?; Ok(()) } diff --git a/executor/src/tests/hint_tests.rs b/executor/src/tests/hint_tests.rs deleted file mode 100644 index 2ed8c096c..000000000 --- a/executor/src/tests/hint_tests.rs +++ /dev/null @@ -1,196 +0,0 @@ -//! Tests for the non-constraining `Hint` syscall. - -use crate::vm::instruction::decoding::Instruction; -use crate::vm::instruction::execution::{ - ExecutionError, HINT_FIELD_INV, HINT_FIELD_SQRT, HINT_SCALAR_INV, HINT_SYSCALL_NUMBER, - compute_hint, -}; -use crate::vm::memory::Memory; -use crate::vm::registers::Registers; - -fn write_u256(memory: &mut Memory, addr: u64, bytes: &[u8; 32]) { - for i in 0..4 { - let mut dw = [0u8; 8]; - dw.copy_from_slice(&bytes[i * 8..i * 8 + 8]); - memory - .store_doubleword(addr + (i as u64) * 8, u64::from_le_bytes(dw)) - .unwrap(); - } -} - -fn read_u256(memory: &Memory, addr: u64) -> [u8; 32] { - let mut out = [0u8; 32]; - for i in 0..4 { - let dw = memory.load_doubleword(addr + (i as u64) * 8).unwrap(); - out[i * 8..i * 8 + 8].copy_from_slice(&dw.to_le_bytes()); - } - out -} - -/// Runs one `Hint` ecall with the given operand addresses, returning the 32 bytes -/// written at `out_addr`. -fn run_hint_at( - hint_id: u64, - in_addr: u64, - out_addr: u64, - input: &[u8; 32], -) -> Result<[u8; 32], ExecutionError> { - let mut memory = Memory::default(); - let mut registers = Registers::default(); - let mut pc = 0u64; - - write_u256(&mut memory, in_addr, input); - registers.write(17, HINT_SYSCALL_NUMBER).unwrap(); - registers.write(10, hint_id).unwrap(); - registers.write(11, in_addr).unwrap(); - registers.write(12, out_addr).unwrap(); - Instruction::EcallEbreak.run(&mut pc, &mut registers, &mut memory)?; - Ok(read_u256(&memory, out_addr)) -} - -/// The base-field inverse hint round-trips through guest memory, big-endian in and -/// out, and matches `compute_hint` (the value the prover recomputes). -#[test] -fn hint_syscall_writes_the_field_inverse() { - let mut input = [0u8; 32]; - input[31] = 3; // 3, big-endian - - let out = run_hint_at(HINT_FIELD_INV, 0x1000, 0x2000, &input).expect("hint must run"); - assert_eq!(out, compute_hint(HINT_FIELD_INV, &input)); - - // 3 · 3⁻¹ ≡ 1 (mod p) — the same check the guest performs on the untrusted value. - let three: k256::FieldElement = - Option::from(k256::FieldElement::from_bytes(&input.into())).unwrap(); - let inv: k256::FieldElement = - Option::from(k256::FieldElement::from_bytes(&out.into())).unwrap(); - assert_eq!( - (three * inv).to_bytes(), - k256::FieldElement::ONE.to_bytes(), - "hinted inverse must satisfy x·inv == 1" - ); -} - -/// Both operands must keep their 32-byte range inside the lower address limb: the -/// HINT table sends the output writes as `[out_addr_lo + 8i, out_addr_hi]`, which -/// cannot represent a carry into the high limb, so a straddling operand would make -/// the trace unprovable. The executor rejects it upfront instead. -#[test] -fn hint_syscall_rejects_address_overflow() { - let input = [0u8; 32]; - // Last accessed byte is at +31, so the first rejected base is 2^32 - 31. - for (in_addr, out_addr) in [ - (0x1000, 0xFFFF_FFE8), - (0xFFFF_FFE8, 0x2000), - (0x1000, 0xFFFF_FFE1), - (0xFFFF_FFE1, 0x2000), - (0x1000, 0xFFFF_FFFF), - ] { - let err = run_hint_at(HINT_FIELD_INV, in_addr, out_addr, &input) - .expect_err("straddling operand must be rejected"); - assert!( - matches!(err, ExecutionError::HintAddressOverflow), - "expected address overflow for in={in_addr:#x}, out={out_addr:#x}, got {err:?}" - ); - } -} - -/// The boundary case: an operand ending exactly on the last byte of the limb is -/// still representable and must be accepted. -#[test] -fn hint_syscall_accepts_operand_ending_at_the_limb_boundary() { - let input = [0u8; 32]; - // 2^32 - 32: last byte lands at 2^32 - 1, the largest in-limb address. - run_hint_at(HINT_FIELD_INV, 0x1000, 0xFFFF_FFE0, &input) - .expect("operand ending at the limb boundary must run"); - run_hint_at(HINT_FIELD_INV, 0xFFFF_FFE0, 0x2000, &input) - .expect("operand ending at the limb boundary must run"); -} - -/// The scalar-field inverse hint (mod n) round-trips through guest memory and -/// satisfies `x·inv == 1 (mod n)` — the check the guest performs on the untrusted -/// value. Used by production ecrecover (`r⁻¹`). -#[test] -fn hint_syscall_writes_the_scalar_inverse() { - use k256::elliptic_curve::PrimeField; - - let mut input = [0u8; 32]; - input[31] = 3; // 3, big-endian - - let out = run_hint_at(HINT_SCALAR_INV, 0x1000, 0x2000, &input).expect("hint must run"); - assert_eq!(out, compute_hint(HINT_SCALAR_INV, &input)); - - let three: k256::Scalar = Option::from(k256::Scalar::from_repr(input.into())).unwrap(); - let inv: k256::Scalar = Option::from(k256::Scalar::from_repr(out.into())).unwrap(); - assert_eq!( - (three * inv).to_bytes(), - k256::Scalar::ONE.to_bytes(), - "hinted scalar inverse must satisfy x·inv == 1 (mod n)" - ); -} - -/// The base-field sqrt hint (mod p) round-trips and satisfies `y² == rhs (mod p)`. -/// Used by production ecrecover (decompressing R). `4 = 2²` is a residue. -#[test] -fn hint_syscall_writes_the_field_sqrt() { - let mut input = [0u8; 32]; - input[31] = 4; // rhs = 4, big-endian - - let out = run_hint_at(HINT_FIELD_SQRT, 0x1000, 0x2000, &input).expect("hint must run"); - assert_eq!(out, compute_hint(HINT_FIELD_SQRT, &input)); - - let rhs: k256::FieldElement = - Option::from(k256::FieldElement::from_bytes(&input.into())).unwrap(); - let y: k256::FieldElement = Option::from(k256::FieldElement::from_bytes(&out.into())).unwrap(); - assert_eq!( - y.square().to_bytes(), - rhs.to_bytes(), - "hinted sqrt must satisfy y² == rhs (mod p)" - ); -} - -/// An unknown `hint_id` is rejected up front. Silently writing zeros would be -/// indistinguishable from a legitimate numeric failure and — because the guest reads -/// the value back — could let a prover-chosen selector steer a caller's accept/reject -/// outcome. The executor traps so a guest bug surfaces loudly. `HINT_FIELD_SQRT = 2` -/// is the last known selector, so 3 is the first unknown one. -#[test] -fn hint_syscall_rejects_an_unknown_selector() { - let mut input = [0u8; 32]; - input[31] = 3; - for bad in [3u64, 100, u64::MAX] { - let err = run_hint_at(bad, 0x1000, 0x2000, &input).expect_err("unknown selector must trap"); - assert!( - matches!(err, ExecutionError::HintUnknownSelector(id) if id == bad), - "expected HintUnknownSelector({bad}), got {err:?}" - ); - } -} - -/// The guest's `lambda-vm-syscalls` crate re-declares the selectors as `usize`, -/// linked to the `u64` copies here only by a comment. A divergence is **silent**: -/// the ecall would trap on an unknown selector, or — worse for the selectors that -/// stay in range — hand back the wrong function's answer, which the guest's -/// verify-then-fallback swallows as "the host lied" and quietly recomputes in -/// software. Nothing fails; the guest just runs ~2000× slower for the right result. -/// This test is the only thing that would notice. -/// -/// `is_valid_hint_selector`'s const-assert pins the AIR's range-check to this crate's -/// accepted set, but nothing ties the *guest's* copy of the selectors to it — that is -/// a third declaration, in a crate the workspace excludes, and this is what binds it. -/// -/// The syscall number itself is not asserted here: the guest's copy is -/// `#[cfg(target_arch = "riscv64")]` and private, so it does not exist in a host -/// build. It is covered indirectly — a wrong number makes every `hint` guest fail -/// to prove, which `test_prove_hint_min_rust_guest` catches loudly. -#[cfg(test)] -mod guest_constant_sync { - use super::{HINT_FIELD_INV, HINT_FIELD_SQRT, HINT_SCALAR_INV}; - use lambda_vm_syscalls::syscalls as guest; - - #[test] - fn hint_selectors_match_the_guest() { - assert_eq!(guest::HINT_FIELD_INV as u64, HINT_FIELD_INV); - assert_eq!(guest::HINT_SCALAR_INV as u64, HINT_SCALAR_INV); - assert_eq!(guest::HINT_FIELD_SQRT as u64, HINT_FIELD_SQRT); - } -} diff --git a/executor/src/tests/mod.rs b/executor/src/tests/mod.rs index 244447b22..456607433 100644 --- a/executor/src/tests/mod.rs +++ b/executor/src/tests/mod.rs @@ -1,5 +1,4 @@ pub mod ecsm_tests; pub mod flamegraph_tests; -pub mod hint_tests; pub mod keccak_tests; pub mod memory_tests; diff --git a/executor/src/vm/execution.rs b/executor/src/vm/execution.rs index dc0660178..24bb85101 100644 --- a/executor/src/vm/execution.rs +++ b/executor/src/vm/execution.rs @@ -25,6 +25,10 @@ pub struct ExecutionResult { /// Predecoded instructions map (pc -> instruction) /// Use this to look up instructions by their PC from the logs pub instructions: U64HashMap, + /// `(hint_id, input)` pairs the guest appended to the hint request log + /// (arena exhausted), in request order — the recording pass of the two-pass + /// hint flow. Empty when the arena covered every request. + pub hint_requests: Vec<(u64, [u8; 32])>, } /// Size of each log chunk - balances memory usage vs callback overhead @@ -51,9 +55,13 @@ pub struct Executor { } impl Executor { - pub fn new(program: &Elf, private_inputs: Vec) -> Result { + pub fn new( + program: &Elf, + private_inputs: Vec, + hints: &[[u8; 32]], + ) -> Result { let mut memory = Memory::default(); - memory.store_private_inputs(private_inputs)?; + memory.store_private_inputs(private_inputs, hints)?; let instructions = InstructionCache::new(&program.data)?; load_program(&program.data, &mut memory)?; @@ -163,6 +171,7 @@ impl Executor { return_values: self.get_return_values()?, logs, instructions: self.instructions.into_instruction_map(), + hint_requests: self.memory.hint_requests()?, }) } @@ -190,6 +199,33 @@ impl Executor { } } +/// Record the guest's hint requests and answer them — the host half of the +/// two-pass hint flow for guests whose hints are not known before the run. +/// +/// The program runs once with an empty hint arena: every +/// `syscalls::syscalls::request_hint` misses, is appended to the guest's +/// request log, and the guest recomputes in software (correct, just slower). +/// Each logged `(hint_id, input)` is then answered with +/// [`crate::vm::instruction::execution::compute_hint`], and the answers — in +/// request order — are returned as the hint arena for the second, provable +/// pass: pass them as `hints` to [`Executor::new`] (or the prover's `prove_*` +/// functions) together with the same program and private input. Both passes +/// produce the same committed output; the arena only changes how cheaply the +/// guest gets there. A guest that requests nothing yields an empty arena. +pub fn collect_hints( + program: &Elf, + private_inputs: Vec, +) -> Result, ExecutorError> { + let result = Executor::new(program, private_inputs, &[])?.run()?; + Ok(result + .hint_requests + .iter() + .map(|(hint_id, input)| { + crate::vm::instruction::execution::compute_hint(*hint_id, input) + }) + .collect()) +} + fn load_program(segments: &[crate::elf::Segment], memory: &mut Memory) -> Result<(), MemoryError> { for segment in segments { for (i, inst) in segment.values.iter().enumerate() { diff --git a/executor/src/vm/instruction/execution.rs b/executor/src/vm/instruction/execution.rs index 592af95e8..806095b4d 100644 --- a/executor/src/vm/instruction/execution.rs +++ b/executor/src/vm/instruction/execution.rs @@ -16,9 +16,6 @@ pub enum SyscallNumbers { Halt = 93, // Placeholder discriminant. The actual syscall value is ECSM_SYSCALL_NUMBER. Ecsm = 94, - // Placeholder discriminant. The actual syscall value is HINT_SYSCALL_NUMBER. - // Non-constraining hint (host computes modular inverse/sqrt, guest verifies). - Hint = 95, } /// Syscall number for KeccakPermute (u64::MAX - 1 = 0xFFFF_FFFF_FFFF_FFFE). @@ -34,37 +31,24 @@ const KECCAK_STATE_BYTES: u64 = 25 * 8; /// bus as `[lo32, hi32] = [2^32 - 11, 2^32 - 1]`. pub const ECSM_SYSCALL_NUMBER: u64 = u64::MAX - 10; -/// Syscall number for the non-constraining `Hint` ecall. -/// -/// The host computes a modular inverse or square root and writes it back to the -/// guest, which MUST verify it (e.g. `x·inv == 1`) and recompute in software on a -/// verification failure. The ecall adds no in-circuit correctness constraint of its -/// own — it lets the guest replace an expensive computation with a cheap check, -/// without letting the (prover-chosen) hinted value change the guest's result. -pub const HINT_SYSCALL_NUMBER: u64 = u64::MAX - 30; - -/// Hint operation selector passed in `a0`. +/// Hint operation selector passed to [`compute_hint`]. These are also the +/// request-log `hint_id`s the guest writes with `syscalls::syscalls::request_hint`. pub const HINT_FIELD_INV: u64 = 0; // secp256k1 base-field inverse (mod p) pub const HINT_SCALAR_INV: u64 = 1; // secp256k1 scalar-field inverse (mod n) pub const HINT_FIELD_SQRT: u64 = 2; // secp256k1 base-field square root -/// One past the largest valid hint selector. The prover's HINT table range-checks -/// `a0 < HINT_SELECTOR_BOUND` on the ALU bus to accept exactly the set -/// [`is_valid_hint_selector`] accepts, so both live here rather than being restated -/// independently in the AIR. +/// One past the largest valid hint selector. Kept in parity with the request-log +/// `hint_id` constants in the `syscalls` crate (`HINT_FIELD_INV` / `HINT_SCALAR_INV` +/// / `HINT_FIELD_SQRT`). pub const HINT_SELECTOR_BOUND: u64 = 3; -/// Whether `hint_id` names a hint [`compute_hint`] can produce. The ecall rejects -/// anything else up front with [`ExecutionError::HintUnknownSelector`]. +/// Whether `hint_id` names a hint [`compute_hint`] can produce. pub const fn is_valid_hint_selector(hint_id: u64) -> bool { matches!(hint_id, HINT_FIELD_INV | HINT_SCALAR_INV | HINT_FIELD_SQRT) } -// The AIR's range-check and the executor's accepted set must denote the same set: every -// selector below the bound is valid, and the bound itself is not. Appending a selector -// without moving the bound (or vice versa) fails to compile here, instead of making the -// HINT table assert `LT(selector, bound) = 1` against an LT row the builder emits as 0 — -// an unbalanced ALU bus with no algebraic pointer to the cause. +// Every selector below the bound is valid, and the bound itself is not. Appending a +// selector without moving the bound (or vice versa) fails to compile here. const _: () = { let mut id = 0; while id < HINT_SELECTOR_BOUND { @@ -88,7 +72,6 @@ impl TryFrom for SyscallNumbers { 93 => Ok(SyscallNumbers::Halt), v if v == KECCAK_SYSCALL_NUMBER => Ok(SyscallNumbers::KeccakPermute), v if v == ECSM_SYSCALL_NUMBER => Ok(SyscallNumbers::Ecsm), - v if v == HINT_SYSCALL_NUMBER => Ok(SyscallNumbers::Hint), _ => Err(()), } } @@ -112,8 +95,7 @@ impl SyscallNumbers { SyscallNumbers::Print | SyscallNumbers::Panic | SyscallNumbers::Commit - | SyscallNumbers::Halt - | SyscallNumbers::Hint => None, + | SyscallNumbers::Halt => None, } } } @@ -140,20 +122,17 @@ fn store_u256_le(memory: &mut Memory, addr: u64, bytes: &[u8; 32]) -> Result<(), /// Compute a non-constraining hint (modular inverse / sqrt) with the same k256 /// arithmetic the guest verifies against. Input/output are 32-byte big-endian, -/// k256's own serialization — unlike the ECSM ABI, which is little-endian because -/// its chip consumes little-endian limbs. The HINT table only copies these bytes -/// into memory writes, so the order is free to match the consumers. +/// k256's own serialization. This is the host-side answer to a request logged by +/// the guest's `syscalls::syscalls::request_hint`: the host runs it over +/// [`crate::vm::memory::Memory::hint_requests`] and appends the results to the +/// hint arena in the private-input region for a second pass. /// /// On a numeric failure (non-canonical input, no inverse/sqrt) returns zeros. This /// is NOT a loud failure and must not be treated as one: the guest's in-circuit /// verify rejects the value and recomputes it in software (see the `ethrex-crypto` /// crate), so a zero/garbage hint only costs the guest extra work — it can never -/// change the guest's result. An *unknown* `hint_id` never reaches here: the ecall -/// dispatch rejects it up front with [`ExecutionError::HintUnknownSelector`], so the -/// `_` arm below is defensive only. -/// -/// `pub` so the prover's `collect_hint_ops` can reproduce the exact output value -/// the executor wrote to guest memory (the value is not carried in the CPU log). +/// change the guest's result. The `_` arm for an unknown `hint_id` is defensive +/// only: the arena is untrusted input, and the guest verifies regardless. pub fn compute_hint(hint_id: u64, in_be: &[u8; 32]) -> [u8; 32] { use k256::elliptic_curve::PrimeField; let mut fb = k256::FieldBytes::default(); @@ -189,7 +168,7 @@ pub fn compute_hint(hint_id: u64, in_be: &[u8; 32]) -> [u8; 32] { /// `(addr mod 2^32) + max_offset < 2^32`. Tables that send an address to the memory /// bus as a `[lo32, hi32]` pair with the per-access offset added to `lo32` alone /// cannot represent a carry into `hi32`, so an operand straddling the limb boundary -/// makes the trace unprovable. Used by the ECSM and Hint ecalls. +/// makes the trace unprovable. Used by the ECSM ecall. fn addr_limb_ok(addr: u64, max_offset: u64) -> bool { (addr % LOW_LIMB) + max_offset < LOW_LIMB } @@ -550,42 +529,6 @@ impl Instruction { src2_val = addr_xg; dst_val = addr_k; } - SyscallNumbers::Hint => { - // Non-constraining hint: host computes a modular inverse/sqrt - // and writes it to the guest, which verifies it (and falls back - // to software on failure). a0 = hint_id, a1 = input addr - // (32-byte BE), a2 = output addr. The `_le` helpers only move - // bytes in address order, which is what a raw big-endian buffer - // needs. - let hint_id = registers.read(10)?; - let in_addr = registers.read(11)?; - let out_addr = registers.read(12)?; - // Reject an unrecognized selector up front: an unknown `hint_id` - // would otherwise silently produce a zero output (see - // `compute_hint`), indistinguishable from a legitimate numeric - // failure. Fail loudly instead so a guest bug surfaces here. - if !is_valid_hint_selector(hint_id) { - return Err(ExecutionError::HintUnknownSelector(hint_id)); - } - // Both operands are bounded so their 32-byte ranges cannot cross the - // 2^32 limb boundary, and the HINT table range-checks both low limbs - // against the same bound (`HINT_ADDR_LIMB_BOUND`) so the AIR accepts - // exactly what this rejects. The memory bus does not do that job on - // its own: it bounds `out_addr` only to 2^32 - 25, because the write - // bases are `out_addr_lo + 8i` and MEMW's carry columns resolve the - // bytes past the largest base. `in_addr` is not on the bus at all - // (the input read is not modeled). Bounding both also keeps - // `load_u256_le`/`store_u256_le` from overflowing their address - // arithmetic. - if !addr_limb_ok(in_addr, 31) || !addr_limb_ok(out_addr, 31) { - return Err(ExecutionError::HintAddressOverflow); - } - let input = load_u256_le(memory, in_addr)?; - let output = compute_hint(hint_id, &input); - store_u256_le(memory, out_addr, &output)?; - src2_val = in_addr; - dst_val = out_addr; - } SyscallNumbers::Halt => { // halt return Ok(Log { @@ -766,10 +709,6 @@ pub enum ExecutionError { EcsmAddressOverflow, #[error("ECSM xG and k operand ranges overlap")] EcsmOperandOverlap, - #[error("Hint address range overflows the lower 32-bit limb")] - HintAddressOverflow, - #[error("Unknown hint selector: {0}")] - HintUnknownSelector(u64), #[error("ECSM scalar multiplication error: {0}")] Ecsm(#[from] ecsm::EcsmError), } diff --git a/executor/src/vm/memory.rs b/executor/src/vm/memory.rs index e1a269a01..705d20922 100644 --- a/executor/src/vm/memory.rs +++ b/executor/src/vm/memory.rs @@ -47,7 +47,8 @@ pub const MAX_PUBLIC_OUTPUT_TOTAL_SIZE: u64 = 1024 * 1024; pub const MAX_PRIVATE_INPUT_SIZE: u64 = 512 * 1024 * 1024; /// Fixed high address where private input is mapped. Guest programs can read /// directly from this address (ZisK-style memory-mapped input). -/// Layout: 4-byte LE length prefix at `PRIVATE_INPUT_START_INDEX`, then data at +4. +/// Layout: `[u32 LE main_len][main data][zero-pad to 8][u32 LE hint_count][u32 zero pad]` +/// then `hint_count` 32-byte hint slots (see [`encode_private_input_region`]). /// Must match `PRIVATE_INPUT_START` in `syscalls/src/syscalls.rs`. pub const PRIVATE_INPUT_START_INDEX: u64 = 0xFF000000; /// Size in bytes of the private input's wire-format length prefix (the `u32` LE @@ -55,6 +56,72 @@ pub const PRIVATE_INPUT_START_INDEX: u64 = 0xFF000000; /// data follows at `+ PRIVATE_INPUT_LENGTH_PREFIX_BYTES`). Single source of truth /// for every page-span computation over the private-input region. pub const PRIVATE_INPUT_LENGTH_PREFIX_BYTES: usize = size_of::(); +/// Size in bytes of one hint slot in the hint arena. +pub const HINT_SLOT_BYTES: u64 = 32; +/// Size in bytes of the hint-arena header (`[u32 LE hint_count][u32 zero pad]`), +/// always present once the region is written at all. +pub const HINT_ARENA_HEADER_BYTES: u64 = 8; + +/// Start of the hint request log: just past the reserved private-input window, +/// rounded up to 8 (`0xFF000000 + 4 + 512 MiB + 4` = `0x11F000008`). The guest +/// appends `(hint_id, input)` entries here when the hint arena is exhausted — +/// the recording pass of the two-pass hint flow. The host reads them back with +/// [`Memory::hint_requests`]. Must match `HINT_LOG_START` in +/// `syscalls/src/syscalls.rs`. +pub const HINT_LOG_START_INDEX: u64 = 0xFF000000 + + PRIVATE_INPUT_LENGTH_PREFIX_BYTES as u64 + + MAX_PRIVATE_INPUT_SIZE + + 4; +/// Size in bytes of the request-log header (`[u32 LE count][u32 zero pad]`). +pub const HINT_LOG_HEADER_BYTES: u64 = 8; +/// Size in bytes of one request-log entry (`[u64 LE hint_id][32-byte input]`). +pub const HINT_LOG_ENTRY_BYTES: u64 = 40; + +/// Byte offset from `PRIVATE_INPUT_START_INDEX` at which the hint-arena header +/// (count word) sits, for a given main-input length: the 4-byte length prefix +/// plus the main data, padded with zeros up to the next 8-byte boundary. +pub const fn hint_arena_header_offset(main_len: u64) -> u64 { + (PRIVATE_INPUT_LENGTH_PREFIX_BYTES as u64 + main_len + 7) & !7 +} + +/// Canonical encoder for the private-input region: +/// `[len][data][pad8][count][pad][slots]`. Single source of truth for the wire +/// format; the prover's trace builder must call this instead of re-encoding. +/// +/// The count word and its pad are ALWAYS written (even when `hints` is empty), +/// so the layout is uniform and old guests reading past their data see a zero +/// count. The whole region must fit the reserved window: +/// `align8(4 + main_len) + 8 + 32 * hint_count <= 4 + MAX_PRIVATE_INPUT_SIZE`, +/// which keeps the verifier's `max_private_input_pages()` bound valid. +pub fn encode_private_input_region( + inputs: &[u8], + hints: &[[u8; 32]], +) -> Result, MemoryError> { + let main_len = u32::try_from(inputs.len()).map_err(|_| MemoryError::PrivateInputSizeExceeded)?; + let header_offset = hint_arena_header_offset(inputs.len() as u64); + let hints_bytes = (hints.len() as u64) + .checked_mul(HINT_SLOT_BYTES) + .ok_or(MemoryError::PrivateInputSizeExceeded)?; + let total = header_offset + .checked_add(HINT_ARENA_HEADER_BYTES) + .and_then(|t| t.checked_add(hints_bytes)) + .ok_or(MemoryError::PrivateInputSizeExceeded)?; + if total > PRIVATE_INPUT_LENGTH_PREFIX_BYTES as u64 + MAX_PRIVATE_INPUT_SIZE { + return Err(MemoryError::PrivateInputSizeExceeded); + } + + let mut region = Vec::with_capacity(total as usize); + region.extend_from_slice(&main_len.to_le_bytes()); + region.extend_from_slice(inputs); + region.resize(header_offset as usize, 0); + region.extend_from_slice(&(hints.len() as u32).to_le_bytes()); + region.extend_from_slice(&[0u8; 4]); + for hint in hints { + region.extend_from_slice(hint); + } + debug_assert_eq!(region.len() as u64, total); + Ok(region) +} #[derive(Default, Debug, Clone)] pub struct Memory { @@ -222,23 +289,45 @@ impl Memory { Ok(self.public_output.clone()) } - /// Pre-loads private input bytes at `PRIVATE_INPUT_START_INDEX` as a - /// 4-byte LE length prefix followed by the raw data. The guest reads these - /// bytes directly via normal RISC-V loads (ZisK-style memory-mapped input). - pub fn store_private_inputs(&mut self, inputs: Vec) -> Result<(), MemoryError> { - if inputs.is_empty() { - return Ok(()); + /// Read the hint request log the guest appended via `request_hint` (the + /// recording pass of the two-pass hint flow): `(hint_id, input)` entries in + /// request order. Empty when the arena covered every request. + pub fn hint_requests(&self) -> Result, MemoryError> { + let count = self.load_word(HINT_LOG_START_INDEX)? as usize; + let mut out = Vec::with_capacity(count); + for i in 0..count { + let entry = HINT_LOG_START_INDEX + + HINT_LOG_HEADER_BYTES + + i as u64 * HINT_LOG_ENTRY_BYTES; + let hint_id = self.load_doubleword(entry)?; + let bytes = self.load_bytes(entry + 8, 32)?; + let mut input = [0u8; 32]; + input.copy_from_slice(&bytes); + out.push((hint_id, input)); } - if inputs.len() as u64 > MAX_PRIVATE_INPUT_SIZE { - return Err(MemoryError::PrivateInputSizeExceeded); + Ok(out) + } + + /// Pre-loads private input bytes at `PRIVATE_INPUT_START_INDEX` in the + /// canonical wire format ([`encode_private_input_region`]): a 4-byte LE + /// length prefix, the main data zero-padded to an 8-byte boundary, then the + /// always-present hint-arena header (`[u32 LE hint_count][u32 zero pad]`) + /// followed by `hint_count` 32-byte hint slots. The guest reads these bytes + /// directly via normal RISC-V loads (ZisK-style memory-mapped input). + /// + /// With no main input AND no hints nothing is written at all (the region + /// reads back as all zeros, including a zero hint count) so a no-input + /// program keeps zero private-input pages. + pub fn store_private_inputs( + &mut self, + inputs: Vec, + hints: &[[u8; 32]], + ) -> Result<(), MemoryError> { + if inputs.is_empty() && hints.is_empty() { + return Ok(()); } - let len_u32 = - u32::try_from(inputs.len()).map_err(|_| MemoryError::PrivateInputSizeExceeded)?; - self.store_word(PRIVATE_INPUT_START_INDEX, len_u32)?; - self.set_bytes_aligned( - PRIVATE_INPUT_START_INDEX + PRIVATE_INPUT_LENGTH_PREFIX_BYTES as u64, - &inputs, - )?; + let region = encode_private_input_region(&inputs, hints)?; + self.set_bytes_aligned(PRIVATE_INPUT_START_INDEX, ®ion)?; Ok(()) } @@ -313,7 +402,7 @@ mod tests { fn store_private_inputs_writes_le_length_prefix_then_data() { let mut memory = Memory::default(); let inputs = vec![0xAAu8, 0xBB, 0xCC]; - memory.store_private_inputs(inputs.clone()).unwrap(); + memory.store_private_inputs(inputs.clone(), &[]).unwrap(); assert_eq!( memory.load_word(PRIVATE_INPUT_START_INDEX).unwrap(), @@ -327,4 +416,158 @@ mod tests { .unwrap(); assert_eq!(data, inputs); } + + // Roundtrip with hints: the count word sits at `hint_arena_header_offset(main_len)` + // (8-aligned), the slots land right after the 8-byte header, and the region's + // extent is `header + 8 + 32 * hint_count`. + #[test] + fn store_private_inputs_roundtrip_with_hints() { + let mut memory = Memory::default(); + let inputs = vec![0x11u8; 5]; // odd length exercises the pad-to-8 + let hints = [[0x22u8; 32], [0x33u8; 32]]; + memory.store_private_inputs(inputs.clone(), &hints).unwrap(); + + // Legacy prefix: [len][data]. + assert_eq!( + memory.load_word(PRIVATE_INPUT_START_INDEX).unwrap(), + inputs.len() as u32 + ); + let data = memory + .load_bytes( + PRIVATE_INPUT_START_INDEX + PRIVATE_INPUT_LENGTH_PREFIX_BYTES as u64, + inputs.len() as u64, + ) + .unwrap(); + assert_eq!(data, inputs); + + // Hint-arena header: align8(4 + 5) = 16, count word at base + 16, pad zero. + let header = hint_arena_header_offset(inputs.len() as u64); + assert_eq!(header, 16); + assert!(header.is_multiple_of(8)); + assert_eq!( + memory + .load_word(PRIVATE_INPUT_START_INDEX + header) + .unwrap(), + hints.len() as u32 + ); + assert_eq!( + memory + .load_word(PRIVATE_INPUT_START_INDEX + header + 4) + .unwrap(), + 0 + ); + + // Slots land at header + 8, 32 bytes each, in order. + for (i, hint) in hints.iter().enumerate() { + let slot = memory + .load_bytes( + PRIVATE_INPUT_START_INDEX + header + HINT_ARENA_HEADER_BYTES + + i as u64 * HINT_SLOT_BYTES, + HINT_SLOT_BYTES, + ) + .unwrap(); + assert_eq!(slot, hint); + } + + // The encoder agrees with what was stored, byte for byte. + let encoded = encode_private_input_region(&inputs, &hints).unwrap(); + let stored = memory + .load_bytes(PRIVATE_INPUT_START_INDEX, encoded.len() as u64) + .unwrap(); + assert_eq!(stored, encoded); + } + + // Empty main input with hints: the `[0]` length prefix and the arena header are + // still written (no early return), and the slots follow the header. + #[test] + fn store_private_inputs_empty_main_with_hints() { + let mut memory = Memory::default(); + let hints = [[0xABu8; 32]]; + memory.store_private_inputs(vec![], &hints).unwrap(); + + assert_eq!(memory.load_word(PRIVATE_INPUT_START_INDEX).unwrap(), 0); + // align8(4 + 0) = 8: the count word is readable at base + 8. + let header = hint_arena_header_offset(0); + assert_eq!(header, 8); + assert_eq!( + memory + .load_word(PRIVATE_INPUT_START_INDEX + header) + .unwrap(), + 1 + ); + let slot = memory + .load_bytes( + PRIVATE_INPUT_START_INDEX + header + HINT_ARENA_HEADER_BYTES, + HINT_SLOT_BYTES, + ) + .unwrap(); + assert_eq!(slot, hints[0]); + } + + // Zero hints produce the legacy prefix plus the always-written 8-byte header + // (count = 0, pad = 0) — and nothing past it. + #[test] + fn store_private_inputs_zero_hints_writes_empty_header() { + let mut memory = Memory::default(); + let inputs = vec![0x42u8; 16]; + memory.store_private_inputs(inputs.clone(), &[]).unwrap(); + + let header = hint_arena_header_offset(inputs.len() as u64); + assert_eq!(header, 24); // align8(4 + 16) + assert_eq!( + memory + .load_word(PRIVATE_INPUT_START_INDEX + header) + .unwrap(), + 0 + ); + assert_eq!( + memory + .load_word(PRIVATE_INPUT_START_INDEX + header + 4) + .unwrap(), + 0 + ); + let encoded = encode_private_input_region(&inputs, &[]).unwrap(); + assert_eq!(encoded.len() as u64, header + HINT_ARENA_HEADER_BYTES); + } + + // No main input and no hints: nothing is written (a zero input program keeps + // zero private-input pages); the count word reads back as 0 regardless. + #[test] + fn store_private_inputs_empty_everything_writes_nothing() { + let mut memory = Memory::default(); + memory.store_private_inputs(vec![], &[]).unwrap(); + assert_eq!(memory.load_word(PRIVATE_INPUT_START_INDEX).unwrap(), 0); + assert_eq!( + memory + .load_word(PRIVATE_INPUT_START_INDEX + hint_arena_header_offset(0)) + .unwrap(), + 0 + ); + } + + // Size cap: `align8(4 + main_len) + 8 + 32 * hint_count` must stay within + // `4 + MAX_PRIVATE_INPUT_SIZE`. A main input exactly at the old cap leaves no + // room for the arena header, and even one hint on top must fail. + #[test] + fn encode_private_input_region_enforces_size_cap() { + // Rejection cases first: the cap is checked before the region is + // allocated, so these never touch the 512 MiB output buffer. + // + // The old single-section cap (main_len > MAX) still fails. + let err = encode_private_input_region(&vec![0u8; MAX_PRIVATE_INPUT_SIZE as usize + 1], &[]) + .unwrap_err(); + assert!(matches!(err, MemoryError::PrivateInputSizeExceeded)); + + // Largest main_len whose region (with the header, zero hints) still fits: + // align8(4 + main_len) + 8 <= 4 + MAX. With MAX a multiple of 8, that is + // 4 + main_len <= MAX - 8, i.e. main_len = MAX - 12. + let ok_len = MAX_PRIVATE_INPUT_SIZE as usize - 12; + let inputs = vec![0u8; ok_len]; + // The same main_len plus a single hint exceeds the reserved window. + let err = encode_private_input_region(&inputs, &[[0u8; 32]]).unwrap_err(); + assert!(matches!(err, MemoryError::PrivateInputSizeExceeded)); + + let encoded = encode_private_input_region(&inputs, &[]).unwrap(); + assert_eq!(encoded.len() as u64, MAX_PRIVATE_INPUT_SIZE); + } } diff --git a/executor/tests/asm.rs b/executor/tests/asm.rs index a1c9baf2b..fe765b35b 100644 --- a/executor/tests/asm.rs +++ b/executor/tests/asm.rs @@ -12,7 +12,7 @@ fn run_program(elf_path: &str) { println!("Testing {}", elf_path); let elf_data = std::fs::read(elf_path).unwrap(); let program = Elf::load(&elf_data).unwrap(); - let mut executor = Executor::new(&program, vec![]).expect("Failed to create executor"); + let mut executor = Executor::new(&program, vec![], &[]).expect("Failed to create executor"); while let Some(_logs) = executor.resume().expect("Failed to execute") {} @@ -31,7 +31,7 @@ fn test_private_input_memory_mapped() { let elf_data = std::fs::read("./program_artifacts/asm/test_private_input_xpage.elf").unwrap(); let program = Elf::load(&elf_data).unwrap(); let input: Vec = (0u8..16).collect(); - let executor = Executor::new(&program, input.clone()).unwrap(); + let executor = Executor::new(&program, input.clone(), &[]).unwrap(); let result = executor.run().unwrap(); // Committed bytes are at 0xFF000008 = data bytes [4..12] assert_eq!(result.return_values.memory_values, input[4..12].to_vec()); @@ -476,7 +476,7 @@ fn test_misalign_sd() { fn test_misaligned_pc_traps() { let elf_data = std::fs::read("./program_artifacts/asm/misaligned_pc.elf").unwrap(); let program = Elf::load(&elf_data).unwrap(); - let mut executor = Executor::new(&program, vec![]).expect("Failed to create executor"); + let mut executor = Executor::new(&program, vec![], &[]).expect("Failed to create executor"); let err = loop { match executor.resume() { Ok(Some(_)) => continue, @@ -886,7 +886,7 @@ fn test_keccak() { // Expected output is the FIPS-202 zero-input KAT. let elf_data = std::fs::read("./program_artifacts/asm/test_keccak.elf").unwrap(); let program = Elf::load(&elf_data).unwrap(); - let executor = Executor::new(&program, vec![]).expect("Failed to create executor"); + let executor = Executor::new(&program, vec![], &[]).expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); let expected_state: [u64; 25] = [ @@ -930,7 +930,7 @@ fn test_run_epochs_splits_execution_into_n_cycle_epochs() { let program = Elf::load(&elf_data).unwrap(); // Reference: full single-pass run. - let full = Executor::new(&program, vec![]).unwrap().run().unwrap(); + let full = Executor::new(&program, vec![], &[]).unwrap().run().unwrap(); // Pick an epoch size that splits this program into a few epochs, whatever // its exact length. @@ -938,7 +938,7 @@ fn test_run_epochs_splits_execution_into_n_cycle_epochs() { assert!(total_cycles >= 2); let epoch_size = (total_cycles / 3).max(1); - let epochs = Executor::new(&program, vec![]) + let epochs = Executor::new(&program, vec![], &[]) .unwrap() .run_epochs(epoch_size) .unwrap(); diff --git a/executor/tests/hint_arena_ecrecover.rs b/executor/tests/hint_arena_ecrecover.rs new file mode 100644 index 000000000..1a7980541 --- /dev/null +++ b/executor/tests/hint_arena_ecrecover.rs @@ -0,0 +1,121 @@ +//! Two-pass hint-arena measurement driver for the `ecrecover_hints` guest. +//! +//! Pass 1 runs with an empty arena: every hint request misses, is appended to +//! the guest's request log, and the guest recomputes in software (correct, +//! just slow). The host then answers the logged requests with `compute_hint`, +//! and pass 2 re-runs with a complete arena — the provable configuration. +//! +//! Run explicitly (prints cycle counts; the guest ELF must be built): +//! cargo test -p executor --test hint_arena_ecrecover -- --ignored --nocapture + +use executor::elf::Elf; +use executor::vm::instruction::execution::compute_hint; +use executor::vm::execution::Executor; +use std::time::Instant; + +/// Number of ecrecovers the guest performs (3 hint requests each). +const N: usize = 30; + +/// Build the guest's private input: `[u32 LE count]` then `count` records of +/// `sig(64) || recid(1) || msg(32)`. Any `(r, s, msg)` with `r, s` nonzero and +/// in range is a legal ecrecover input, but `r³ + 7` must be a quadratic +/// residue for decompression to succeed — so `r` values are rejection-sampled +/// for residuosity. `recid` alternates parity. +fn build_input() -> Vec { + use k256::{FieldElement, Scalar}; + + let mut out = Vec::with_capacity(4 + N * 97); + out.extend_from_slice(&(N as u32).to_le_bytes()); + + let mut r_int = 0u64; + for i in 0..N { + // Next r whose rhs = r³ + 7 is a residue. + let (r_bytes, y_is_odd) = loop { + r_int += 1; + let r = FieldElement::from(r_int); + let rhs = r * r * r + FieldElement::from(7u64); + if let Some(y) = Option::::from(rhs.sqrt()) { + // ecrecover parses r as a *scalar* (r < n); small ints qualify. + // `sqrt` yields an unnormalized element; `is_odd` requires a + // normalized one. + break (r.to_bytes(), bool::from(y.normalize().is_odd())); + } + }; + let s = Scalar::from(1000u64 + i as u64); + let msg_byte = (i as u8).wrapping_mul(7).wrapping_add(1); + + out.extend_from_slice(&r_bytes); + out.extend_from_slice(&s.to_bytes()); + out.push(u8::from(y_is_odd)); + out.extend_from_slice(&[msg_byte; 32]); + } + out +} + +#[test] +#[ignore = "measurement driver — run explicitly"] +fn ecrecover_two_pass_cycles() { + let elf_bytes = std::fs::read("program_artifacts/rust/ecrecover_hints.elf") + .expect("ecrecover_hints.elf missing — run `make executor/program_artifacts/rust/ecrecover_hints.elf`"); + let elf = Elf::load(&elf_bytes).expect("ELF load"); + let input = build_input(); + + // ── Pass 1: empty arena — all hints miss, logged, software fallback. ── + let t0 = Instant::now(); + let pass1 = Executor::new(&elf, input.clone(), &[]) + .expect("executor") + .run() + .expect("pass 1"); + let pass1_time = t0.elapsed(); + let pass1_cycles = pass1.logs.len(); + assert_eq!( + pass1.hint_requests.len(), + 3 * N, + "every recovery must log sqrt + scalar_inv + field_inv" + ); + + // Host answers the requests, in order — that IS the arena. + let hints: Vec<[u8; 32]> = pass1 + .hint_requests + .iter() + .map(|(id, input)| compute_hint(*id, input)) + .collect(); + + // ── Pass 2: complete arena — every hint hits and verifies. ── + let t0 = Instant::now(); + let pass2 = Executor::new(&elf, input.clone(), &hints) + .expect("executor") + .run() + .expect("pass 2"); + let pass2_time = t0.elapsed(); + let pass2_cycles = pass2.logs.len(); + assert!(pass2.hint_requests.is_empty(), "arena must cover pass 2"); + + // Same program, same input: identical committed output. + assert_eq!( + pass1.return_values.memory_values, pass2.return_values.memory_values, + "hint source must not change the result" + ); + + println!("[ecrecover-hints] N = {N} recoveries"); + println!("[ecrecover-hints] pass 1 (software fallback): {pass1_cycles} cycles in {pass1_time:?}"); + println!("[ecrecover-hints] pass 2 (arena hints): {pass2_cycles} cycles in {pass2_time:?}"); + println!( + "[ecrecover-hints] guest cycle ratio pass1/pass2: {:.2}x", + pass1_cycles as f64 / pass2_cycles as f64 + ); + + // Dump the fixtures the prover-side continuation measurement consumes: + // `.input.bin` (the private input) and `.hints.bin` (the arena + // slots, concatenated). + let dir = std::path::Path::new("program_artifacts/rust"); + let input_path = dir.join("ecrecover_hints.input.bin"); + let hints_path = dir.join("ecrecover_hints.hints.bin"); + std::fs::write(&input_path, &input).expect("write input fixture"); + let mut hints_bin = Vec::with_capacity(32 * hints.len()); + for h in &hints { + hints_bin.extend_from_slice(h); + } + std::fs::write(&hints_path, &hints_bin).expect("write hints fixture"); + println!("[ecrecover-hints] fixtures written: {input_path:?}, {hints_path:?}"); +} diff --git a/executor/tests/rust.rs b/executor/tests/rust.rs index 1c13ad1a5..7f2cbe242 100644 --- a/executor/tests/rust.rs +++ b/executor/tests/rust.rs @@ -13,7 +13,7 @@ fn run_program_without_expect( let elf_data = std::fs::read(elf_path).unwrap(); let program = Elf::load(&elf_data).unwrap(); println!("Program entry: 0x{:016x}", program.entry_point); - let mut executor = Executor::new(&program, private_inputs)?; + let mut executor = Executor::new(&program, private_inputs, &[])?; while let Some(_logs) = executor.resume()? {} executor.finish() } diff --git a/prover/benches/bench_continuation.rs b/prover/benches/bench_continuation.rs index c8638346f..7be769c93 100644 --- a/prover/benches/bench_continuation.rs +++ b/prover/benches/bench_continuation.rs @@ -47,7 +47,7 @@ fn main() { use executor::elf::Elf; use executor::vm::execution::Executor; let program = Elf::load(&elf).expect("bad ELF"); - let result = Executor::new(&program, private_inputs) + let result = Executor::new(&program, private_inputs, &[]) .expect("executor") .run() .expect("execution failed"); @@ -62,7 +62,7 @@ fn main() { use executor::vm::execution::Executor; use executor::vm::registers::STACK_TOP; let program = Elf::load(&elf).expect("bad ELF"); - let mut ex = Executor::new(&program, private_inputs).expect("executor"); + let mut ex = Executor::new(&program, private_inputs, &[]).expect("executor"); while ex.pc() != 0 { match ex.resume_with_limit(usize::MAX).expect("execution failed") { Some(_) => {} @@ -107,7 +107,7 @@ fn main() { } } "main" => { - lambda_vm_prover::prove_with_inputs(&elf, &private_inputs) + lambda_vm_prover::prove_with_inputs(&elf, &private_inputs, &[]) .expect("monolithic prove failed"); println!("main prove ok ({} bytes ELF)", elf.len()); } diff --git a/prover/src/continuation.rs b/prover/src/continuation.rs index 85f2d6223..da6194ee0 100644 --- a/prover/src/continuation.rs +++ b/prover/src/continuation.rs @@ -303,7 +303,7 @@ fn global_memory_configs( ) -> Vec { // No private bytes: the verifier only builds the AIRs, and private-input pages are // non-preprocessed (their INIT is never recomputed). - let image = build_initial_image_paged(elf, &[]); + let image = build_initial_image_paged(elf, &[], &[]); let init_page_data = build_init_page_data(&image); global_memory_configs_from_init_page_data( page_bases, @@ -1050,6 +1050,7 @@ fn verify_global( pub fn prove_continuation( elf_bytes: &[u8], private_inputs: &[u8], + hints: &[[u8; 32]], epoch_size_log2: u32, opts: &ProofOptions, ) -> Result { @@ -1073,7 +1074,7 @@ pub fn prove_continuation( let __root = stark::instruments::span("prove_continuation_total"); let elf = Elf::load(elf_bytes).map_err(|e| Error::ElfLoad(format!("{e}")))?; - let mut executor = Executor::new(&elf, private_inputs.to_vec()) + let mut executor = Executor::new(&elf, private_inputs.to_vec(), hints) .map_err(|e| Error::Execution(format!("{e}")))?; // The DECODE precomputed commitment depends only on (ELF, opts): compute // it once here instead of once per epoch inside `build_epoch_airs`. @@ -1086,7 +1087,7 @@ pub fn prove_continuation( // The cross-epoch memory image, carried forward: epoch i+1's init is epoch i's // fini, updated in place with each epoch's touched-cell final values. - let mut image = build_initial_image_paged(&elf, private_inputs); + let mut image = build_initial_image_paged(&elf, private_inputs, hints); let init_page_data = build_init_page_data(&image); let mut provenance = local_to_global::genesis_provenance(image.iter().map(|(a, v)| (a, v as u64))); @@ -1228,6 +1229,7 @@ pub fn prove_continuation( &job.register_init, &MaxRowsConfig::default(), private_inputs, + hints, job.is_final, true, #[cfg(feature = "disk-spill")] @@ -1429,7 +1431,7 @@ pub fn prove_continuation( let run = || -> Result { #[cfg(feature = "instruments")] let __sp = stark::instruments::span("prove_global"); - let num_private_input_pages = page::private_input_page_count(private_inputs); + let num_private_input_pages = page::private_input_page_count(private_inputs, hints); // SINGLE source of truth: the same page-base list drives the // committed GLOBAL_MEMORY tables and is shipped in the bundle, // so the two can never diverge in set or order. @@ -1755,7 +1757,7 @@ pub fn prove_and_verify_continuation( epoch_size_log2: u32, opts: &ProofOptions, ) -> Result>, Error> { - let bundle = prove_continuation(elf_bytes, private_inputs, epoch_size_log2, opts)?; + let bundle = prove_continuation(elf_bytes, private_inputs, &[], epoch_size_log2, opts)?; verify_continuation(elf_bytes, &bundle, opts) } @@ -1784,7 +1786,7 @@ mod tests { let elf_bytes = asm_elf_bytes("test_commit_split"); let expected_output: [u8; 4] = [0xAA, 0xBB, 0xCC, 0xDD]; - let total = Executor::new(&Elf::load(&elf_bytes).unwrap(), vec![]) + let total = Executor::new(&Elf::load(&elf_bytes).unwrap(), vec![], &[]) .unwrap() .run() .unwrap() @@ -1807,7 +1809,7 @@ mod tests { // Prove first so we can assert the run actually split into >1 epoch — without // this the test would silently pass even if it degraded to a single epoch. let bundle = - prove_continuation(&elf_bytes, &[], 4, &ProofOptions::default_test_options()).unwrap(); + prove_continuation(&elf_bytes, &[], &[], 4, &ProofOptions::default_test_options()).unwrap(); assert!( bundle.num_epochs() > 1, "16-cycle epochs must split the run into multiple epochs" @@ -1836,6 +1838,7 @@ mod tests { let r = prove_continuation( &elf_bytes, test_fault::MAGIC, + &[], 2, &ProofOptions::default_test_options(), ); @@ -1863,7 +1866,7 @@ mod tests { // Guard against silent degradation: the program must be longer than one // epoch, otherwise this collapses to a single final epoch and stops testing // the cross-epoch (intermediate-epoch) path. - let total = Executor::new(&Elf::load(&elf_bytes).unwrap(), vec![]) + let total = Executor::new(&Elf::load(&elf_bytes).unwrap(), vec![], &[]) .unwrap() .run() .unwrap() @@ -1892,7 +1895,7 @@ mod tests { fn test_verify_continuation_with_supplied_roots() { let elf_bytes = asm_elf_bytes("data_page_touch"); let opts = ProofOptions::default_test_options(); - let bundle = prove_continuation(&elf_bytes, &[], 3, &opts).unwrap(); + let bundle = prove_continuation(&elf_bytes, &[], &[], 3, &opts).unwrap(); let expected = verify_continuation(&elf_bytes, &bundle, &opts) .unwrap() @@ -1973,7 +1976,7 @@ mod tests { for name in ["data_page_touch", "all_loadstore_32"] { let elf_bytes = asm_elf_bytes(name); let opts = ProofOptions::default_test_options(); - let bundle = prove_continuation(&elf_bytes, &[], 3, &opts).unwrap(); + let bundle = prove_continuation(&elf_bytes, &[], &[], 3, &opts).unwrap(); let elf = Elf::load(&elf_bytes).unwrap(); let page_bases = canonical_page_bases(&bundle.touched_page_bases); @@ -2011,7 +2014,7 @@ mod tests { fn test_ecsm_across_epochs_verifies() { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("test_ecsm_split"); - let total = Executor::new(&Elf::load(&elf_bytes).unwrap(), vec![]) + let total = Executor::new(&Elf::load(&elf_bytes).unwrap(), vec![], &[]) .unwrap() .run() .unwrap() @@ -2051,7 +2054,7 @@ mod tests { #[test] fn test_continuation_rejects_too_small_epoch_size_log2() { assert!(matches!( - prove_continuation(&[], &[], 1, &ProofOptions::default_test_options()), + prove_continuation(&[], &[], &[], 1, &ProofOptions::default_test_options()), Err(Error::InvalidContinuationEpochSize(_)) )); } @@ -2065,7 +2068,7 @@ mod tests { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("test_commit_split"); let bundle = - prove_continuation(&elf_bytes, &[], 4, &ProofOptions::default_test_options()).unwrap(); + prove_continuation(&elf_bytes, &[], &[], 4, &ProofOptions::default_test_options()).unwrap(); let out = verify_continuation(&elf_bytes, &bundle, &ProofOptions::default_test_options()) .unwrap(); assert_eq!(out.as_deref(), Some(&[0xAA, 0xBB, 0xCC, 0xDD][..])); @@ -2078,7 +2081,7 @@ mod tests { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("test_commit_split"); let bundle = - prove_continuation(&elf_bytes, &[], 4, &ProofOptions::default_test_options()).unwrap(); + prove_continuation(&elf_bytes, &[], &[], 4, &ProofOptions::default_test_options()).unwrap(); let bytes = rkyv::to_bytes::(&bundle).unwrap(); let restored: ContinuationProof = @@ -2097,7 +2100,7 @@ mod tests { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("all_loadstore_32"); let mut bundle = - prove_continuation(&elf_bytes, &[], 3, &ProofOptions::default_test_options()).unwrap(); + prove_continuation(&elf_bytes, &[], &[], 3, &ProofOptions::default_test_options()).unwrap(); assert!(bundle.epochs.len() >= 3, "need multiple epochs"); bundle.epochs.pop(); assert!( @@ -2115,7 +2118,7 @@ mod tests { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("all_loadstore_32"); let mut bundle = - prove_continuation(&elf_bytes, &[], 3, &ProofOptions::default_test_options()).unwrap(); + prove_continuation(&elf_bytes, &[], &[], 3, &ProofOptions::default_test_options()).unwrap(); assert!(bundle.epochs.len() >= 3, "need multiple epochs"); bundle.epochs.swap(0, 1); assert!( @@ -2134,7 +2137,7 @@ mod tests { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("all_loadstore_32"); let mut bundle = - prove_continuation(&elf_bytes, &[], 3, &ProofOptions::default_test_options()).unwrap(); + prove_continuation(&elf_bytes, &[], &[], 3, &ProofOptions::default_test_options()).unwrap(); assert!( bundle.epochs.len() >= 2, "need a second epoch to chain into" @@ -2157,7 +2160,7 @@ mod tests { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("all_loadstore_32"); let mut bundle = - prove_continuation(&elf_bytes, &[], 3, &ProofOptions::default_test_options()).unwrap(); + prove_continuation(&elf_bytes, &[], &[], 3, &ProofOptions::default_test_options()).unwrap(); assert!(!bundle.epochs.is_empty()); bundle.epochs[0].reg_fini.pop(); assert!( @@ -2174,7 +2177,7 @@ mod tests { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("all_loadstore_32"); let mut bundle = - prove_continuation(&elf_bytes, &[], 8, &ProofOptions::default_test_options()).unwrap(); + prove_continuation(&elf_bytes, &[], &[], 8, &ProofOptions::default_test_options()).unwrap(); bundle.epochs[0].table_counts.cpu += 1; assert!( verify_continuation(&elf_bytes, &bundle, &ProofOptions::default_test_options()) @@ -2198,7 +2201,7 @@ mod tests { // Smallest epochs (2^2 = 4 cycles) so the short program splits across epochs. let bundle = - prove_continuation(&elf_bytes, &input, 2, &ProofOptions::default_test_options()) + prove_continuation(&elf_bytes, &input, &[], 2, &ProofOptions::default_test_options()) .unwrap(); assert!( bundle.num_epochs() > 1, @@ -2237,7 +2240,7 @@ mod tests { let elf_bytes = asm_elf_bytes("test_private_input_xpage"); let input: Vec = (0u8..16).collect(); let mut bundle = - prove_continuation(&elf_bytes, &input, 2, &ProofOptions::default_test_options()) + prove_continuation(&elf_bytes, &input, &[], 2, &ProofOptions::default_test_options()) .unwrap(); assert!( bundle.num_private_input_pages > 0, @@ -2271,7 +2274,7 @@ mod tests { let elf_bytes = asm_elf_bytes("test_private_input_xpage"); let input: Vec = (0u8..16).collect(); let mut bundle = - prove_continuation(&elf_bytes, &input, 2, &ProofOptions::default_test_options()) + prove_continuation(&elf_bytes, &input, &[], 2, &ProofOptions::default_test_options()) .unwrap(); assert_eq!( bundle.num_private_input_pages, 1, @@ -2321,19 +2324,37 @@ mod tests { // Pages below the region are never private. assert!(!page::is_private_input_page(start - page_size, 10)); - // private_input_page_count: wire format is [len:4][data], region is page-aligned. - assert_eq!(page::private_input_page_count(&[]), 0); - assert_eq!(page::private_input_page_count(&[0u8; 16]), 1); - // 4-byte prefix + (page_size - 4) data exactly fills one page. + // private_input_page_count: wire format is `[len:4][data][pad8][count:4][pad:4]` + // plus 32-byte hint slots; the region is page-aligned. The always-written + // 8-byte arena header shifts the old boundaries by +8 bytes. + assert_eq!(page::private_input_page_count(&[], &[]), 0); + assert_eq!(page::private_input_page_count(&[0u8; 16], &[]), 1); + // 4-byte prefix + (page_size - 12) data pads to page_size - 8, plus the + // 8-byte header exactly fills one page. assert_eq!( - page::private_input_page_count(&vec![0u8; page::DEFAULT_PAGE_SIZE - 4]), + page::private_input_page_count(&vec![0u8; page::DEFAULT_PAGE_SIZE - 12], &[]), 1 ); - // One more byte spills into a second page. + // One more byte pads up to page_size and the header spills into a second page. + assert_eq!( + page::private_input_page_count(&vec![0u8; page::DEFAULT_PAGE_SIZE - 11], &[]), + 2 + ); + // The old single-page boundary (page_size - 4) now needs two pages: the + // data section alone fills the page and the +8 header spills over. + assert_eq!( + page::private_input_page_count(&vec![0u8; page::DEFAULT_PAGE_SIZE - 4], &[]), + 2 + ); assert_eq!( - page::private_input_page_count(&vec![0u8; page::DEFAULT_PAGE_SIZE - 3]), + page::private_input_page_count(&vec![0u8; page::DEFAULT_PAGE_SIZE - 3], &[]), 2 ); + // Hints extend the region: empty main + one hint = align8(4) + 8 + 32 = 48 bytes. + assert_eq!(page::private_input_page_count(&[], &[[0u8; 32]]), 1); + // The arena alone can push the span past a page boundary. + let hints = vec![[0u8; 32]; page::DEFAULT_PAGE_SIZE / 32]; + assert_eq!(page::private_input_page_count(&[], &hints), 2); } // `private_input_page_bases` must enumerate exactly the aligned bases that @@ -2416,7 +2437,7 @@ mod tests { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("all_loadstore_32"); let mut bundle = - prove_continuation(&elf_bytes, &[], 3, &ProofOptions::default_test_options()).unwrap(); + prove_continuation(&elf_bytes, &[], &[], 3, &ProofOptions::default_test_options()).unwrap(); bundle.num_private_input_pages = page::max_private_input_pages() + 1; assert!(matches!( verify_continuation(&elf_bytes, &bundle, &ProofOptions::default_test_options()), @@ -2446,7 +2467,7 @@ mod tests { input[commit_off..commit_off + 8].copy_from_slice(&expected); let bundle = - prove_continuation(&elf_bytes, &input, 4, &ProofOptions::default_test_options()) + prove_continuation(&elf_bytes, &input, &[], 4, &ProofOptions::default_test_options()) .unwrap(); assert!( bundle.num_private_input_pages >= 2, @@ -2478,7 +2499,7 @@ mod tests { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("all_loadstore_32"); let mut bundle = - prove_continuation(&elf_bytes, &[], 3, &ProofOptions::default_test_options()).unwrap(); + prove_continuation(&elf_bytes, &[], &[], 3, &ProofOptions::default_test_options()).unwrap(); assert!( !bundle.touched_page_bases.is_empty(), "baseline must have touched pages" @@ -2510,7 +2531,7 @@ mod tests { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("all_loadstore_32"); let mut bundle = - prove_continuation(&elf_bytes, &[], 3, &ProofOptions::default_test_options()).unwrap(); + prove_continuation(&elf_bytes, &[], &[], 3, &ProofOptions::default_test_options()).unwrap(); assert!( !bundle.touched_page_bases.is_empty(), "baseline must have touched pages" @@ -2540,7 +2561,7 @@ mod tests { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("all_loadstore_32"); let mut bundle = - prove_continuation(&elf_bytes, &[], 3, &ProofOptions::default_test_options()).unwrap(); + prove_continuation(&elf_bytes, &[], &[], 3, &ProofOptions::default_test_options()).unwrap(); assert!( !bundle.touched_page_bases.is_empty(), "baseline must have touched pages" @@ -2571,7 +2592,7 @@ mod tests { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("all_loadstore_32"); let mut bundle = - prove_continuation(&elf_bytes, &[], 3, &ProofOptions::default_test_options()).unwrap(); + prove_continuation(&elf_bytes, &[], &[], 3, &ProofOptions::default_test_options()).unwrap(); assert!( bundle.epochs.len() >= 2, "need multiple epochs to exercise the binding" @@ -2593,7 +2614,7 @@ mod tests { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("all_loadstore_32"); let mut bundle = - prove_continuation(&elf_bytes, &[], 3, &crate::recursion::MIN_PROOF_OPTIONS).unwrap(); + prove_continuation(&elf_bytes, &[], &[], 3, &crate::recursion::MIN_PROOF_OPTIONS).unwrap(); assert!( bundle.epochs.len() >= 2, "need multiple epochs to exercise the binding" @@ -2636,8 +2657,8 @@ mod tests { let input_a: Vec = (0u8..16).collect(); let input_b: Vec = (0u8..16).map(|b| b ^ 0xFF).collect(); - let mut bundle_a = prove_continuation(&elf_bytes, &input_a, 2, &opts).unwrap(); - let bundle_b = prove_continuation(&elf_bytes, &input_b, 2, &opts).unwrap(); + let mut bundle_a = prove_continuation(&elf_bytes, &input_a, &[], 2, &opts).unwrap(); + let bundle_b = prove_continuation(&elf_bytes, &input_b, &[], 2, &opts).unwrap(); assert!( verify_continuation(&elf_bytes, &bundle_a, &opts) .unwrap() @@ -2686,8 +2707,8 @@ mod tests { let input_a: Vec = (0u8..16).collect(); let input_b: Vec = (0u8..16).map(|b| b ^ 0xFF).collect(); - let mut bundle_a = prove_continuation(&elf_bytes, &input_a, 2, &opts).unwrap(); - let bundle_b = prove_continuation(&elf_bytes, &input_b, 2, &opts).unwrap(); + let mut bundle_a = prove_continuation(&elf_bytes, &input_a, &[], 2, &opts).unwrap(); + let bundle_b = prove_continuation(&elf_bytes, &input_b, &[], 2, &opts).unwrap(); assert_eq!(bundle_a.epochs.len(), bundle_b.epochs.len()); assert_eq!(bundle_a.touched_page_bases, bundle_b.touched_page_bases); assert_ne!(bundle_a.epochs[0].l2g_root, bundle_b.epochs[0].l2g_root); diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 79ef4c715..d9261b51e 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -53,7 +53,7 @@ use crate::tables::types::BusId; use crate::test_utils::{ E, F, VmAir, create_bitwise_air, create_branch_air, create_bytewise_air, create_commit_air, create_cpu_air, create_cpu32_air, create_decode_air, create_dvrm_air, create_ecdas_air, - create_ecsm_air, create_eq_air, create_halt_air, create_hint_air, create_keccak_air, + create_ecsm_air, create_eq_air, create_halt_air, create_keccak_air, create_keccak_rc_air, create_keccak_rnd_air, create_load_air, create_lt_air, create_memw_air, create_memw_aligned_air, create_memw_register_air, create_mul_air, create_page_air, create_register_air, create_shift_air, create_store_air, @@ -82,8 +82,8 @@ pub struct RuntimePageRange { /// Number of tables that always contribute exactly one sub-proof, regardless /// of `TableCounts`: bitwise, decode, halt, commit, keccak, keccak_rnd, -/// keccak_rc, register, ecsm, ecdas, hint. -pub const FIXED_TABLE_COUNT: usize = 11; +/// keccak_rc, register, ecsm, ecdas. +pub const FIXED_TABLE_COUNT: usize = 10; /// Number of chunks for each split table. /// The verifier needs this to reconstruct matching AIRs. @@ -522,7 +522,6 @@ pub(crate) struct VmAirs { pub keccak_rc: VmAir, pub ecsm: VmAir, pub ecdas: VmAir, - pub hint: VmAir, pub register: VmAir, pub pages: Vec, pub memw_registers: Vec, @@ -548,7 +547,6 @@ impl VmAirs { (self.keccak_rc.as_ref(), &mut traces.keccak_rc, &()), (self.ecsm.as_ref(), &mut traces.ecsm, &()), (self.ecdas.as_ref(), &mut traces.ecdas, &()), - (self.hint.as_ref(), &mut traces.hint, &()), (self.register.as_ref(), &mut traces.register, &()), ]; if self.include_halt { @@ -623,7 +621,6 @@ impl VmAirs { self.keccak_rc.as_ref(), self.ecsm.as_ref(), self.ecdas.as_ref(), - self.hint.as_ref(), self.register.as_ref(), ]; if self.include_halt { @@ -795,7 +792,6 @@ impl VmAirs { )); let ecsm: VmAir = Box::new(create_ecsm_air(proof_options)); let ecdas: VmAir = Box::new(create_ecdas_air(proof_options)); - let hint: VmAir = Box::new(create_hint_air(proof_options)); let register: VmAir = if let Some((commitment, num_preprocessed_cols)) = register_preprocessed { Box::new( @@ -916,7 +912,6 @@ impl VmAirs { keccak_rc, ecsm, ecdas, - hint, register, pages, memw_registers, @@ -1044,14 +1039,23 @@ pub(crate) fn verify_l2g_commitment_binding_view( /// Prove an ELF binary execution. Returns a serializable proof bundle. pub fn prove(elf_bytes: &[u8]) -> Result { - prove_with_inputs(elf_bytes, &[]) + prove_with_inputs(elf_bytes, &[], &[]) } /// Prove an ELF binary execution with private inputs. Returns a serializable proof bundle. -pub fn prove_with_inputs(elf_bytes: &[u8], private_inputs: &[u8]) -> Result { +/// +/// `hints` are untrusted 32-byte values appended to the private-input memory +/// region's hint arena; the guest reads them with ordinary loads and must +/// verify them in-circuit. +pub fn prove_with_inputs( + elf_bytes: &[u8], + private_inputs: &[u8], + hints: &[[u8; 32]], +) -> Result { prove_with_options_and_inputs( elf_bytes, private_inputs, + hints, &GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 is always valid"), &MaxRowsConfig::default(), ) @@ -1065,9 +1069,13 @@ pub fn prove_with_inputs(elf_bytes: &[u8], private_inputs: &[u8]) -> Result Result<(u64, u64), Error> { +pub fn count_elements( + elf_bytes: &[u8], + private_inputs: &[u8], + hints: &[[u8; 32]], +) -> Result<(u64, u64), Error> { let program = Elf::load(elf_bytes).map_err(|e| Error::ElfLoad(format!("{e}")))?; - let executor = Executor::new(&program, private_inputs.to_vec()) + let executor = Executor::new(&program, private_inputs.to_vec(), hints) .map_err(|e| Error::Execution(format!("{e}")))?; let result = executor .run() @@ -1077,6 +1085,7 @@ pub fn count_elements(elf_bytes: &[u8], private_inputs: &[u8]) -> Result<(u64, u &result.logs, &MaxRowsConfig::default(), private_inputs, + hints, #[cfg(feature = "disk-spill")] StorageMode::Ram, )?; @@ -1092,7 +1101,7 @@ pub fn prove_with_options( proof_options: &ProofOptions, max_rows: &MaxRowsConfig, ) -> Result { - prove_with_options_and_inputs(elf_bytes, &[], proof_options, max_rows) + prove_with_options_and_inputs(elf_bytes, &[], &[], proof_options, max_rows) } /// Prove an ELF binary execution with custom proof options, max rows config, @@ -1100,6 +1109,7 @@ pub fn prove_with_options( pub fn prove_with_options_and_inputs( elf_bytes: &[u8], private_inputs: &[u8], + hints: &[[u8; 32]], proof_options: &ProofOptions, max_rows: &MaxRowsConfig, ) -> Result { @@ -1119,7 +1129,7 @@ pub fn prove_with_options_and_inputs( let __sp = stark::instruments::span("execute"); let program = Elf::load(elf_bytes).map_err(|e| Error::ElfLoad(format!("{e}")))?; - let executor = Executor::new(&program, private_inputs.to_vec()) + let executor = Executor::new(&program, private_inputs.to_vec(), hints) .map_err(|e| Error::Execution(format!("{e}")))?; let result = executor .run() @@ -1140,7 +1150,7 @@ pub fn prove_with_options_and_inputs( #[cfg(feature = "disk-spill")] let storage_mode = { - let lengths = count_table_lengths(&program, &result.logs, max_rows, private_inputs)?; + let lengths = count_table_lengths(&program, &result.logs, max_rows, private_inputs, hints)?; auto_storage::decide(&lengths, proof_options.blowup_factor) }; @@ -1149,6 +1159,7 @@ pub fn prove_with_options_and_inputs( &result.logs, max_rows, private_inputs, + hints, #[cfg(feature = "disk-spill")] storage_mode, )?; diff --git a/prover/src/tables/cpu.rs b/prover/src/tables/cpu.rs index fc4c2f976..781bb02b0 100644 --- a/prover/src/tables/cpu.rs +++ b/prover/src/tables/cpu.rs @@ -188,11 +188,6 @@ pub struct CpuOperation { /// Whether this ECALL is an ECSM (elliptic-curve scalar multiply) syscall pub ecall_ecsm: bool, - - /// Whether this ECALL is a non-constraining Hint syscall. The hint operand - /// addresses (x10/x11/x12) are recovered from the register state in the trace - /// builder, exactly like ECSM. - pub ecall_hint: bool, } impl CpuOperation { @@ -240,8 +235,6 @@ impl CpuOperation { // in the trace builder. let ecall_ecsm = f.ecall && log.src1_val == executor::vm::instruction::execution::ECSM_SYSCALL_NUMBER; - let ecall_hint = - f.ecall && log.src1_val == executor::vm::instruction::execution::HINT_SYSCALL_NUMBER; // Word instructions are fully handled by CPU32; the main CPU row is a // delegate that only advances the PC and sends the CPU32 lookup. We still @@ -360,7 +353,6 @@ impl CpuOperation { ecall_keccak, keccak_state_addr, ecall_ecsm, - ecall_hint, } } diff --git a/prover/src/tables/hint.rs b/prover/src/tables/hint.rs deleted file mode 100644 index cb1dab9f3..000000000 --- a/prover/src/tables/hint.rs +++ /dev/null @@ -1,373 +0,0 @@ -//! HINT table — receiver for the non-constraining `hint` ecall. -//! -//! The `hint` ecall (syscall `u64::MAX - 30`) lets the executor hand the guest a -//! value that is expensive to compute but cheap to verify (modular inverse, sqrt, -//! …); the guest verifies it with ordinary constrained instructions. Unlike a -//! normal `STORE`, the ecall writes the 32-byte output to guest memory *directly* -//! (not through the CPU load/store decode), so those writes are invisible to the -//! CPU op stream — this table is what puts them into the memory argument. -//! -//! The table therefore does exactly four things, and constrains **nothing** about -//! *which* value was hinted (that is the point — soundness lives in the guest's -//! verify). It does constrain *where* the value lands and that it is 32 bytes: -//! -//! 1. **Receives** the `Hint` ecall on the `Ecall` bus (balances the CPU's send; -//! a syscall with no receiver leaves the LogUp argument unbalanced). -//! 2. **Reads `x12`** (`a2`) through the memory argument, which pins `out_addr` to -//! the value the CPU had in that register. The writes below take their base from -//! an ordinary trace column, so without this read that column is free and the -//! witness chooses *where* the 32 bytes land — an arbitrary memory write, which -//! is a strictly larger hole than the unconstrained value. -//! 3. **Sends** the four 8-byte MEMW writes of the output at `out_addr` +0/8/16/24 -//! (received by the MEMW table). Without these the output's initial→final -//! memory chain is unexplained and the memory argument fails to balance. -//! 4. **Range-checks** the 32 output cells as bytes (`AreBytes`). MEMW does not -//! range-check what it receives, so each table that writes fresh values into -//! memory checks its own cells; skipping it lets the witness put arbitrary field -//! elements where loads and the ALU expect bytes. -//! -//! The input read (the ecall also reads `in_addr`) is intentionally **not** modeled: -//! a read leaves the value unchanged, the guest supplies the input via ordinary -//! stores, and nothing depends on the ecall having re-read it — so omitting it is -//! sound and avoids the mixed-timestamp bookkeeping of a partial-buffer read. -//! -//! `mu` is constrained to a bit (`IS_BIT`, the table's only algebraic constraint) — -//! the same guard every other multiplicity-column table carries (ECSM/ECDAS/COMMIT/ -//! STORE/MEMW_R). The `Ecall` bus alone does not establish it: its tuple carries the -//! timestamp, a free column, so the LogUp identity pins only the *sum* of `mu` over -//! the rows sharing a `(ts, syscall)` tuple to the CPU's send — it does not rule out -//! a witness that spreads `mu` across rows with integer weights summing to 1 (a `+1` -//! row plus a `+1`/`-1` pair, each keeping its own `out_addr`, the base the four -//! output writes take). MEMW does NOT catch this: it only ever receives the legal -//! `+1`, while the `-1` cancels an honest STORE on the sender side, so MEMW's own -//! multiplicity constraints stay satisfied and nothing downstream rejects it. The -//! `IS_BIT` on `mu` here is therefore load-bearing -- not a redundant restatement of -//! a check some other table performs. -//! -//! ## Columns (41) -//! - `timestamp[0..1]` (DWordWL): the ecall timestamp `T` -//! - `out_addr[0..1]` (DWordWL): base address of the 32-byte output buffer -//! - `out_bytes[0..31]`: the 32 output bytes (the hint) — **unconstrained** -//! - `mu`: multiplicity flag (1 = real hint call, 0 = padding) — gates every bus -//! - `selector[0..1]` (DWordWL): `a0`, bound to `x10` and range-checked `< 3` -//! - `in_addr[0..1]` (DWordWL): `a1`, bound to `x11`; its low limb is range-checked -//! so the ecall's input range cannot straddle the 32-bit limb boundary -//! -//! Both address low limbs are range-checked against [`HINT_ADDR_LIMB_BOUND`]; see that -//! constant for why the memory bus alone does not bound `out_addr` tightly enough. - -use executor::vm::instruction::execution::HINT_SYSCALL_NUMBER; -use stark::constraints::builder::{ConstraintBuilder, ConstraintSet}; -use stark::lookup::{BusInteraction, BusValue, LinearTerm, Multiplicity, Packing}; -use stark::trace::TraceTable; - -use crate::constraints::templates::emit_is_bit; - -use super::types::{BusId, FE, GoldilocksExtension, GoldilocksField, VmTable, alu_op}; - -/// One past the largest valid hint selector (`a0 ∈ {0, 1, 2}` = FIELD_INV / SCALAR_INV / -/// FIELD_SQRT). Re-exported from the executor, which const-asserts that the bound and its -/// `is_valid_hint_selector` set coincide — so the AIR's range-check cannot drift from the -/// set the executor accepts. -pub use executor::vm::instruction::execution::HINT_SELECTOR_BOUND; - -/// Bound the low 32-bit limb of `in_addr` and `out_addr` must stay under so the -/// ecall's 32-byte range (`+0..+31`) cannot straddle the 2^32 limb boundary. Mirrors -/// the executor's `addr_limb_ok(addr, 31)`: `(addr % 2^32) + 31 < 2^32`, i.e. the -/// largest accepted limb is `2^32 - 32`. -/// -/// Both operands need this explicitly. `in_addr` because it is not on the memory bus -/// at all (the input read is not modelled). `out_addr` because the bus bounds it only -/// to `2^32 - 25`: the write bases are `out_addr_lo + 8i`, so the largest one -/// (`+24`) stops being a canonical limb at `2^32 - 24`, while MEMW's `carry` -/// columns resolve the *bytes* past it correctly. That left a seven-value window -/// (`2^32-31 ..= 2^32-25`) the AIR accepted and the executor rejected with -/// `HintAddressOverflow` — a prover could prove a hint call the VM halts on. -pub const HINT_ADDR_LIMB_BOUND: u64 = (1 << 32) - 31; - -pub mod cols { - /// timestamp[0]: lower 32 bits of the ecall timestamp - pub const TIMESTAMP_0: usize = 0; - /// timestamp[1]: upper 32 bits (always 0 — timestamps fit u32) - pub const TIMESTAMP_1: usize = 1; - /// out_addr[0]: lower 32 bits of the output base address - pub const ADDR_OUT_0: usize = 2; - /// out_addr[1]: upper 32 bits of the output base address - pub const ADDR_OUT_1: usize = 3; - /// out_bytes[0..31]: the 32 output bytes, one per column - pub const OUT: usize = 4; - /// multiplicity flag (1 = real hint call, 0 = padding) - pub const MU: usize = 36; - /// selector[0]: lower 32 bits of `a0` (the hint id) - pub const SEL_0: usize = 37; - /// selector[1]: upper 32 bits of `a0` - pub const SEL_1: usize = 38; - /// in_addr[0]: lower 32 bits of `a1` (the input base address) - pub const ADDR_IN_0: usize = 39; - /// in_addr[1]: upper 32 bits of `a1` - pub const ADDR_IN_1: usize = 40; - - pub const NUM_COLUMNS: usize = 41; - - /// Column of output byte `i` (0..32). - #[inline] - pub const fn out(i: usize) -> usize { - OUT + i - } -} - -/// One `hint` ecall: the timestamp, the output base address, and the 32 output -/// bytes the executor wrote to guest memory (recomputed by the trace builder). -#[derive(Debug, Clone)] -pub struct HintOperation { - pub timestamp: u64, - pub out_addr: u64, - pub out_bytes: [u8; 32], - /// `a0` — the hint selector, bound to `x10` and range-checked `< 3`. - pub hint_id: u64, - /// `a1` — the input base address, bound to `x11` and low-limb range-checked. - pub in_addr: u64, -} - -/// Generates the HINT trace: one row per hint-ecall call (in program order), -/// `mu = 1`; padding rows are all-zero (`mu = 0`, inert on the bus). Empty (all -/// padding) for programs that make no hint calls. -pub fn generate_hint_trace( - ops: &[HintOperation], -) -> TraceTable { - let num_rows = ops.len().next_power_of_two().max(4); - let mut trace = TraceTable::new_main( - crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), - cols::NUM_COLUMNS, - 1, - ); - let table = &mut trace.main_table; - - for (row, op) in ops.iter().enumerate() { - debug_assert!( - op.timestamp <= u32::MAX as u64, - "HINT timestamp {} exceeds u32", - op.timestamp - ); - table.set_dword_wl(row, cols::TIMESTAMP_0, op.timestamp); - table.set_dword_wl(row, cols::ADDR_OUT_0, op.out_addr); - table.set_bytes(row, cols::OUT, &op.out_bytes); - table.set_dword_wl(row, cols::SEL_0, op.hint_id); - table.set_dword_wl(row, cols::ADDR_IN_0, op.in_addr); - table.set_fe(row, cols::MU, FE::one()); - } - - trace -} - -// ========================================================================= -// Bus interactions -// ========================================================================= - -fn packed(col: usize) -> BusValue { - BusValue::Packed { - start_column: col, - packing: Packing::Direct, - } -} - -/// The eight output bytes of doubleword `chunk` (`out_bytes[8*chunk .. 8*chunk+7]`) -/// as MEMW value elements. -fn out_dword_bytes(chunk: usize) -> [BusValue; 8] { - std::array::from_fn(|b| packed(cols::out(8 * chunk + b))) -} - -/// A 16-element MEMW **write** tuple (CO25): `[is_register=0, base_lo, base_hi, -/// value[8], ts_lo, ts_hi, w2=0, w4=0, w8=1]`. The MEMW table supplies `old`. -fn memw_write(value: [BusValue; 8], base_lo: BusValue, base_hi: BusValue) -> Vec { - let mut v = Vec::with_capacity(16); - v.push(BusValue::constant(0)); // is_register = 0 (memory) - v.push(base_lo); - v.push(base_hi); - v.extend(value); - v.push(packed(cols::TIMESTAMP_0)); // ts_lo - v.push(packed(cols::TIMESTAMP_1)); // ts_hi - v.push(BusValue::constant(0)); // w2 - v.push(BusValue::constant(0)); // w4 - v.push(BusValue::constant(1)); // w8 = 1 (8-byte write) - v -} - -/// A 24-element MEMW **read** tuple (CO24) for a register: `[old[8], is_register=1, -/// base_lo=2*reg, base_hi=0, value[8], ts_lo, ts_hi, w2=1, w4=0, w8=0]`, with -/// `old == value` because a read leaves the register unchanged. Binds `x{reg}` to -/// the `(lo, hi)` column pair at the ecall timestamp. -fn memw_register_read(reg: u64, lo_col: usize, hi_col: usize) -> Vec { - let value = || [packed(lo_col), packed(hi_col)]; - let mut v = Vec::with_capacity(24); - v.extend(value()); // old[0..2] - v.extend(std::iter::repeat_n(BusValue::constant(0), 6)); // old[2..8] - v.push(BusValue::constant(1)); // is_register = 1 - v.push(BusValue::constant(2 * reg)); // base_address lo - v.push(BusValue::constant(0)); // base_address hi - v.extend(value()); // value[0..2] == old - v.extend(std::iter::repeat_n(BusValue::constant(0), 6)); // value[2..8] - v.push(packed(cols::TIMESTAMP_0)); - v.push(packed(cols::TIMESTAMP_1)); - v.push(BusValue::constant(1)); // w2 = 1 (register = 2 words) - v.push(BusValue::constant(0)); // w4 - v.push(BusValue::constant(0)); // w8 - v -} - -/// Bus interactions: -/// - **`Ecall` receiver** (mult `mu`): `[timestamp, cast(HINT_SYSCALL_NUMBER, -/// DWordWL)]` — HALT-shaped, balances the CPU's ECALL send. -/// - **MEMW register-read sender** (mult `mu`): binds `out_addr` to `x12`, the -/// ecall's `a2`. Without it the write addresses below are free columns, so a -/// witness could place the output bytes at any address it likes — an arbitrary -/// memory write, independent of whether the hinted *value* is constrained. -/// - **MEMW write senders** (mult `mu`, ×4): the four 8-byte writes of the output -/// at `out_addr` +0/8/16/24, timestamp `T`. Received by the MEMW table. -/// - **`AreBytes` senders** (mult `mu`, ×16): range-check the 32 output cells. -/// -/// - **MEMW register-read senders** (mult `mu`, ×2): bind `a0` (`x10`, the selector) -/// and `a1` (`x11`, the input address) to their register columns. -/// - **ALU `LT` senders** (mult `mu`, ×3): assert `selector < 3` and that both -/// `in_addr`'s and `out_addr`'s low limbs are `< 2^32 − 31`, matching the executor's -/// up-front rejections (`HintUnknownSelector`, `HintAddressOverflow`). Without them -/// the AIR would accept hints the executor rejects — a malicious prover could prove -/// an execution the VM would halt on. The value stays unconstrained (the guest -/// verifies it); this only pins the *operands* to the executor's accepted set. -pub fn bus_interactions() -> Vec { - let mu = || Multiplicity::Column(cols::MU); - let mut out = Vec::with_capacity(27); - - // ECALL receiver: [ts_lo, ts_hi, syscall_lo32, syscall_hi32]. - out.push(BusInteraction::receiver( - BusId::Ecall, - mu(), - vec![ - packed(cols::TIMESTAMP_0), - packed(cols::TIMESTAMP_1), - BusValue::constant(HINT_SYSCALL_NUMBER & 0xFFFF_FFFF), - BusValue::constant(HINT_SYSCALL_NUMBER >> 32), - ], - )); - - // Bind out_addr to x12 (a2): without this the write base below is a free column. - out.push(BusInteraction::sender( - BusId::Memw, - mu(), - memw_register_read(12, cols::ADDR_OUT_0, cols::ADDR_OUT_1), - )); - - // Bind a0 (x10 = selector) and a1 (x11 = in_addr). Without these the range-checks - // below would constrain free columns instead of the registers the CPU held. - out.push(BusInteraction::sender( - BusId::Memw, - mu(), - memw_register_read(10, cols::SEL_0, cols::SEL_1), - )); - out.push(BusInteraction::sender( - BusId::Memw, - mu(), - memw_register_read(11, cols::ADDR_IN_0, cols::ADDR_IN_1), - )); - - // ALU LT: selector < 3 (full 64-bit value), asserting the result is 1. A witness - // with an out-of-range selector has no matching LT row and unbalances the bus. - // ALU LT tuple (matching the LT table's receiver): `[lhs_lo, lhs_hi, rhs_lo, - // rhs_hi, op_encoding, result, 0]` — both operands are two elements (low, high - // 32-bit words), `op_encoding = LT` for an unsigned non-inverted compare, and - // `result = 1` asserts the strict inequality holds. - // - // selector < 3 (full 64-bit value: SEL_0/SEL_1). - out.push(BusInteraction::sender( - BusId::Alu, - mu(), - vec![ - BusValue::Packed { - start_column: cols::SEL_0, - packing: Packing::DWordWL, - }, - BusValue::constant(HINT_SELECTOR_BOUND), - BusValue::constant(0), - BusValue::constant(alu_op::LT as u64), - BusValue::constant(1), - BusValue::constant(0), - ], - )); - - // in_addr's and out_addr's low limbs < 2^32 - 31, matching addr_limb_ok(addr, 31). - // The lhs high word is a literal 0, so only the low limb is compared — exactly the - // executor's check, which ignores the high limb. `out_addr` needs its own check even - // though it is on the memory bus: the bus only bounds it to 2^32 - 25 (see - // HINT_ADDR_LIMB_BOUND), leaving a window the executor rejects. - for addr_lo in [cols::ADDR_IN_0, cols::ADDR_OUT_0] { - out.push(BusInteraction::sender( - BusId::Alu, - mu(), - vec![ - packed(addr_lo), - BusValue::constant(0), - BusValue::constant(HINT_ADDR_LIMB_BOUND), - BusValue::constant(0), - BusValue::constant(alu_op::LT as u64), - BusValue::constant(1), - BusValue::constant(0), - ], - )); - } - - // write output: 4 doublewords at out_addr + 8i (timestamp T). - for i in 0..4 { - let base_lo = BusValue::linear(vec![ - LinearTerm::Column { - coefficient: 1, - column: cols::ADDR_OUT_0, - }, - LinearTerm::Constant((8 * i) as i64), - ]); - out.push(BusInteraction::sender( - BusId::Memw, - mu(), - memw_write(out_dword_bytes(i), base_lo, packed(cols::ADDR_OUT_1)), - )); - } - - // ARE_BYTES[out_bytes[2i], out_bytes[2i+1]]: the output cells are free columns - // that enter memory as MEMW write values, and MEMW range-checks nothing it - // receives. Every other table that puts fresh values into memory (STORE, KECCAK, - // ECSM, PAGE) range-checks its own cells for this reason: the value is allowed to - // be *wrong* here, but it must still be 32 bytes, or the witness can smuggle - // arbitrary field elements into memory and break the byte decomposition that - // loads and the ALU depend on. 16 sends, pairing cells as ECSM/KECCAK do. - for i in 0..16 { - out.push(BusInteraction::sender( - BusId::AreBytes, - mu(), - vec![packed(cols::out(2 * i)), packed(cols::out(2 * i + 1))], - )); - } - - out -} - -// ========================================================================= -// Single-source constraint set (ConstraintBuilder front-end) -// ========================================================================= - -/// The HINT table's single transition constraint: `mu·(1−mu) = 0`. -/// -/// `mu` is the multiplicity gating every one of this table's bus interactions -/// (the `Ecall` receive, the three register reads, the three `LT` range-checks, the -/// four output writes, the 16 byte range-checks). It must be boolean, or a witness -/// could put a non-`{0,1}` value on the `AreBytes`/MEMW sends. This is load-bearing, -/// not a redundant restatement of a bus check: the `Ecall` bus pins only the *sum* -/// of `mu` over the rows sharing a tuple — see the module-level docs for the -/// spread-multiplicity witness it rules out. -#[derive(Clone, Copy)] -pub struct HintConstraints; - -impl ConstraintSet for HintConstraints { - fn eval>(&self, b: &mut B) { - // idx 0: IS_BIT for mu. - emit_is_bit(b, 0, cols::MU, None); - } -} diff --git a/prover/src/tables/mod.rs b/prover/src/tables/mod.rs index f1a899f56..0a86e4149 100644 --- a/prover/src/tables/mod.rs +++ b/prover/src/tables/mod.rs @@ -34,7 +34,6 @@ pub mod ecsm; pub mod eq; pub mod global_memory; pub mod halt; -pub mod hint; pub mod keccak; pub mod keccak_rc; pub mod keccak_rnd; diff --git a/prover/src/tables/page.rs b/prover/src/tables/page.rs index 6788bee08..a1784724a 100644 --- a/prover/src/tables/page.rs +++ b/prover/src/tables/page.rs @@ -170,10 +170,11 @@ impl PageConfig { // ========================================================================= /// Number of pages the private input occupies, starting at -/// `PRIVATE_INPUT_START_INDEX`. The wire format is the 4-byte length prefix plus -/// the data ([`Memory::store_private_inputs`]), and `PRIVATE_INPUT_START_INDEX` is -/// page-aligned, so the span is `ceil((prefix + len) / page_size)` consecutive -/// pages (0 when there is no input). +/// `PRIVATE_INPUT_START_INDEX`. The wire format is `[len:4][data][pad8]` plus the +/// always-written 8-byte hint-arena header and its 32-byte slots +/// ([`Memory::store_private_inputs`]), and `PRIVATE_INPUT_START_INDEX` is +/// page-aligned, so the span is `ceil((align8(4 + len) + 8 + 32 * hint_count) / +/// page_size)` consecutive pages (0 when there is neither input nor hints). /// /// SINGLE source of truth: the monolithic trace builder, the continuation prover, /// and both verifiers' classification all derive from this count — a divergence @@ -181,12 +182,15 @@ impl PageConfig { /// the other commits it, which is a soundness bug, so do not reimplement it. /// /// [`Memory::store_private_inputs`]: executor::vm::memory::Memory::store_private_inputs -pub(crate) fn private_input_page_count(private_inputs: &[u8]) -> usize { - use executor::vm::memory::PRIVATE_INPUT_LENGTH_PREFIX_BYTES; - if private_inputs.is_empty() { +pub(crate) fn private_input_page_count(private_inputs: &[u8], hints: &[[u8; 32]]) -> usize { + use executor::vm::memory::{HINT_ARENA_HEADER_BYTES, HINT_SLOT_BYTES, hint_arena_header_offset}; + if private_inputs.is_empty() && hints.is_empty() { return 0; } - (PRIVATE_INPUT_LENGTH_PREFIX_BYTES + private_inputs.len()).div_ceil(DEFAULT_PAGE_SIZE) + let extent = hint_arena_header_offset(private_inputs.len() as u64) + + HINT_ARENA_HEADER_BYTES + + hints.len() as u64 * HINT_SLOT_BYTES; + (extent as usize).div_ceil(DEFAULT_PAGE_SIZE) } /// Whether `page_base` is one of the first `num_private_input_pages` pages starting @@ -224,6 +228,11 @@ pub(crate) fn private_input_page_bases( /// a MAX-size input including its length prefix — no slack (an honest max-size /// input occupies exactly this many pages). Both the monolithic and continuation /// verifiers bound the deserialized, untrusted count with this before sizing AIRs. +/// +/// Still valid with the hint arena: `encode_private_input_region` caps the whole +/// two-section region (main section + always-written arena header + hint slots) +/// at `4 + MAX_PRIVATE_INPUT_SIZE` bytes, so no honest region can span more pages +/// than this bound. pub(crate) fn max_private_input_pages() -> usize { use executor::vm::memory::{MAX_PRIVATE_INPUT_SIZE, PRIVATE_INPUT_LENGTH_PREFIX_BYTES}; (MAX_PRIVATE_INPUT_SIZE as usize + PRIVATE_INPUT_LENGTH_PREFIX_BYTES) diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 29874caef..3dffde11f 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -51,7 +51,6 @@ use super::ecdas; use super::ecsm; use super::eq; use super::halt; -use super::hint; use super::keccak::{self, KeccakOperation}; use super::keccak_rc; use super::keccak_rnd::{self, KeccakRoundOperation}; @@ -550,7 +549,6 @@ fn collect_ops_from_cpu( Vec, Vec, Vec, - Vec, ) { let mut memw = MemwBuckets::with_register_capacity(cpu_ops.len() * 3); let mut load_ops = Vec::with_capacity(cpu_ops.len() / 8 + 1); @@ -562,7 +560,6 @@ fn collect_ops_from_cpu( let mut cpu32_ops = Vec::new(); let mut ecsm_ops = Vec::new(); let mut ecdas_ops = Vec::new(); - let mut hint_ops = Vec::new(); // Seed from the carried x254 (0 for a monolithic run or the first epoch) so a // continuation epoch indexes its commits globally, matching the x254 the // register binding transports across epochs. Resetting to 0 here would drift @@ -657,13 +654,6 @@ fn collect_ops_from_cpu( ecdas_ops.extend(ecdas_rows); } - // Collect Hint ecall operations (the 32-byte output write). - if op.ecall_hint { - let (hint_memw, hint_op) = collect_hint_ops(op, memory_state, register_state); - memw.extend_ops(hint_memw); - hint_ops.push(hint_op); - } - // --- ALU chip dispatch (no state tracking) --- // Word (`*W`) instructions are delegated to CPU32 (which itself drives // the ALU chips); the main CPU does not send the ALU bus for them, so we @@ -719,7 +709,6 @@ fn collect_ops_from_cpu( cpu32_ops, ecsm_ops, ecdas_ops, - hint_ops, ) } @@ -959,81 +948,6 @@ fn collect_ecsm_ops( (memw_ops, ecsm_op, ecdas_ops) } -/// Collects the memory operations for a `Hint` ecall. -/// -/// The `hint` ecall writes a 32-byte value (a modular inverse / sqrt) to guest -/// memory *directly* — bypassing the CPU load/store decode — so the trace builder -/// must reproduce that write itself: the value is not carried in the CPU log. We -/// re-derive the operand addresses from the register state (a0/a1/a2 = x10/x11/x12, -/// like ECSM), read the input from the replayed memory, recompute the output with -/// the executor's `compute_hint` (deterministic, same k256 arithmetic), then emit -/// four 8-byte MEMW writes at `out_addr` +0/8/16/24 and advance `memory_state`. -/// -/// The input read is intentionally not modeled (a read leaves the value unchanged; -/// the guest supplied the input via ordinary stores). The value itself is -/// unconstrained — soundness lives in the guest's in-circuit verify. -fn collect_hint_ops( - op: &CpuOperation, - memory_state: &mut MemoryState, - register_state: &mut RegisterState, -) -> (Vec, hint::HintOperation) { - let t = op.timestamp; - let hint_id = register_state.read(10).0; - let in_addr = register_state.read(11).0; - let out_addr = register_state.read(12).0; - - let mut memw_ops = Vec::with_capacity(7); - - // Bind a0/a1/a2 (x10/x11/x12) at ts through the memory argument. x12 ties the - // output-write base below to the ecall's a2; x10 (selector) and x11 (in_addr) pin - // the operands the HINT table range-checks against the executor's accepted set, so - // the AIR cannot prove a hint the executor would reject. All three are register - // reads (old == value; a read leaves the register unchanged). See `tables::hint`. - for (reg, value) in [(10u8, hint_id), (11, in_addr), (12, out_addr)] { - let reg_value = pack_register_value(value); - let (_old_val, old_ts) = register_state.read(reg); - memw_ops.push( - MemwOperation::new(true, 2 * reg as u64, reg_value, t, 2, true) - .with_old(reg_value, [old_ts, old_ts, 0, 0, 0, 0, 0, 0]), - ); - register_state.write(reg, value, t); - } - - // Read the 32-byte big-endian input from the replayed memory. - let mut input = [0u8; 32]; - for (i, b) in input.iter_mut().enumerate() { - *b = memory_state.read_byte(in_addr.wrapping_add(i as u64)).0; - } - - // Recompute the output exactly as the executor did (the value isn't in the log). - let out_bytes = executor::vm::instruction::execution::compute_hint(hint_id, &input); - - // Emit the 32-byte output as four 8-byte MEMW writes at ts = T. - for i in 0..4 { - let addr = out_addr.wrapping_add((8 * i) as u64); - let mut value = [0u32; 8]; - let mut dword = 0u64; - for j in 0..8 { - let byte = out_bytes[8 * i + j]; - value[j] = byte as u32; - dword |= (byte as u64) << (8 * j); - } - let (old_vals, old_ts) = memory_state.read_bytes(addr, 8); - memw_ops - .push(MemwOperation::new(false, addr, value, t, 8, false).with_old(old_vals, old_ts)); - memory_state.write_bytes(addr, dword, 8, t); - } - - let hint_op = hint::HintOperation { - timestamp: t, - out_addr, - out_bytes, - hint_id, - in_addr, - }; - (memw_ops, hint_op) -} - /// Collects register read/write operations (M1, M3, M5) from CpuOperation, /// pushing them into `memw_ops`. fn collect_register_ops_from_cpu( @@ -2090,21 +2004,27 @@ fn add_padding_byte_checks(hist: &mut bitwise::BitwiseHistogram, num_padding_row /// /// This must be called BEFORE bitwise multiplicities are updated. /// -/// Encode private input as `[len_u32_LE][data]` — the canonical wire format. -/// Must match `executor::vm::memory::Memory::store_private_inputs`. -fn private_input_bytes(private_input: &[u8]) -> Vec { - let len_bytes = (private_input.len() as u32).to_le_bytes(); - len_bytes - .iter() - .chain(private_input.iter()) - .copied() - .collect() +/// Encode the private-input region in the canonical wire format +/// (`[len][data][pad8][count][pad][slots]`) by delegating to the executor's +/// single source of truth, [`executor::vm::memory::encode_private_input_region`]. +/// +/// Panics if the region exceeds the reserved window. Inputs reaching this via the +/// prove path were already validated by `Memory::store_private_inputs` inside +/// `Executor::new`; a test calling the trace builder directly with an oversized +/// input gets a loud panic here instead of a silently truncated region. +fn private_input_bytes(private_input: &[u8], hints: &[[u8; 32]]) -> Vec { + executor::vm::memory::encode_private_input_region(private_input, hints) + .expect("private input plus hints exceed the reserved private-input window") } /// Build the initial-memory image (byte address -> value) from the ELF segments /// and the private-input region. Single source of "what memory starts as", read /// by both `MemoryState` seeding and PAGE/bitwise init. -pub(crate) fn build_initial_image(elf: &Elf, private_input: &[u8]) -> HashMap { +pub(crate) fn build_initial_image( + elf: &Elf, + private_input: &[u8], + hints: &[[u8; 32]], +) -> HashMap { let mut image: HashMap = HashMap::new(); for segment in &elf.data { for (i, &word) in segment.values.iter().enumerate() { @@ -2116,9 +2036,11 @@ pub(crate) fn build_initial_image(elf: &Elf, private_input: &[u8]) -> HashMap HashMap PagedMem { +pub(crate) fn build_initial_image_paged( + elf: &Elf, + private_input: &[u8], + hints: &[[u8; 32]], +) -> PagedMem { let mut image = PagedMem::new(0u8); for segment in &elf.data { for (i, &word) in segment.values.iter().enumerate() { @@ -2141,9 +2067,9 @@ pub(crate) fn build_initial_image_paged(elf: &Elf, private_input: &[u8]) -> Page } } } - if !private_input.is_empty() { + if !(private_input.is_empty() && hints.is_empty()) { use executor::vm::memory::PRIVATE_INPUT_START_INDEX; - for (i, &b) in private_input_bytes(private_input).iter().enumerate() { + for (i, &b) in private_input_bytes(private_input, hints).iter().enumerate() { image.set(PRIVATE_INPUT_START_INDEX + i as u64, b); } } @@ -2334,23 +2260,6 @@ fn collect_bitwise_from_commit(commit_ops: &[CommitOperation]) -> Vec Vec { - let mut lookups = Vec::with_capacity(16 * hint_ops.len()); - for op in hint_ops { - for i in 0..16 { - lookups.push(BitwiseOperation::byte_op( - BitwiseOperationType::AreBytes, - op.out_bytes[2 * i], - op.out_bytes[2 * i + 1], - )); - } - } - lookups -} - // ============================================================================= // BITWISE lookup helpers // ============================================================================= @@ -2688,6 +2597,7 @@ fn generate_page_tables( image: &I, memory_state: &MemoryState, private_input: &[u8], + hints: &[[u8; 32]], exclude_touched: bool, ) -> ( Vec>, @@ -2715,7 +2625,7 @@ fn generate_page_tables( // Determine which page bases hold private input data — count-based, via the // shared helpers (single source of truth with the continuation path). - let num_private_input_pages = page::private_input_page_count(private_input); + let num_private_input_pages = page::private_input_page_count(private_input, hints); for &page_base in &page_bases { let config = if page::is_private_input_page(page_base, num_private_input_pages) { @@ -2870,9 +2780,6 @@ pub struct Traces { /// ECDAS double/add table (variable rows per ecall) pub ecdas: TraceTable, - /// HINT table (one row per non-constraining hint ecall). - pub hint: TraceTable, - /// MEMW_R register-only fast-path traces (split into chunks of max_rows::MEMW_R) pub memw_registers: Vec>, /// Local-to-global boundary table for continuation epochs. Empty unless the @@ -2915,8 +2822,6 @@ struct CollectedOps { // EC scalar-multiplication accelerator chips. ecsm_ops: Vec, ecdas_ops: Vec, - // Non-constraining hint ecall. - hint_ops: Vec, } /// Chunk raw ops and generate one trace table per chunk. When `storage_mode` @@ -2971,7 +2876,6 @@ fn collect_all_ops( cpu32_ops: Vec, ecsm_ops: Vec, ecdas_ops: Vec, - hint_ops: Vec, register_state: &mut RegisterState, is_final: bool, ) -> CollectedOps { @@ -3114,7 +3018,6 @@ fn collect_all_ops( cpu32_ops, ecsm_ops, ecdas_ops, - hint_ops, } } @@ -3135,6 +3038,7 @@ fn build_traces( max_rows: &super::MaxRowsConfig, #[cfg(feature = "disk-spill")] storage_mode: StorageMode, private_input: &[u8], + hints: &[[u8; 32]], is_final: bool, l2g_memory_bookend: bool, ) -> Result { @@ -3158,7 +3062,6 @@ fn build_traces( cpu32_ops, ecsm_ops, ecdas_ops, - hint_ops, } = ops; // ===================================================================== @@ -3166,16 +3069,6 @@ fn build_traces( // ===================================================================== lt_ops.extend(collect_lt_from_memw(&memw_ops)); lt_ops.extend(collect_lt_from_memw_aligned(&memw_aligned_ops)); - // HINT range-checks: selector < 3 and both address low limbs < 2^32 - 31 (matching - // the executor's HintUnknownSelector / HintAddressOverflow rejections). Three LT ops - // per hint call; the HINT table sends the matching ALU LT interactions. - lt_ops.extend(hint_ops.iter().flat_map(|op| { - [ - LtOperation::new(op.hint_id, hint::HINT_SELECTOR_BOUND, false), - LtOperation::new(op.in_addr & 0xFFFF_FFFF, hint::HINT_ADDR_LIMB_BOUND, false), - LtOperation::new(op.out_addr & 0xFFFF_FFFF, hint::HINT_ADDR_LIMB_BOUND, false), - ] - })); // ===================================================================== // PHASE 4: All → Bitwise lookups @@ -3205,8 +3098,7 @@ fn build_traces( // chunk size used to split them into instances so multiplicities match the per-instance // sends. MEMW_R sends IS_HALFWORD[timestamp_0 - old_timestamp_lo - 1]. PAGE does a // batched ARE_BYTES[init, fini] per row (skipped in continuation epochs, which the L2G - // table owns). COMMIT sends AreBytes+IsHalfword; KECCAK_RND sends XOR/AND/ARE_BYTES/HWSL; - // HINT sends ARE_BYTES for its 32 output cells. + // table owns). COMMIT sends AreBytes+IsHalfword; KECCAK_RND sends XOR/AND/ARE_BYTES/HWSL. // We never concatenate the lookups into one giant `Vec` (~140 M ops / // ~560 MB at 10-tx whose only consumer is the multiplicity count). Each collector bumps // the `BitwiseHistogram` it is handed: the heavy sources (MEMW_R one-per-row, PAGE @@ -3245,7 +3137,6 @@ fn build_traces( Box::new(|h| h.add_ops(&collect_bitwise_from_keccak(&keccak_ops))), Box::new(|h| h.add_ops(&collect_bitwise_from_ecsm(&ecsm_ops))), Box::new(|h| h.add_ops(&collect_bitwise_from_ecdas(&ecdas_ops))), - Box::new(|h| h.add_ops(&collect_bitwise_from_hint(&hint_ops))), Box::new(|h| add_padding_byte_checks(h, num_padding_rows)), ]; if let Some(image) = initial_image @@ -3523,7 +3414,7 @@ fn build_traces( // every touched cell's Memory init/fini, and every untouched PAGE row // self-cancels (init==fini, ts=0), so PAGE contributes nothing here. Some(image) if !l2g_memory_bookend => { - generate_page_tables(image, memory_state, private_input, l2g_memory_bookend) + generate_page_tables(image, memory_state, private_input, hints, l2g_memory_bookend) } _ => (Vec::new(), Vec::new()), }; @@ -3532,8 +3423,6 @@ fn build_traces( // ECSM accelerator traces (empty/all-padding for programs that do not use ECSM). let gen_ecsm = || ecsm::generate_ecsm_trace(&ecsm_ops); let gen_ecdas = || ecdas::generate_ecdas_trace(&ecdas_ops); - // HINT table (all-padding for programs that make no hint ecalls). - let gen_hint = || hint::generate_hint_trace(&hint_ops); let (mut cpus_slot, mut memws_slot, mut memw_aligneds_slot, mut memw_registers_slot) = (None, None, None, None); @@ -3546,7 +3435,6 @@ fn build_traces( let (mut eqs_slot, mut bytewises_slot, mut stores_slot, mut cpu32s_slot) = (None, None, None, None); let (mut ecsm_slot, mut ecdas_slot) = (None, None); - let mut hint_slot = None; #[cfg(feature = "disk-spill")] let sequential = storage_mode == StorageMode::Disk || cfg!(not(feature = "parallel")); @@ -3588,7 +3476,6 @@ fn build_traces( spawn_into!(cpu32s_slot, gen_cpu32s); spawn_into!(ecsm_slot, gen_ecsm); spawn_into!(ecdas_slot, gen_ecdas); - spawn_into!(hint_slot, gen_hint); }); } else { cpus_slot = Some(gen_cpus()); @@ -3616,7 +3503,6 @@ fn build_traces( cpu32s_slot = Some(gen_cpu32s()); ecsm_slot = Some(gen_ecsm()); ecdas_slot = Some(gen_ecdas()); - hint_slot = Some(gen_hint()); } const PHASE5_RAN: &str = "phase 5 generation ran in one of the branches above"; @@ -3651,7 +3537,6 @@ fn build_traces( let mut halt_trace = halt_slot.expect(PHASE5_RAN); let ecsm_trace = ecsm_slot.expect(PHASE5_RAN); let ecdas_trace = ecdas_slot.expect(PHASE5_RAN); - let hint_trace = hint_slot.expect(PHASE5_RAN); // Fixed-size and per-page tables aren't built through `chunk_and_generate`, // so spill them here before returning. @@ -3719,7 +3604,6 @@ fn build_traces( keccak_rc: keccak_rc_trace, ecsm: ecsm_trace, ecdas: ecdas_trace, - hint: hint_trace, memw_registers, local_to_global, touched_memory_cells, @@ -3778,6 +3662,7 @@ pub fn count_table_lengths( logs: &[Log], max_rows: &super::MaxRowsConfig, private_input: &[u8], + hints: &[[u8; 32]], ) -> Result { // Phase 0: ELF → instructions + DECODE row count. let instructions = decode::instructions_from_elf(elf) @@ -3786,7 +3671,8 @@ pub fn count_table_lengths( let decode_rows = (instructions.len() as u64 + 1).next_power_of_two().max(2); // Memory + register state for partition predicates that need timestamps. - let mut memory_state = MemoryState::from_image(&build_initial_image(elf, private_input)); + let mut memory_state = + MemoryState::from_image(&build_initial_image(elf, private_input, hints)); let mut register_state = RegisterState::new(elf.entry_point); // Raw counts (pre-chunking + pre-padding). @@ -3893,25 +3779,6 @@ pub fn count_table_lengths( .ok_or_else(|| Error::Execution("commit index exceeds u32 range".into()))?; } - if cpu_op.ecall_hint { - // Mirror `collect_hint_ops`: three register reads (a0/a1/a2) and four - // 8-byte output writes go through the memory argument, plus the three LT - // range-checks (selector < 3, in_addr and out_addr low limbs). Replaying it - // here keeps memory/register state in sync with generation, exactly like - // commit above. - let (hint_memw, _hint_op) = - collect_hint_ops(&cpu_op, &mut memory_state, &mut register_state); - for memw_op in &hint_memw { - partition_memw( - memw_op, - &mut memw_by_width, - &mut memw_aligned_count, - &mut memw_register_count, - ); - } - lt_count += 3; - } - // CPU-side per-instruction-kind counters (non-word; word → CPU32, B5b) let f = &cpu_op.decode.fields; if !f.word_instr && f.is_lt() { @@ -4001,7 +3868,6 @@ impl Traces { use super::ecsm::cols::NUM_COLUMNS as ECSM_COLS; use super::eq::cols::NUM_COLUMNS as EQ_COLS; use super::halt::cols::NUM_COLUMNS as HALT_COLS; - use super::hint::cols::NUM_COLUMNS as HINT_COLS; use super::keccak::cols::NUM_COLUMNS as KECCAK_COLS; use super::keccak_rc::NUM_PRECOMPUTED_COLS as KECCAK_RC_PRECOMPUTED; use super::keccak_rc::cols::NUM_COLUMNS as KECCAK_RC_COLS; @@ -4040,7 +3906,6 @@ impl Traces { keccak_rc, ecsm, ecdas, - hint, memw_registers, eqs, bytewises, @@ -4108,7 +3973,6 @@ impl Traces { } total += (ecsm.num_rows() * ECSM_COLS) as u64; total += (ecdas.num_rows() * ECDAS_COLS) as u64; - total += (hint.num_rows() * HINT_COLS) as u64; total } @@ -4150,7 +4014,6 @@ impl Traces { let n_cpu32 = aux_cols(super::cpu32::bus_interactions().len()); let n_ecsm = aux_cols(super::ecsm::bus_interactions().len()); let n_ecdas = aux_cols(super::ecdas::bus_interactions().len()); - let n_hint = aux_cols(super::hint::bus_interactions().len()); let Traces { cpus, @@ -4173,7 +4036,6 @@ impl Traces { keccak_rc, ecsm, ecdas, - hint, memw_registers, eqs, bytewises, @@ -4241,7 +4103,6 @@ impl Traces { } total += (ecsm.num_rows() * n_ecsm) as u64; total += (ecdas.num_rows() * n_ecdas) as u64; - total += (hint.num_rows() * n_hint) as u64; total } @@ -4273,7 +4134,7 @@ impl Traces { pub fn page_configs_from_elf(elf: &Elf) -> Vec { use std::collections::BTreeSet; - let init_page_data = build_init_page_data(&build_initial_image(elf, &[])); + let init_page_data = build_init_page_data(&build_initial_image(elf, &[], &[])); let page_bases: BTreeSet = init_page_data.keys().copied().collect(); @@ -4460,9 +4321,10 @@ impl Traces { logs: &[Log], max_rows: &super::MaxRowsConfig, private_input: &[u8], + hints: &[[u8; 32]], #[cfg(feature = "disk-spill")] storage_mode: StorageMode, ) -> Result { - let initial_image = build_initial_image(elf, private_input); + let initial_image = build_initial_image(elf, private_input, hints); let register_init = register::register_init_from_entry_point(elf.entry_point); Self::from_image_and_logs( elf, @@ -4471,6 +4333,7 @@ impl Traces { logs, max_rows, private_input, + hints, true, false, #[cfg(feature = "disk-spill")] @@ -4498,6 +4361,7 @@ impl Traces { logs: &[Log], max_rows: &super::MaxRowsConfig, private_input: &[u8], + hints: &[[u8; 32]], is_final: bool, l2g_memory_bookend: bool, #[cfg(feature = "disk-spill")] storage_mode: StorageMode, @@ -4510,6 +4374,7 @@ impl Traces { logs, max_rows, private_input, + hints, is_final, l2g_memory_bookend, #[cfg(feature = "disk-spill")] @@ -4531,6 +4396,7 @@ impl Traces { logs: &[Log], max_rows: &super::MaxRowsConfig, private_input: &[u8], + hints: &[[u8; 32]], is_final: bool, l2g_memory_bookend: bool, #[cfg(feature = "disk-spill")] storage_mode: StorageMode, @@ -4544,6 +4410,7 @@ impl Traces { register_init, max_rows, private_input, + hints, is_final, l2g_memory_bookend, #[cfg(feature = "disk-spill")] @@ -4595,7 +4462,6 @@ impl Traces { cpu32_ops, ecsm_ops, ecdas_ops, - hint_ops, ) = collect_ops_from_cpu(&cpu_ops, &mut memory_state, &mut register_state); #[cfg(feature = "instruments")] drop(__sp); @@ -4614,7 +4480,6 @@ impl Traces { cpu32_ops, ecsm_ops, ecdas_ops, - hint_ops, &mut register_state, is_final, ); @@ -4642,6 +4507,7 @@ impl Traces { register_init: &[u32], max_rows: &super::MaxRowsConfig, private_input: &[u8], + hints: &[[u8; 32]], is_final: bool, l2g_memory_bookend: bool, #[cfg(feature = "disk-spill")] storage_mode: StorageMode, @@ -4670,6 +4536,7 @@ impl Traces { #[cfg(feature = "disk-spill")] storage_mode, private_input, + hints, is_final, l2g_memory_bookend, ); @@ -4708,7 +4575,6 @@ impl Traces { cpu32_ops, ecsm_ops, ecdas_ops, - hint_ops, ) = collect_ops_from_cpu(&cpu_ops, &mut memory_state, &mut register_state); let ops = collect_all_ops( @@ -4723,7 +4589,6 @@ impl Traces { cpu32_ops, ecsm_ops, ecdas_ops, - hint_ops, &mut register_state, true, ); @@ -4744,6 +4609,7 @@ impl Traces { #[cfg(feature = "disk-spill")] StorageMode::Ram, &[], + &[], true, false, ) diff --git a/prover/src/test_utils.rs b/prover/src/test_utils.rs index d6a8b8608..c0426a538 100644 --- a/prover/src/test_utils.rs +++ b/prover/src/test_utils.rs @@ -66,9 +66,6 @@ use crate::tables::ecsm::{ }; use crate::tables::eq::{EqConstraints, bus_interactions as eq_bus_interactions, cols as eq_cols}; use crate::tables::halt::{bus_interactions as halt_bus_interactions, cols as halt_cols}; -use crate::tables::hint::{ - HintConstraints, bus_interactions as hint_bus_interactions, cols as hint_cols, -}; use crate::tables::keccak::{ KeccakConstraints, bus_interactions as keccak_bus_interactions, cols as keccak_cols, }; @@ -246,7 +243,7 @@ pub fn asm_elf_bytes(name: &str) -> Vec { pub fn run_asm_elf(name: &str) -> (Elf, Vec, U64HashMap) { let elf_data = asm_elf_bytes(name); let elf = Elf::load(&elf_data).expect("Failed to load ELF"); - let executor = Executor::new(&elf, vec![]).expect("Failed to create executor"); + let executor = Executor::new(&elf, vec![], &[]).expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); (elf, result.logs, result.instructions) } @@ -897,21 +894,6 @@ pub fn create_halt_air(proof_options: &ProofOptions) -> ConcreteVmAir ConcreteVmAir { - build_air( - hint_cols::NUM_COLUMNS, - hint_bus_interactions(), - proof_options, - 1, - HintConstraints, - "HINT", - ) -} - /// Create COMMIT AIR with constraints and bus interactions. pub fn create_commit_air(proof_options: &ProofOptions) -> ConcreteVmAir { build_air( diff --git a/prover/src/tests/constraint_program_device_tests.rs b/prover/src/tests/constraint_program_device_tests.rs index a29a7cb49..a2863b2f0 100644 --- a/prover/src/tests/constraint_program_device_tests.rs +++ b/prover/src/tests/constraint_program_device_tests.rs @@ -181,5 +181,4 @@ fn all_table_programs_lower_and_match_folders() { check_air_device(&create_keccak_rc_air(&opts), "KECCAK_RC"); check_air_device(&create_ecsm_air(&opts), "ECSM"); check_air_device(&create_ecdas_air(&opts), "ECDAS"); - check_air_device(&create_hint_air(&opts), "HINT"); } diff --git a/prover/src/tests/constraint_program_tests.rs b/prover/src/tests/constraint_program_tests.rs index e227da53d..3ae46494d 100644 --- a/prover/src/tests/constraint_program_tests.rs +++ b/prover/src/tests/constraint_program_tests.rs @@ -179,5 +179,4 @@ fn all_table_programs_match_folders() { check_air(&create_keccak_rc_air(&opts), "KECCAK_RC"); check_air(&create_ecsm_air(&opts), "ECSM"); check_air(&create_ecdas_air(&opts), "ECDAS"); - check_air(&create_hint_air(&opts), "HINT"); } diff --git a/prover/src/tests/constraint_set_tests_b.rs b/prover/src/tests/constraint_set_tests_b.rs index a7f68ecfd..0348c2b70 100644 --- a/prover/src/tests/constraint_set_tests_b.rs +++ b/prover/src/tests/constraint_set_tests_b.rs @@ -299,19 +299,3 @@ mod cpu { check_table("cpu", &CpuConstraints, cols::NUM_COLUMNS); } } - -// ============================================================================= -// hint.rs -// ============================================================================= - -mod hint { - use super::*; - use crate::tables::hint::{HintConstraints, cols}; - - #[test] - fn hint_constraint_set_folder_capture_agree() { - // The one constraint is IS_BIT(mu): a single dense, idx-0, base-field root. - assert_eq!(HintConstraints.meta().len(), 1); - check_table("hint", &HintConstraints, cols::NUM_COLUMNS); - } -} diff --git a/prover/src/tests/count_table_lengths_drift_tests.rs b/prover/src/tests/count_table_lengths_drift_tests.rs index 7337f0790..4fad8cdae 100644 --- a/prover/src/tests/count_table_lengths_drift_tests.rs +++ b/prover/src/tests/count_table_lengths_drift_tests.rs @@ -4,16 +4,15 @@ use crate::tables::MaxRowsConfig; use crate::tables::trace_builder::{Traces, count_table_lengths}; use crate::test_utils::run_asm_elf; use executor::elf::Elf; -use executor::vm::execution::Executor; use executor::vm::logs::Log; fn assert_count_table_lengths_matches(elf: &Elf, logs: &[Log]) { let max_rows = MaxRowsConfig::default(); let predicted = - count_table_lengths(elf, logs, &max_rows, &[]).expect("count_table_lengths succeeds"); + count_table_lengths(elf, logs, &max_rows, &[], &[]).expect("count_table_lengths succeeds"); let traces = - Traces::from_elf_and_logs_minimal(elf, logs, &max_rows, &[]).expect("trace build succeeds"); + Traces::from_elf_and_logs_minimal(elf, logs, &max_rows, &[], &[]).expect("trace build succeeds"); let sum_heights = |tables: &[stark::trace::TraceTable<_, _>]| -> u64 { tables.iter().map(|t| t.main_table.height as u64).sum() @@ -98,31 +97,3 @@ fn count_table_lengths_matches_traces() { let (elf, logs, _) = run_asm_elf("fib_iterative_372k"); assert_count_table_lengths_matches(&elf, &logs); } - -/// The `hint` ecall routes three register reads (`a0`/`a1`/`a2`) and four output -/// writes through the memory argument, plus two LT range-checks (selector, in_addr). -/// `count_table_lengths` must replay all of that exactly, or `memw_register` (an -/// exact-match table) drifts. Uses a real hint guest so the counts are non-trivial. -#[test] -fn count_table_lengths_matches_nonempty_hint_trace() { - let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("workspace root") - .to_path_buf(); - let elf_bytes = - std::fs::read(workspace_root.join("executor/program_artifacts/rust/hint_min.elf")) - .expect("hint_min.elf not found — run `make compile-programs-rust`"); - let elf = Elf::load(&elf_bytes).expect("valid hint guest ELF"); - let result = Executor::new(&elf, vec![]) - .expect("executor") - .run() - .expect("hint guest execution"); - - assert!( - result.logs.iter().any(|log| { - log.src1_val == executor::vm::instruction::execution::HINT_SYSCALL_NUMBER - }), - "fixture must contain a hint ecall" - ); - assert_count_table_lengths_matches(&elf, &result.logs); -} diff --git a/prover/src/tests/hint_tests.rs b/prover/src/tests/hint_tests.rs deleted file mode 100644 index 479e7b001..000000000 --- a/prover/src/tests/hint_tests.rs +++ /dev/null @@ -1,171 +0,0 @@ -//! HINT constraint tests. - -use crate::tables::hint::{ - HINT_ADDR_LIMB_BOUND, HintConstraints, HintOperation, bus_interactions, cols, - generate_hint_trace, -}; -use crate::tables::types::{BusId, FE, GoldilocksExtension, GoldilocksField}; -use math::field::element::FieldElement; -use stark::constraints::builder::{ConstraintSet, ProverEvalFolder}; -use stark::frame::Frame; -use stark::lookup::{BusValue, LinearTerm}; -use stark::table::TableView; -use stark::traits::TransitionEvaluationContext; - -/// Evaluate the HINT constraint set on one main-trace row. -fn eval_main_row(main: Vec) -> Vec { - let n = HintConstraints.meta().len(); - let frame = Frame::::new(vec![TableView::new( - vec![main], - vec![vec![]], - )]); - let no_e: Vec> = vec![]; - let offset_e = FieldElement::::zero(); - let ctx = - TransitionEvaluationContext::new_prover(frame.as_row_frame(), &no_e, &no_e, &offset_e); - let mut base = vec![FE::zero(); n]; - let mut ext = vec![FieldElement::::zero(); n]; - let mut folder = ProverEvalFolder::new(&ctx, &mut base, &mut ext); - HintConstraints.eval(&mut folder); - base -} - -fn op(timestamp: u64, out_addr: u64) -> HintOperation { - HintOperation { - timestamp, - out_addr, - out_bytes: std::array::from_fn(|i| i as u8), - hint_id: 0, - in_addr: 0x3000, - } -} - -#[test] -fn constraint_set_count() { - assert_eq!(HintConstraints.meta().len(), 1); -} - -/// Every constraint holds on a generated trace — real rows (`mu = 1`) and the -/// all-zero padding rows (`mu = 0`) alike. -#[test] -fn constraints_hold_on_generated_trace() { - let trace = generate_hint_trace(&[op(4, 0x1000), op(8, 0x2000)]); - for row in 0..trace.num_rows() { - let main: Vec = (0..cols::NUM_COLUMNS) - .map(|c| *trace.main_table.get(row, c)) - .collect(); - for (i, v) in eval_main_row(main).iter().enumerate() { - assert_eq!(*v, FE::zero(), "constraint {i} must hold at row {row}"); - } - } -} - -/// `IS_BIT(mu)` rejects a row whose multiplicity is not a bit. -/// -/// The `Ecall` bus does not establish this on its own: its tuple carries a -/// per-instruction timestamp, so LogUp pins the *sum* of `mu` over the rows sharing a -/// tuple, which a witness can satisfy by spreading `mu` across rows with integer -/// weights summing to 1 (the real exploit uses a `+1`/`-1` pair, not a fractional -/// split; MEMW does not catch it — it only sees the legal `+1`, the `-1` cancelling an -/// honest STORE). This constraint rejects any non-boolean `mu` locally. The test below -/// tampers with a fractional `1/2`, which `IS_BIT` also rejects. -#[test] -fn is_bit_mu_rejects_non_boolean_multiplicity() { - let trace = generate_hint_trace(&[op(4, 0x1000)]); - let mut main: Vec = (0..cols::NUM_COLUMNS) - .map(|c| *trace.main_table.get(0, c)) - .collect(); - assert_eq!(main[cols::MU], FE::one(), "row 0 must be a real hint row"); - - // A halved multiplicity: 1/2 + 1/2 across two rows keeps the Ecall bus balanced. - let half = (FE::one() / (FE::one() + FE::one())).expect("2 is invertible"); - main[cols::MU] = half; - assert_ne!( - eval_main_row(main.clone())[0], - FE::zero(), - "IS_BIT(mu) must reject a fractional multiplicity" - ); - - // And any other non-bit value. - main[cols::MU] = FE::from(2u64); - assert_ne!( - eval_main_row(main)[0], - FE::zero(), - "IS_BIT(mu) must reject mu = 2" - ); -} - -/// The lhs column of an ALU `LT` sender, and the constant it is compared against. -fn alu_lt_senders() -> Vec<(usize, u64)> { - let id: u64 = BusId::Alu.into(); - bus_interactions() - .iter() - .filter(|i| i.is_sender && i.bus_id == id) - .map(|i| { - let lhs = match &i.values[0] { - BusValue::Packed { start_column, .. } => *start_column, - BusValue::Linear(_) => panic!("LT lhs must be a column, not a constant"), - }; - let bound = match &i.values[2] { - BusValue::Linear(terms) => match terms.as_slice() { - [LinearTerm::Constant(c)] => *c as u64, - _ => panic!("LT rhs must be a single constant"), - }, - BusValue::Packed { .. } => panic!("LT rhs must be a constant"), - }; - (lhs, bound) - }) - .collect() -} - -/// Both address low limbs are range-checked, not just `in_addr`. -/// -/// `out_addr` is on the memory bus, which is why it originally had no LT sender — but the -/// bus bounds it only to `2^32 - 25` (the largest write base is `out_addr_lo + 24`, and -/// MEMW's carry columns resolve the bytes past it), while the executor rejects anything -/// above `2^32 - 32`. Without this sender the AIR accepted the seven-value window in -/// [`addr_limb_bound_rejects_every_operand_the_executor_rejects`]. -#[test] -fn alu_lt_senders_range_check_selector_and_both_address_limbs() { - let senders = alu_lt_senders(); - assert_eq!(senders.len(), 3, "selector + in_addr + out_addr"); - - for col in [cols::ADDR_IN_0, cols::ADDR_OUT_0] { - let bound = senders - .iter() - .find_map(|(lhs, bound)| (*lhs == col).then_some(*bound)) - .unwrap_or_else(|| panic!("column {col} must have an ALU LT range-check")); - assert_eq!( - bound, HINT_ADDR_LIMB_BOUND, - "column {col} must be checked against the executor's bound" - ); - } -} - -/// The bound accepts exactly the operands `addr_limb_ok(addr, 31)` accepts. -/// -/// The seven values in `2^32-31 ..= 2^32-25` are the regression: the executor rejects -/// them with `HintAddressOverflow`, and before the `out_addr` sender existed the AIR -/// accepted them for the output address — a provable hint call the VM halts on. -#[test] -fn addr_limb_bound_rejects_every_operand_the_executor_rejects() { - // `addr_limb_ok(addr, 31)`: the 32-byte range must fit under 2^32. - let executor_accepts = |limb: u64| limb + 31 < (1 << 32); - // The AIR accepts iff the LT range-check passes. - let air_accepts = |limb: u64| limb < HINT_ADDR_LIMB_BOUND; - - for limb in (1u64 << 32) - 40..1u64 << 32 { - assert_eq!( - air_accepts(limb), - executor_accepts(limb), - "AIR and executor disagree on out_addr low limb {limb:#x}" - ); - } - - // The window that used to verify while the executor halted on it. - for limb in (1u64 << 32) - 31..=(1u64 << 32) - 25 { - assert!(!air_accepts(limb), "{limb:#x} must be rejected"); - } - // And the largest operand that must still run. - assert!(air_accepts((1 << 32) - 32)); -} diff --git a/prover/src/tests/mod.rs b/prover/src/tests/mod.rs index 9288cf2ac..2730a9d98 100644 --- a/prover/src/tests/mod.rs +++ b/prover/src/tests/mod.rs @@ -47,8 +47,6 @@ pub mod ecsm_tests; #[cfg(test)] pub mod eq_tests; #[cfg(test)] -pub mod hint_tests; -#[cfg(test)] pub mod ir_stats_dump; #[cfg(test)] pub mod keccak_rnd_tests; diff --git a/prover/src/tests/ood_window_ir_tests.rs b/prover/src/tests/ood_window_ir_tests.rs index 29d224627..b4ff5766c 100644 --- a/prover/src/tests/ood_window_ir_tests.rs +++ b/prover/src/tests/ood_window_ir_tests.rs @@ -114,5 +114,4 @@ fn all_table_windows_match_captured_ir() { assert_ood_window_matches_ir(&create_keccak_rc_air(&opts), true, "KECCAK_RC"); assert_ood_window_matches_ir(&create_ecsm_air(&opts), true, "ECSM"); assert_ood_window_matches_ir(&create_ecdas_air(&opts), true, "ECDAS"); - assert_ood_window_matches_ir(&create_hint_air(&opts), true, "HINT"); } diff --git a/prover/src/tests/page_offset_forgery_poc.rs b/prover/src/tests/page_offset_forgery_poc.rs index 5e2e24d78..492d5271b 100644 --- a/prover/src/tests/page_offset_forgery_poc.rs +++ b/prover/src/tests/page_offset_forgery_poc.rs @@ -147,7 +147,7 @@ fn craft_proof( // Execution logs come from whatever `run_elf` is. let run_program = Elf::load(run_elf).expect("run ELF load"); let executor = - Executor::new(&run_program, private_inputs.to_vec()).expect("executor construction"); + Executor::new(&run_program, private_inputs.to_vec(), &[]).expect("executor construction"); let result = executor.run().expect("run"); let max_rows = MaxRowsConfig::default(); @@ -156,6 +156,7 @@ fn craft_proof( &result.logs, &max_rows, private_inputs, + &[], #[cfg(feature = "disk-spill")] stark::storage_mode::StorageMode::Ram, ) @@ -541,7 +542,7 @@ fn poc_real_ethrex_inputs_produce_private_input_pages() { let Ok(bytes) = std::fs::read(&path) else { continue; // fixture not present in this checkout }; - let pages = private_input_page_count(&bytes); + let pages = private_input_page_count(&bytes, &[]); println!( "{name}: {} bytes -> {pages} private-input page(s) = {} free-OFFSET rows", bytes.len(), @@ -633,7 +634,7 @@ fn craft_proof_with_duplicate_page( let options = opts(); let program = Elf::load(honest_elf).expect("honest ELF load"); let run_program = Elf::load(run_elf).expect("run ELF load"); - let executor = Executor::new(&run_program, vec![]).expect("executor construction"); + let executor = Executor::new(&run_program, vec![], &[]).expect("executor construction"); let result = executor.run().expect("run"); let max_rows = MaxRowsConfig::default(); @@ -642,6 +643,7 @@ fn craft_proof_with_duplicate_page( &result.logs, &max_rows, &[], + &[], #[cfg(feature = "disk-spill")] stark::storage_mode::StorageMode::Ram, ) diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index bbc8d2c63..1b80d7b63 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -105,13 +105,19 @@ fn prove_and_verify_vm_minimal(elf: &Elf, traces: &mut Traces) -> bool { /// /// Same unsoundness caveats as [`Traces::from_elf_and_logs_minimal`]. The full /// preprocessed bitwise path is covered by `test_prove_elfs_all_instructions_64_full`. -fn prove_vm_minimal(elf_bytes: &[u8], private_inputs: &[u8], max_rows: &MaxRowsConfig) -> VmProof { +fn prove_vm_minimal( + elf_bytes: &[u8], + private_inputs: &[u8], + hints: &[[u8; 32]], + max_rows: &MaxRowsConfig, +) -> VmProof { let proof_options = ProofOptions::default_test_options(); let elf = Elf::load(elf_bytes).expect("ELF load"); - let executor = Executor::new(&elf, private_inputs.to_vec()).expect("executor"); + let executor = Executor::new(&elf, private_inputs.to_vec(), hints).expect("executor"); let result = executor.run().expect("execution"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, max_rows, private_inputs).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &result.logs, max_rows, private_inputs, hints) + .unwrap(); let table_counts = traces.table_counts(); let airs = VmAirs::new( &elf, @@ -274,7 +280,7 @@ fn test_prove_elfs_sub_fast() { let (elf, logs, _instructions) = run_asm_elf("sub"); // Use from_elf_and_logs_minimal to get PAGE and REGISTER tables for Memory bus let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[], &[]).unwrap(); assert!( prove_and_verify_vm_minimal(&elf, &mut traces), @@ -618,7 +624,7 @@ fn test_prove_elfs_sign_ext_edge_cases_8() { fn test_prove_elfs_misalign_lh() { let (elf, logs, _instructions) = run_asm_elf("misalign_lh"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[], &[]).unwrap(); assert!( prove_and_verify_vm_minimal(&elf, &mut traces), "misalign_lh failed" @@ -629,7 +635,7 @@ fn test_prove_elfs_misalign_lh() { fn test_prove_elfs_misalign_lhu() { let (elf, logs, _instructions) = run_asm_elf("misalign_lhu"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[], &[]).unwrap(); assert!( prove_and_verify_vm_minimal(&elf, &mut traces), "misalign_lhu failed" @@ -640,7 +646,7 @@ fn test_prove_elfs_misalign_lhu() { fn test_prove_elfs_misalign_lw() { let (elf, logs, _instructions) = run_asm_elf("misalign_lw"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[], &[]).unwrap(); assert!( prove_and_verify_vm_minimal(&elf, &mut traces), "misalign_lw failed" @@ -651,7 +657,7 @@ fn test_prove_elfs_misalign_lw() { fn test_prove_elfs_misalign_lwu() { let (elf, logs, _instructions) = run_asm_elf("misalign_lwu"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[], &[]).unwrap(); assert!( prove_and_verify_vm_minimal(&elf, &mut traces), "misalign_lwu failed" @@ -662,7 +668,7 @@ fn test_prove_elfs_misalign_lwu() { fn test_prove_elfs_misalign_ld() { let (elf, logs, _instructions) = run_asm_elf("misalign_ld"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[], &[]).unwrap(); assert!( prove_and_verify_vm_minimal(&elf, &mut traces), "misalign_ld failed" @@ -673,7 +679,7 @@ fn test_prove_elfs_misalign_ld() { fn test_prove_elfs_misalign_sh() { let (elf, logs, _instructions) = run_asm_elf("misalign_sh"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[], &[]).unwrap(); assert!( prove_and_verify_vm_minimal(&elf, &mut traces), "misalign_sh failed" @@ -684,7 +690,7 @@ fn test_prove_elfs_misalign_sh() { fn test_prove_elfs_misalign_sw() { let (elf, logs, _instructions) = run_asm_elf("misalign_sw"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[], &[]).unwrap(); assert!( prove_and_verify_vm_minimal(&elf, &mut traces), "misalign_sw failed" @@ -695,7 +701,7 @@ fn test_prove_elfs_misalign_sw() { fn test_prove_elfs_misalign_sd() { let (elf, logs, _instructions) = run_asm_elf("misalign_sd"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[], &[]).unwrap(); assert!( prove_and_verify_vm_minimal(&elf, &mut traces), "misalign_sd failed" @@ -925,7 +931,7 @@ fn test_prove_elfs_test_xor_8() { fn test_prove_elfs_test_lb_lh_8() { let (elf, logs, _instructions) = run_asm_elf("test_lb_lh_8"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[], &[]).unwrap(); assert!( prove_and_verify_vm_minimal(&elf, &mut traces), "test_lb_lh_8 failed" @@ -936,7 +942,7 @@ fn test_prove_elfs_test_lb_lh_8() { fn test_prove_elfs_test_sb_sh_8() { let (elf, logs, _instructions) = run_asm_elf("test_sb_sh_8"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[], &[]).unwrap(); assert!( !traces.memws.is_empty(), "test_sb_sh_8 should produce MEMW rows for byte/halfword memory accesses" @@ -954,7 +960,7 @@ fn test_prove_elfs_test_sb_sh_8() { fn test_prove_elfs_lw_sw() { let (elf, logs, _instructions) = run_asm_elf("lw_sw"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[], &[]).unwrap(); assert!( !traces.memw_aligneds.is_empty(), "lw_sw should produce MEMW_A rows for aligned word accesses" @@ -975,7 +981,7 @@ fn test_prove_elfs_lw_sw() { fn test_prove_elfs_test_memw_split_ts() { let (elf, logs, _instructions) = run_asm_elf("test_memw_split_ts"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[], &[]).unwrap(); assert!( !traces.memws.is_empty(), "test_memw_split_ts should produce MEMW rows (split old_timestamps from sb+sb+lh)" @@ -1015,7 +1021,7 @@ fn test_prove_elfs_all_branches_16() { fn test_prove_elfs_all_loadstore_32() { let (elf, logs, _instructions) = run_asm_elf("all_loadstore_32"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[], &[]).unwrap(); assert!( prove_and_verify_vm_minimal(&elf, &mut traces), "all_loadstore_32 failed" @@ -1056,7 +1062,7 @@ fn test_prove_elfs_keccak() { // Must use from_elf_and_logs (not from_logs_minimal) because keccak accesses // RAM (stack memory), which requires PAGE tables for Memory bus balance. let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[], &[]).unwrap(); assert!( prove_and_verify_vm_minimal(&elf, &mut traces), @@ -1071,7 +1077,7 @@ fn test_prove_elfs_keccak_multi_call() { let elf_bytes = crate::test_utils::asm_elf_bytes("test_keccak_multi"); let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); let executor = - executor::vm::execution::Executor::new(&elf, vec![]).expect("Failed to create executor"); + executor::vm::execution::Executor::new(&elf, vec![], &[]).expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); // The guest initializes lane[i] = i + 1 and applies keccak-f[1600] three times. @@ -1092,7 +1098,7 @@ fn test_prove_elfs_keccak_multi_call() { ); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]).unwrap(); assert_eq!( traces.public_output_bytes, result.return_values.memory_values @@ -1111,7 +1117,7 @@ fn test_prove_elfs_ecsm() { let elf_bytes = crate::test_utils::asm_elf_bytes("test_ecsm"); let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); let executor = - executor::vm::execution::Executor::new(&elf, vec![]).expect("Failed to create executor"); + executor::vm::execution::Executor::new(&elf, vec![], &[]).expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); // The guest computes 5·G and commits the 32-byte x-coordinate; cross-check it against @@ -1132,7 +1138,7 @@ fn test_prove_elfs_ecsm() { ); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]).unwrap(); assert!( prove_and_verify_vm_minimal(&elf, &mut traces), "ECSM prove/verify failed" @@ -1146,7 +1152,7 @@ fn test_prove_elfs_ecsm_multi() { let elf_bytes = crate::test_utils::asm_elf_bytes("test_ecsm_multi"); let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); let executor = - executor::vm::execution::Executor::new(&elf, vec![]).expect("Failed to create executor"); + executor::vm::execution::Executor::new(&elf, vec![], &[]).expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); // Gx little-endian. @@ -1171,7 +1177,7 @@ fn test_prove_elfs_ecsm_multi() { ); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]).unwrap(); assert!( prove_and_verify_vm_minimal(&elf, &mut traces), "ECSM multi-call prove/verify failed" @@ -1191,7 +1197,7 @@ fn test_prove_ecsm_rust_guest() { let elf_bytes = std::fs::read(workspace_root.join("executor/program_artifacts/rust/ecsm.elf")) .expect("ecsm.elf not found — run `make compile-programs-rust`"); - let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); + let proof = prove_vm_minimal(&elf_bytes, &[], &[], &Default::default()); assert!( verify_vm_minimal(&proof, &elf_bytes), "ecsm rust guest should verify" @@ -1212,15 +1218,14 @@ fn test_prove_ecsm_rust_guest() { ); } -/// End-to-end prove→verify for the non-constraining `Hint` ecall: the minimal Rust -/// guest does one `hint` call (secp256k1 base-field inverse of 3) and commits the result. -/// This exercises the whole HINT table bus surface (Ecall receive, the x10/x11/x12 -/// register reads, the two ALU `LT` operand range-checks, the four 8-byte output MEMW -/// writes and the output byte range-checks) end-to-end through prove→verify, de-risking -/// the bus balance before scaling to real consumers. The committed output must equal -/// the value the executor's `compute_hint` produced (= 3^{-1} mod p). +/// End-to-end prove→verify for the hint arena: three hint values (one per +/// selector — secp256k1 base-field inverse of 3, scalar inverse of 5, square +/// root of 4) supplied as private-input hint-arena slots. The guest reads them +/// with ordinary aligned loads (MEMR reads chained to the private-input pages) — +/// the hint mechanism is just more private-input bytes. Committed output = XOR +/// of the three slots. #[test] -fn test_prove_hint_min_rust_guest() { +fn test_prove_hint_arena_rust_guest() { let _ = env_logger::builder().is_test(true).try_init(); let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -1228,316 +1233,44 @@ fn test_prove_hint_min_rust_guest() { .expect("workspace root") .to_path_buf(); let elf_bytes = - std::fs::read(workspace_root.join("executor/program_artifacts/rust/hint_min.elf")) - .expect("hint_min.elf not found — run `make compile-programs-rust`"); + std::fs::read(workspace_root.join("executor/program_artifacts/rust/hint_arena.elf")) + .expect("hint_arena.elf not found — run `make compile-programs-rust`"); - let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); - assert!( - verify_vm_minimal(&proof, &elf_bytes), - "hint_min rust guest should verify" - ); - - // Committed output must equal the hinted value (field inverse of 3, 32-byte BE). - let mut input = [0u8; 32]; - input[31] = 3; - let expected = - executor::vm::instruction::execution::compute_hint(0 /* HINT_FIELD_INV */, &input); - assert_eq!(proof.public_output, expected.to_vec()); -} - -/// Multi-hint: three `hint` ecalls, one per selector, each result read back with -/// ordinary `LOAD`s. Complements `test_prove_hint_min_rust_guest` by proving the -/// paths the ethrex consumer relies on that a single-call guest doesn't: **multiple -/// real HINT rows** (padded), **all three selectors** (so the AIR's `selector < 3` -/// range-check is exercised at every accepted value, not only at 0) and **read-back -/// via normal LOAD** (MEMW reads chaining to the HINT writes). Committed output = -/// XOR of the three hinted values. -#[test] -fn test_prove_hint_multi_rust_guest() { - let _ = env_logger::builder().is_test(true).try_init(); - - let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("workspace root") - .to_path_buf(); - let elf_bytes = - std::fs::read(workspace_root.join("executor/program_artifacts/rust/hint_multi.elf")) - .expect("hint_multi.elf not found — run `make compile-programs-rust`"); - - let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); - assert!( - verify_vm_minimal(&proof, &elf_bytes), - "hint_multi rust guest should verify" - ); - - // Expected = XOR of inv(3) mod p, inv(5) mod n and sqrt(4) mod p (32-byte BE), - // matching the guest's one-call-per-selector loop. + // The host computes the three hints up front (the known-beforehand case) and + // passes them as arena slots, in the guest's request order. use executor::vm::instruction::execution::{ HINT_FIELD_INV, HINT_FIELD_SQRT, HINT_SCALAR_INV, compute_hint, }; - let mut expected = [0u8; 32]; - for (hint_id, seed) in [ + let hints: Vec<[u8; 32]> = [ (HINT_FIELD_INV, 3u8), (HINT_SCALAR_INV, 5u8), (HINT_FIELD_SQRT, 4u8), - ] { + ] + .into_iter() + .map(|(hint_id, seed)| { let mut input = [0u8; 32]; input[31] = seed; - let out = compute_hint(hint_id, &input); - for i in 0..32 { - expected[i] ^= out[i]; - } - } - assert_eq!(proof.public_output, expected.to_vec()); -} - -/// Consistency: the verifier REJECTS a HINT row that disagrees with the -/// MEMW rows. -/// -/// The HINT table's `out_bytes` are unconstrained *by the table* — the point of a -/// non-constraining hint. Editing one output byte on the (single) real HINT row makes -/// the MEMW write it sends stop matching the write the MEMW table received (the honest -/// value `collect_hint_ops` derived), so the Memw LogUp bus unbalances and the proof -/// must fail to verify. -/// -/// What this covers is an *internally inconsistent* trace — the failure mode of a buggy -/// trace builder. It is **not** a forgery test: a prover that edits the HINT row and the -/// corresponding MEMW rows together satisfies every constraint, because nothing in the -/// AIR pins *which* value was hinted. That guarantee lives in the guest's verify -/// (`x·inv == 1`, `y² == x³+7`), which this minimal guest deliberately omits. What the -/// AIR does pin is *where* the value lands and that it is 32 bytes — see -/// `test_hint_binds_out_addr_to_x12` and `test_hint_range_checks_its_output_bytes`. -#[test] -fn test_prove_hint_min_inconsistent_output_rejected() { - use crate::tables::hint::cols as hint_cols; + compute_hint(hint_id, &input) + }) + .collect(); - let _ = env_logger::builder().is_test(true).try_init(); - - let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("workspace root") - .to_path_buf(); - let elf_bytes = - std::fs::read(workspace_root.join("executor/program_artifacts/rust/hint_min.elf")) - .expect("hint_min.elf not found — run `make compile-programs-rust`"); - let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); - let executor = Executor::new(&elf, vec![]).expect("Failed to create executor"); - let result = executor.run().expect("Failed to run program"); - let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); - - // Forge the low byte of the output on the (single) real HINT row. - let orig = *traces.hint.main_table.get(0, hint_cols::out(0)); - let forged = orig + FieldElement::::one(); - traces.hint.main_table.set(0, hint_cols::out(0), forged); - - assert!( - !prove_and_verify_vm_minimal(&elf, &mut traces), - "Verifier must reject a forged hint output byte" - ); -} - -/// Load `hint_min` and build its minimal traces (for the operand-forgery tests below). -fn hint_min_traces() -> (Elf, Traces) { - let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("workspace root") - .to_path_buf(); - let elf_bytes = - std::fs::read(workspace_root.join("executor/program_artifacts/rust/hint_min.elf")) - .expect("hint_min.elf not found — run `make compile-programs-rust`"); - let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); - let result = Executor::new(&elf, vec![]) - .expect("Failed to create executor") - .run() - .expect("Failed to run program"); - let traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); - (elf, traces) -} - -/// Soundness: the verifier REJECTS a HINT row whose selector is out of range. -/// -/// The executor rejects `hint_id ∉ {0,1,2}` up front (`HintUnknownSelector`). The AIR -/// now matches that: it binds the selector to `x10` and range-checks it `< 3`, so a -/// witness cannot prove a hint the executor would reject. Before `a0` was bound this -/// forgery verified. Forcing the selector to 3 (one past the valid set) unbalances both -/// the `x10` register read and the `LT(selector, 3)` interaction. -#[test] -fn test_prove_hint_min_forged_selector_rejected() { - use crate::tables::hint::cols as hint_cols; - let (elf, mut traces) = hint_min_traces(); - traces.hint.main_table.set( - 0, - hint_cols::SEL_0, - FieldElement::::from(3u64), - ); + let proof = prove_vm_minimal(&elf_bytes, &[], &hints, &Default::default()); assert!( - !prove_and_verify_vm_minimal(&elf, &mut traces), - "Verifier must reject a hint with an out-of-range selector" - ); -} - -/// Soundness: the verifier REJECTS a HINT row whose input address would straddle the -/// 32-bit limb boundary — the executor rejects it (`HintAddressOverflow`), and the AIR -/// now binds `in_addr` to `x11` and range-checks its low limb `< 2^32 - 31`. Forcing -/// the low limb to `2^32 - 1` unbalances the `x11` read and the `LT` interaction. -#[test] -fn test_prove_hint_min_forged_input_address_rejected() { - use crate::tables::hint::cols as hint_cols; - let (elf, mut traces) = hint_min_traces(); - traces.hint.main_table.set( - 0, - hint_cols::ADDR_IN_0, - FieldElement::::from(0xFFFF_FFFFu64), - ); - assert!( - !prove_and_verify_vm_minimal(&elf, &mut traces), - "Verifier must reject a hint whose input range crosses the limb boundary" - ); -} - -/// Column a bus value reads, for the structural HINT tests below. -fn hint_bus_column(v: &stark::lookup::BusValue) -> Option { - match v { - stark::lookup::BusValue::Packed { start_column, .. } => Some(*start_column), - stark::lookup::BusValue::Linear(_) => None, - } -} - -/// Constant a bus value holds, for the structural HINT tests below. -fn hint_bus_constant(v: &stark::lookup::BusValue) -> Option { - match v { - stark::lookup::BusValue::Linear(terms) => match terms.as_slice() { - [stark::lookup::LinearTerm::Constant(c)] => Some(*c), - _ => None, - }, - stark::lookup::BusValue::Packed { .. } => None, - } -} - -/// Soundness: the HINT table must bind its output address to `x12` (the ecall's `a2`). -/// -/// The four output writes take their base from `ADDR_OUT_0`, an ordinary column in a -/// table with no algebraic constraints, so the register read asserted here is the only -/// thing pinning that column to the register the CPU actually held. Without it the -/// witness chooses *where* the 32 hinted bytes land — an arbitrary memory write, which -/// is a strictly larger hole than the unconstrained value the table is designed around. -/// -/// Asserted structurally rather than by tampering: editing `ADDR_OUT_0` in a trace also -/// unbalances the honest MEMW rows, so a tamper test passes either way and would not -/// notice this interaction being dropped. -#[test] -fn test_hint_binds_out_addr_to_x12() { - use crate::tables::hint::{bus_interactions, cols as hint_cols}; - use crate::tables::types::BusId; - use stark::lookup::Multiplicity; - - let memw_id = u64::from(BusId::Memw); - let reads: Vec<_> = bus_interactions() - .into_iter() - .filter(|i| i.bus_id == memw_id && i.is_sender && i.values.len() == 24) - .collect(); - assert_eq!( - reads.len(), - 3, - "HINT must send three MEMW register reads (a0 → x10, a1 → x11, a2 → x12)" - ); - // The out_addr binding is the x12 read (base address 2*12); the a0/a1 reads bind - // the selector and input address, checked by the range-check interactions. - let out_read = reads - .iter() - .find(|r| hint_bus_constant(&r.values[9]) == Some(2 * 12)) - .expect("HINT must send a MEMW register read for x12 (out_addr)"); - let v = &out_read.values; - - // CO24 read layout: old[8], is_register, base_lo, base_hi, value[8], ts_lo, ts_hi, - // w2, w4, w8. - assert_eq!(hint_bus_constant(&v[8]), Some(1), "is_register must be 1"); - assert_eq!( - hint_bus_constant(&v[9]), - Some(2 * 12), - "register address must be x12 (the ecall's a2)" - ); - assert_eq!(hint_bus_constant(&v[10]), Some(0), "address hi must be 0"); - assert_eq!( - hint_bus_constant(&v[21]), - Some(1), - "w2 must be 1 for a 2-word register access" - ); - for (slot, col) in [(0, hint_cols::ADDR_OUT_0), (1, hint_cols::ADDR_OUT_1)] { - assert_eq!( - hint_bus_column(&v[slot]), - Some(col), - "old[{slot}] must carry out_addr" - ); - assert_eq!( - hint_bus_column(&v[11 + slot]), - Some(col), - "value[{slot}] must carry out_addr (a read leaves the register unchanged)" - ); - } - // The read must happen at THE ecall's timestamp (ts_lo/ts_hi = slots 19/20). A - // register read bound to x12 but at some other timestamp would pin out_addr to - // whatever x12 held then, not at the ecall — the writes below all use the same - // TIMESTAMP columns, so the binding is only meaningful if it reads x12 at T. - assert_eq!( - hint_bus_column(&v[19]), - Some(hint_cols::TIMESTAMP_0), - "ts_lo must be the ecall timestamp (the read must occur at T)" - ); - assert_eq!( - hint_bus_column(&v[20]), - Some(hint_cols::TIMESTAMP_1), - "ts_hi must be the ecall timestamp (the read must occur at T)" - ); - assert!( - matches!(out_read.multiplicity, Multiplicity::Column(c) if c == hint_cols::MU), - "the register read must be gated by mu, like every other HINT interaction" + verify_vm_minimal(&proof, &elf_bytes), + "hint_arena rust guest should verify" ); -} -/// Soundness: the HINT table must range-check all 32 output cells as bytes. -/// -/// The cells are free columns that enter memory as MEMW write values, and MEMW -/// range-checks nothing it receives — every table that writes fresh values into memory -/// (STORE, KECCAK, ECSM, PAGE) checks its own cells for that reason. The hinted value is -/// allowed to be wrong; it is not allowed to be a field element outside `[0, 256)`, or -/// the witness can smuggle non-bytes into memory and break the byte decomposition that -/// loads and the ALU rely on. -#[test] -fn test_hint_range_checks_its_output_bytes() { - use crate::tables::hint::{bus_interactions, cols as hint_cols}; - use crate::tables::types::BusId; - use stark::lookup::Multiplicity; - - let are_bytes_id = u64::from(BusId::AreBytes); - let checks: Vec<_> = bus_interactions() - .into_iter() - .filter(|i| i.bus_id == are_bytes_id) - .collect(); - assert_eq!(checks.len(), 16, "32 output cells, paired two per lookup"); + // Empty main input + 3 slots: the region spans align8(4) + 8 + 96 = 112 bytes, + // one private-input page. + assert_eq!(proof.num_private_input_pages, 1); - let mut covered = std::collections::BTreeSet::new(); - for check in &checks { - assert!(check.is_sender, "range checks are sends; BITWISE receives"); - assert_eq!(check.values.len(), 2, "ARE_BYTES takes exactly two values"); - assert!( - matches!(check.multiplicity, Multiplicity::Column(c) if c == hint_cols::MU), - "range checks must be gated by mu, or padding rows unbalance BITWISE" - ); - for v in &check.values { - covered - .insert(hint_bus_column(v).expect("a range check must reference an output column")); + let mut expected = [0u8; 32]; + for hint in &hints { + for i in 0..32 { + expected[i] ^= hint[i]; } } - - // 16 lookups × 2 slots = 32 slots; 32 distinct columns means each cell exactly once. - let expected: std::collections::BTreeSet = (0..32).map(hint_cols::out).collect(); - assert_eq!( - covered, expected, - "every output cell must be range-checked exactly once" - ); + assert_eq!(proof.public_output, expected.to_vec()); } /// Soundness: the verifier REJECTS a forged ECSM result. @@ -1556,10 +1289,10 @@ fn test_prove_elfs_ecsm_forged_result_rejected() { let elf_bytes = crate::test_utils::asm_elf_bytes("test_ecsm"); let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); let executor = - executor::vm::execution::Executor::new(&elf, vec![]).expect("Failed to create executor"); + executor::vm::execution::Executor::new(&elf, vec![], &[]).expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]).unwrap(); // Forge the low byte of xR on the (single) real ECSM row. let orig = *traces.ecsm.main_table.get(0, ecsm_cols::xr(0)); @@ -1584,10 +1317,10 @@ fn test_prove_elfs_ecsm_forged_ecdas_mu_rejected() { let elf_bytes = crate::test_utils::asm_elf_bytes("test_ecsm"); let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); let executor = - executor::vm::execution::Executor::new(&elf, vec![]).expect("Failed to create executor"); + executor::vm::execution::Executor::new(&elf, vec![], &[]).expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]).unwrap(); // Row 0 is a real ECDAS step (µ=1); forge µ to a non-boolean value. traces.ecdas.main_table.set( @@ -1620,10 +1353,10 @@ fn test_prove_elfs_keccak_unaligned_state_addr() { let elf_bytes = crate::test_utils::asm_elf_bytes("test_keccak_multi"); let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); let executor = - executor::vm::execution::Executor::new(&elf, vec![]).expect("Failed to create executor"); + executor::vm::execution::Executor::new(&elf, vec![], &[]).expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]).unwrap(); // Tamper the first real keccak row: replace addr(1) (a byte cell) with a // value outside [0, 256). The new ARE_BYTES bus sender will emit this @@ -1646,7 +1379,7 @@ fn test_prove_elfs_test_commit_4() { let elf_bytes = crate::test_utils::asm_elf_bytes("test_commit_4"); let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); let executor = - executor::vm::execution::Executor::new(&elf, vec![]).expect("Failed to create executor"); + executor::vm::execution::Executor::new(&elf, vec![], &[]).expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); // Verify public output matches the committed bytes [0xAA, 0xBB, 0xCC, 0xDD] @@ -1657,7 +1390,7 @@ fn test_prove_elfs_test_commit_4() { ); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]).unwrap(); assert_eq!( traces.public_output_bytes, result.return_values.memory_values @@ -1680,10 +1413,10 @@ fn test_prove_elfs_test_commit_4_wrong_pages_rejected() { let proof_options = ProofOptions::default_test_options(); let executor = - executor::vm::execution::Executor::new(&elf, vec![]).expect("Failed to create executor"); + executor::vm::execution::Executor::new(&elf, vec![], &[]).expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]).unwrap(); // Prover uses correct page configs let table_counts = traces.table_counts(); @@ -2080,6 +1813,7 @@ fn test_debug_memory_tokens_sb_sh() { &logs, &Default::default(), &[], + &[], #[cfg(feature = "disk-spill")] stark::storage_mode::StorageMode::Ram, ) @@ -2415,7 +2149,7 @@ fn test_debug_memory_tokens_sb_sh() { fn test_deep_stack_passes() { let (elf, logs, _instructions) = run_asm_elf("deep_stack"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[], &[]).unwrap(); assert!( prove_and_verify_vm_minimal(&elf, &mut traces), @@ -2434,10 +2168,10 @@ fn test_deep_stack_runtime_pages_roundtrip() { let proof_options = ProofOptions::default_test_options(); let executor = - executor::vm::execution::Executor::new(&elf, vec![]).expect("Failed to create executor"); + executor::vm::execution::Executor::new(&elf, vec![], &[]).expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]).unwrap(); let runtime_page_ranges = traces.runtime_page_ranges(); let table_counts = traces.table_counts(); @@ -2516,10 +2250,10 @@ fn test_deep_stack_missing_pages_rejected() { let proof_options = ProofOptions::default_test_options(); let executor = - executor::vm::execution::Executor::new(&elf, vec![]).expect("Failed to create executor"); + executor::vm::execution::Executor::new(&elf, vec![], &[]).expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]).unwrap(); // Prover uses correct page configs (auto-detected from MemoryState) let table_counts = traces.table_counts(); @@ -2592,7 +2326,7 @@ fn test_deep_stack_missing_pages_rejected() { fn test_heap_alloc_passes() { let (elf, logs, _instructions) = run_asm_elf("heap_alloc"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[], &[]).unwrap(); // Verify runtime_page_ranges includes the heap page let ranges = traces.runtime_page_ranges(); @@ -2618,10 +2352,10 @@ fn test_heap_alloc_runtime_pages_roundtrip() { let proof_options = ProofOptions::default_test_options(); let executor = - executor::vm::execution::Executor::new(&elf, vec![]).expect("Failed to create executor"); + executor::vm::execution::Executor::new(&elf, vec![], &[]).expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]).unwrap(); let runtime_page_ranges = traces.runtime_page_ranges(); let table_counts = traces.table_counts(); @@ -2878,7 +2612,7 @@ fn test_small_max_rows_splits_tables() { let elf_bytes = crate::test_utils::asm_elf_bytes("all_instructions_64"); let max_rows = crate::tables::MaxRowsConfig::small(); - let vm_proof = prove_vm_minimal(&elf_bytes, &[], &max_rows); + let vm_proof = prove_vm_minimal(&elf_bytes, &[], &[], &max_rows); // With 2^5 max rows and 64+ instructions, tables should have multiple chunks. assert!( @@ -2956,7 +2690,7 @@ fn test_verify_rejects_inflated_table_counts() { #[test] fn test_prove_wsuffix_64bit() { let elf_bytes = crate::test_utils::asm_elf_bytes("test_wsuffix_64bit"); - let vm_proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); + let vm_proof = prove_vm_minimal(&elf_bytes, &[], &[], &Default::default()); assert!( verify_vm_minimal(&vm_proof, &elf_bytes), "W-suffix 64-bit register test should verify" @@ -2977,7 +2711,7 @@ fn test_prove_allocator_minimal_reproducer() { let elf_bytes = std::fs::read(workspace_root.join("executor/program_artifacts/rust/allocator.elf")) .expect("allocator.elf not found — run `make compile-programs-rust`"); - let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); + let proof = prove_vm_minimal(&elf_bytes, &[], &[], &Default::default()); assert!( verify_vm_minimal(&proof, &elf_bytes), "allocator.elf should verify" @@ -2996,7 +2730,7 @@ fn test_pure_commit_rust() { let elf_bytes = std::fs::read(workspace_root.join("executor/program_artifacts/rust/pure_commit.elf")) .expect("pure_commit.elf not found — run `make compile-programs-rust`"); - let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); + let proof = prove_vm_minimal(&elf_bytes, &[], &[], &Default::default()); assert!( verify_vm_minimal(&proof, &elf_bytes), "pure_commit.elf should verify" @@ -3009,7 +2743,7 @@ fn test_pure_commit_rust() { fn test_prove_with_input_empty() { let elf_bytes = crate::test_utils::asm_elf_bytes("sub"); let result = - crate::prove_with_inputs(&elf_bytes, &[]).expect("prove_with_inputs should succeed on sub"); + crate::prove_with_inputs(&elf_bytes, &[], &[]).expect("prove_with_inputs should succeed on sub"); assert!( crate::verify(&result, &elf_bytes).expect("verify should not error"), "prove_with_inputs(empty) proof should verify" @@ -3021,7 +2755,7 @@ fn test_prove_with_input_empty() { fn test_prove_private_input_xpage() { let elf_bytes = crate::test_utils::asm_elf_bytes("test_private_input_xpage"); let input: Vec = (0u8..16).collect(); - let proof = prove_vm_minimal(&elf_bytes, &input, &Default::default()); + let proof = prove_vm_minimal(&elf_bytes, &input, &[], &Default::default()); assert!(verify_vm_minimal(&proof, &elf_bytes), "proof should verify"); assert_eq!(proof.public_output, input[4..12].to_vec()); } @@ -3034,7 +2768,7 @@ fn test_prove_private_input_different_values() { 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0x00, ]; - let proof = prove_vm_minimal(&elf_bytes, &input, &Default::default()); + let proof = prove_vm_minimal(&elf_bytes, &input, &[], &Default::default()); assert!(verify_vm_minimal(&proof, &elf_bytes), "proof should verify"); assert_eq!(proof.public_output, input[4..12].to_vec()); } @@ -3053,7 +2787,7 @@ fn test_prove_ef_io_demo_concatenates() { std::fs::read(workspace_root.join("executor/program_artifacts/rust/ef_io_demo.elf")) .expect("ef_io_demo.elf not found — run `make compile-programs-rust`"); let input: &[u8] = b"hello world!"; - let proof = crate::prove_with_inputs(&elf_bytes, input).expect("prove should succeed"); + let proof = crate::prove_with_inputs(&elf_bytes, input, &[]).expect("prove should succeed"); assert!( crate::verify(&proof, &elf_bytes).expect("verify should not error"), "ef_io_demo should verify" @@ -3075,7 +2809,7 @@ fn test_prove_commit_sum() { std::fs::read(workspace_root.join("executor/program_artifacts/rust/commit_sum.elf")) .expect("commit_sum.elf not found — run `make compile-programs-rust`"); let input = &[3u8, 5u8]; - let proof = prove_vm_minimal(&elf_bytes, input, &Default::default()); + let proof = prove_vm_minimal(&elf_bytes, input, &[], &Default::default()); assert!( verify_vm_minimal(&proof, &elf_bytes), "commit_sum should verify" @@ -3096,7 +2830,7 @@ fn test_prove_ethrex_empty_block() { .expect("need ethrex.elf"); let input = std::fs::read(workspace_root.join("executor/tests/ethrex_empty_block.bin")).unwrap(); - let proof = crate::prove_with_inputs(&elf_bytes, &input).expect("prove"); + let proof = crate::prove_with_inputs(&elf_bytes, &input, &[]).expect("prove"); assert!( crate::verify(&proof, &elf_bytes).expect("verify"), "ethrex empty block should verify" @@ -3115,7 +2849,7 @@ fn test_prove_ethrex_empty_block() { fn test_verify_rejects_tampered_num_private_input_pages_zero() { let elf_bytes = crate::test_utils::asm_elf_bytes("test_private_input_xpage"); let input: Vec = (0u8..16).collect(); - let vm_proof = crate::prove_with_inputs(&elf_bytes, &input).expect("prove should succeed"); + let vm_proof = crate::prove_with_inputs(&elf_bytes, &input, &[]).expect("prove should succeed"); // Baseline: untampered proof must verify. assert!( @@ -3146,7 +2880,7 @@ fn test_verify_rejects_tampered_num_private_input_pages_zero() { fn test_verify_rejects_inflated_num_private_input_pages() { let elf_bytes = crate::test_utils::asm_elf_bytes("test_private_input_xpage"); let input: Vec = (0u8..16).collect(); - let vm_proof = crate::prove_with_inputs(&elf_bytes, &input).expect("prove should succeed"); + let vm_proof = crate::prove_with_inputs(&elf_bytes, &input, &[]).expect("prove should succeed"); assert_eq!( vm_proof.num_private_input_pages, 1, @@ -3171,7 +2905,7 @@ fn test_verify_rejects_inflated_num_private_input_pages() { fn test_verify_rejects_num_private_input_pages_exceeds_max() { let elf_bytes = crate::test_utils::asm_elf_bytes("test_private_input_xpage"); let input: Vec = (0u8..16).collect(); - let vm_proof = crate::prove_with_inputs(&elf_bytes, &input).expect("prove should succeed"); + let vm_proof = crate::prove_with_inputs(&elf_bytes, &input, &[]).expect("prove should succeed"); let tampered = crate::VmProof { num_private_input_pages: crate::tables::page::max_private_input_pages() + 1, @@ -3190,7 +2924,7 @@ fn test_verify_rejects_num_private_input_pages_exceeds_max() { fn test_verify_rejects_private_input_with_tampered_public_output() { let elf_bytes = crate::test_utils::asm_elf_bytes("test_private_input_xpage"); let input: Vec = (0u8..16).collect(); - let vm_proof = crate::prove_with_inputs(&elf_bytes, &input).expect("prove should succeed"); + let vm_proof = crate::prove_with_inputs(&elf_bytes, &input, &[]).expect("prove should succeed"); assert!( crate::verify(&vm_proof, &elf_bytes).expect("verify should not error"), @@ -3219,7 +2953,7 @@ fn test_verify_rejects_private_input_with_tampered_public_output() { fn test_proof_does_not_contain_private_input_field() { let elf_bytes = crate::test_utils::asm_elf_bytes("test_private_input_xpage"); let input: Vec = (0xA0u8..0xB0).collect(); - let vm_proof = crate::prove_with_inputs(&elf_bytes, &input).expect("prove should succeed"); + let vm_proof = crate::prove_with_inputs(&elf_bytes, &input, &[]).expect("prove should succeed"); // The VmProof struct should only contain num_private_input_pages (a count), // not the actual bytes. Verify the proof's public fields don't contain them. @@ -3242,7 +2976,7 @@ fn test_proof_does_not_contain_private_input_field() { #[test] fn test_addiw_neg_immediate() { let elf_bytes = crate::test_utils::asm_elf_bytes("test_addiw_neg"); - let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); + let proof = prove_vm_minimal(&elf_bytes, &[], &[], &Default::default()); assert!( verify_vm_minimal(&proof, &elf_bytes), "addiw with negative immediate should verify" @@ -3255,7 +2989,7 @@ fn test_addiw_neg_immediate() { #[test] fn test_count_elements_nonzero() { let elf_bytes = crate::test_utils::asm_elf_bytes("addi_one"); - let (main, aux) = crate::count_elements(&elf_bytes, &[]).expect("count_elements failed"); + let (main, aux) = crate::count_elements(&elf_bytes, &[], &[]).expect("count_elements failed"); assert!( main > 0, "total_field_elements should be nonzero (got {main})" @@ -3283,7 +3017,7 @@ fn test_prove_first_epoch_without_halt() { // intermediate epoch (4 cycles → no CPU padding rows) with the program // continuing past it. let epoch_size = 4; - let epochs = Executor::new(&elf, vec![]) + let epochs = Executor::new(&elf, vec![], &[]) .unwrap() .run_epochs(epoch_size) .unwrap(); @@ -3291,7 +3025,7 @@ fn test_prove_first_epoch_without_halt() { // Epoch 0's starting memory/registers are the program-start image; it does // not halt (is_final=false). - let image = build_initial_image(&elf, &[]); + let image = build_initial_image(&elf, &[], &[]); let register_init = crate::tables::register::register_init_from_entry_point(elf.entry_point); let mut traces = Traces::from_image_and_logs( &elf, @@ -3300,6 +3034,7 @@ fn test_prove_first_epoch_without_halt() { &epochs[0].logs, &MaxRowsConfig::default(), &[], + &[], false, false, #[cfg(feature = "disk-spill")] @@ -3370,7 +3105,7 @@ fn test_prove_second_epoch_from_snapshot() { // arith_8 is ~10 cycles; epoch_size 4 (power of two) yields epochs 4/4/2, so // epoch 1 is intermediate (4 cycles → no CPU padding rows). let epoch_size = 4; - let epochs = Executor::new(&elf, vec![]) + let epochs = Executor::new(&elf, vec![], &[]) .unwrap() .run_epochs(epoch_size) .unwrap(); @@ -3388,6 +3123,7 @@ fn test_prove_second_epoch_from_snapshot() { &epochs[1].logs, &MaxRowsConfig::default(), &[], + &[], false, false, #[cfg(feature = "disk-spill")] @@ -3463,13 +3199,13 @@ fn test_epoch_proof_commits_l2g() { // Power-of-two epoch size: all_loadstore_32 is ~34 cycles, so epoch_size 8 // makes epoch 0 an intermediate epoch with no CPU padding rows. let epoch_size = 8; - let epochs = Executor::new(&elf, vec![]) + let epochs = Executor::new(&elf, vec![], &[]) .unwrap() .run_epochs(epoch_size) .unwrap(); assert!(epochs.len() >= 2); - let image = build_initial_image(&elf, &[]); + let image = build_initial_image(&elf, &[], &[]); let register_init = register::register_init_from_entry_point(elf.entry_point); let mut traces = Traces::from_image_and_logs( &elf, @@ -3478,6 +3214,7 @@ fn test_epoch_proof_commits_l2g() { &epochs[0].logs, &MaxRowsConfig::default(), &[], + &[], false, false, #[cfg(feature = "disk-spill")] @@ -3595,13 +3332,13 @@ fn test_continuation_pipeline_end_to_end() { // Split execution into power-of-two epochs (all_loadstore_32 is ~34 cycles, so // epoch_size 8 gives intermediate epochs with no CPU padding rows). let epoch_size = 8; - let epochs = Executor::new(&elf, vec![]) + let epochs = Executor::new(&elf, vec![], &[]) .unwrap() .run_epochs(epoch_size) .unwrap(); assert!(epochs.len() >= 2); - let image0 = build_initial_image(&elf, &[]); + let image0 = build_initial_image(&elf, &[], &[]); let initial_memory: HashMap = image0.iter().map(|(&a, &v)| (a, v as u64)).collect(); // Pass 1: each epoch's starting state + the cells it touches. Epoch 0 starts @@ -3644,6 +3381,7 @@ fn test_continuation_pipeline_end_to_end() { &epoch.logs, &MaxRowsConfig::default(), &[], + &[], is_final, false, #[cfg(feature = "disk-spill")] @@ -3765,14 +3503,14 @@ fn test_epoch_memory_bus_with_l2g_bookend() { // Power-of-two epoch size: all_loadstore_32 is ~34 cycles, so epoch_size 8 // makes epoch 0 an intermediate epoch with no CPU padding rows. let epoch_size = 8; - let epochs = Executor::new(&elf, vec![]) + let epochs = Executor::new(&elf, vec![], &[]) .unwrap() .run_epochs(epoch_size) .unwrap(); assert!(epochs.len() >= 2); // Epoch 0 starts from the program image; build it with the L2G memory bookend. - let image = build_initial_image(&elf, &[]); + let image = build_initial_image(&elf, &[], &[]); let register_init = register::register_init_from_entry_point(elf.entry_point); let mut traces = Traces::from_image_and_logs( &elf, @@ -3781,6 +3519,7 @@ fn test_epoch_memory_bus_with_l2g_bookend() { &epochs[0].logs, &MaxRowsConfig::default(), &[], + &[], false, true, #[cfg(feature = "disk-spill")] diff --git a/prover/src/tests/recursion_smoke_test.rs b/prover/src/tests/recursion_smoke_test.rs index 90482a3a4..44bf8b420 100644 --- a/prover/src/tests/recursion_smoke_test.rs +++ b/prover/src/tests/recursion_smoke_test.rs @@ -48,6 +48,7 @@ fn prove_inner_and_encode_blob( let inner_proof = crate::prove_with_options_and_inputs( inner_elf, inner_input, + &[], opts, &crate::MaxRowsConfig::default(), ) @@ -76,7 +77,7 @@ fn execute_outer_and_commit(label: &str, recursion_elf_bytes: &[u8], blob: &[u8] eprintln!("[{label}] executing outer (recursion guest, in-VM verify, streaming) ..."); let program = Elf::load(recursion_elf_bytes).expect("load recursion elf"); - let mut executor = Executor::new(&program, blob.to_vec()).expect("executor new"); + let mut executor = Executor::new(&program, blob.to_vec(), &[]).expect("executor new"); let (total_cycles, exec_time) = drive_executor( &mut executor, @@ -196,7 +197,7 @@ fn setup_guest_run( preset.name() ); let executor = - executor::vm::execution::Executor::new(&program, blob).expect("Executor::new failed"); + executor::vm::execution::Executor::new(&program, blob, &[]).expect("Executor::new failed"); (guest_elf_bytes, program, executor, expected) } @@ -242,7 +243,7 @@ fn setup_block4_blowup4_guest_run() -> ( "recursion-cont-blowup4 ELF has entry_point=0 — build artifact is malformed", ); let executor = - executor::vm::execution::Executor::new(&program, blob).expect("Executor::new failed"); + executor::vm::execution::Executor::new(&program, blob, &[]).expect("Executor::new failed"); (guest_elf_bytes, program, executor, expected) } @@ -702,6 +703,7 @@ fn test_recursion_continuation_blob_decodes_and_verifies_on_host() { let bundle = crate::continuation::prove_continuation( &fib_elf_bytes, &inner_input, + &[], 4, &MIN_PROOF_OPTIONS, ) @@ -991,6 +993,7 @@ fn test_dump_recursion_input() { let bundle = crate::continuation::prove_continuation( &inner_elf_bytes, &inner_input, + &[], epoch_log2, &opts, ) diff --git a/prover/src/tests/recursion_soundness_gap_poc.rs b/prover/src/tests/recursion_soundness_gap_poc.rs index 73410ff62..5a54229d5 100644 --- a/prover/src/tests/recursion_soundness_gap_poc.rs +++ b/prover/src/tests/recursion_soundness_gap_poc.rs @@ -48,7 +48,7 @@ fn read_guest_elf(name: &str) -> Vec { /// The set of program-counter values fetched during a run of `elf_bytes`. fn executed_pcs(elf_bytes: &[u8]) -> HashSet { let elf = Elf::load(elf_bytes).expect("ELF load failed"); - let executor = Executor::new(&elf, vec![]).expect("executor new"); + let executor = Executor::new(&elf, vec![], &[]).expect("executor new"); let result = executor.run().expect("run failed"); result.logs.iter().map(|l| l.current_pc).collect() } @@ -132,7 +132,7 @@ fn custom_prove_with_statement_elf( opts: &stark::proof::options::ProofOptions, ) -> VmProof { let program = Elf::load(prove_elf).expect("prove ELF load failed"); - let executor = Executor::new(&program, vec![]).expect("executor new"); + let executor = Executor::new(&program, vec![], &[]).expect("executor new"); let result = executor.run().expect("run failed"); let max_rows = MaxRowsConfig::default(); @@ -141,6 +141,7 @@ fn custom_prove_with_statement_elf( &result.logs, &max_rows, &[], + &[], #[cfg(feature = "disk-spill")] stark::storage_mode::StorageMode::Ram, ) diff --git a/prover/src/tests/trace_builder_tests.rs b/prover/src/tests/trace_builder_tests.rs index 428fd4700..3acdd8bf7 100644 --- a/prover/src/tests/trace_builder_tests.rs +++ b/prover/src/tests/trace_builder_tests.rs @@ -837,7 +837,7 @@ fn test_from_image_and_logs_matches_from_elf_and_logs() { let elf_bytes = asm_elf_bytes("basic_program"); let program = Elf::load(&elf_bytes).unwrap(); - let logs = Executor::new(&program, vec![]).unwrap().run().unwrap().logs; + let logs = Executor::new(&program, vec![], &[]).unwrap().run().unwrap().logs; let max_rows = MaxRowsConfig::default(); let from_elf = Traces::from_elf_and_logs( @@ -845,12 +845,13 @@ fn test_from_image_and_logs_matches_from_elf_and_logs() { &logs, &max_rows, &[], + &[], #[cfg(feature = "disk-spill")] stark::storage_mode::StorageMode::Ram, ) .unwrap(); - let image = build_initial_image(&program, &[]); + let image = build_initial_image(&program, &[], &[]); let register_init = crate::tables::register::register_init_from_entry_point(program.entry_point); let from_image = Traces::from_image_and_logs( @@ -860,6 +861,7 @@ fn test_from_image_and_logs_matches_from_elf_and_logs() { &logs, &max_rows, &[], + &[], true, false, #[cfg(feature = "disk-spill")] @@ -889,14 +891,14 @@ fn test_epoch_end_memory_converts_to_image() { let elf_bytes = asm_elf_bytes("basic_program"); let program = Elf::load(&elf_bytes).unwrap(); - let total = Executor::new(&program, vec![]) + let total = Executor::new(&program, vec![], &[]) .unwrap() .run() .unwrap() .logs .len(); let epoch_size = (total / 3).max(1); - let epochs = Executor::new(&program, vec![]) + let epochs = Executor::new(&program, vec![], &[]) .unwrap() .run_epochs(epoch_size) .unwrap(); @@ -920,14 +922,14 @@ fn test_build_traces_for_all_epochs() { let elf_bytes = asm_elf_bytes("basic_program"); let program = Elf::load(&elf_bytes).unwrap(); - let total = Executor::new(&program, vec![]) + let total = Executor::new(&program, vec![], &[]) .unwrap() .run() .unwrap() .logs .len(); let epoch_size = (total / 3).max(1); - let epochs = Executor::new(&program, vec![]) + let epochs = Executor::new(&program, vec![], &[]) .unwrap() .run_epochs(epoch_size) .unwrap(); @@ -941,7 +943,7 @@ fn test_build_traces_for_all_epochs() { // previous epoch's ending memory + register snapshot. let (image, register_init): (HashMap, Vec) = if i == 0 { ( - build_initial_image(&program, &[]), + build_initial_image(&program, &[], &[]), crate::tables::register::register_init_from_entry_point(program.entry_point), ) } else { @@ -961,6 +963,7 @@ fn test_build_traces_for_all_epochs() { &epoch.logs, &max_rows, &[], + &[], i == last, false, #[cfg(feature = "disk-spill")] @@ -989,14 +992,14 @@ fn test_terminating_epoch_rejected_when_not_final() { let elf_bytes = asm_elf_bytes("basic_program"); let program = Elf::load(&elf_bytes).unwrap(); - let total = Executor::new(&program, vec![]) + let total = Executor::new(&program, vec![], &[]) .unwrap() .run() .unwrap() .logs .len(); let epoch_size = (total / 3).max(1); - let epochs = Executor::new(&program, vec![]) + let epochs = Executor::new(&program, vec![], &[]) .unwrap() .run_epochs(epoch_size) .unwrap(); @@ -1016,6 +1019,7 @@ fn test_terminating_epoch_rejected_when_not_final() { &epochs[last].logs, &MaxRowsConfig::default(), &[], + &[], false, false, #[cfg(feature = "disk-spill")] @@ -1043,20 +1047,20 @@ fn test_local_to_global_traces_from_real_execution() { let elf_bytes = asm_elf_bytes("all_loadstore_32"); let program = Elf::load(&elf_bytes).unwrap(); - let total = Executor::new(&program, vec![]) + let total = Executor::new(&program, vec![], &[]) .unwrap() .run() .unwrap() .logs .len(); let epoch_size = (total / 3).max(1); - let epochs = Executor::new(&program, vec![]) + let epochs = Executor::new(&program, vec![], &[]) .unwrap() .run_epochs(epoch_size) .unwrap(); assert!(epochs.len() >= 2); - let elf_image = build_initial_image(&program, &[]); + let elf_image = build_initial_image(&program, &[], &[]); let total_memory = elf_image.len(); // Per-epoch touched cells from real execution (epoch 0 from the ELF image, diff --git a/prover/src/tests/trace_test_helpers.rs b/prover/src/tests/trace_test_helpers.rs index 5544be69d..0ec59a2ad 100644 --- a/prover/src/tests/trace_test_helpers.rs +++ b/prover/src/tests/trace_test_helpers.rs @@ -131,12 +131,14 @@ impl Traces { logs: &[Log], max_rows: &MaxRowsConfig, private_input: &[u8], + hints: &[[u8; 32]], ) -> Result { let mut traces = Self::from_elf_and_logs( elf, logs, max_rows, private_input, + hints, #[cfg(feature = "disk-spill")] StorageMode::Ram, )?; diff --git a/prover/tests/calibration.rs b/prover/tests/calibration.rs index ff11bcf4b..92ac000e5 100644 --- a/prover/tests/calibration.rs +++ b/prover/tests/calibration.rs @@ -33,7 +33,7 @@ fn peak_bytes_does_not_underestimate_measured_heap() { let max_rows = MaxRowsConfig::default(); let lengths = - count_table_lengths(&elf, &logs, &max_rows, &[]).expect("count_table_lengths succeeds"); + count_table_lengths(&elf, &logs, &max_rows, &[], &[]).expect("count_table_lengths succeeds"); let opts = GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 is valid"); let predicted = peak_bytes(&lengths, opts.blowup_factor, table_parallelism()) as usize; @@ -56,7 +56,7 @@ fn peak_bytes_does_not_underestimate_measured_heap() { }; let _proof = - prove_with_options_and_inputs(&elf_bytes, &[], &opts, &max_rows).expect("proof succeeds"); + prove_with_options_and_inputs(&elf_bytes, &[], &[], &opts, &max_rows).expect("proof succeeds"); stop.store(true, Ordering::Relaxed); sampler.join().expect("sampler joins"); diff --git a/prover/tests/ecrecover_hints_continuation.rs b/prover/tests/ecrecover_hints_continuation.rs new file mode 100644 index 000000000..4147f8ddc --- /dev/null +++ b/prover/tests/ecrecover_hints_continuation.rs @@ -0,0 +1,61 @@ +//! Continuation-proof measurement for the `ecrecover_hints` guest on the hint +//! arena: proves the pass-2 run (arena hints) with continuations to bound +//! prover memory, and verifies the bundle. +//! +//! Fixtures are produced by the executor-side driver — run it first: +//! cargo test -p executor --test hint_arena_ecrecover -- --ignored --nocapture +//! Then: +//! cargo test -p lambda-vm-prover --test ecrecover_hints_continuation -- --ignored --nocapture + +use lambda_vm_prover::continuation::{prove_continuation, verify_continuation}; +use stark::proof::options::ProofOptions; +use std::time::Instant; + +/// Epoch size: 2^16 cycles. ~866k cycles → ~14 epochs, bounding per-epoch +/// prover memory. +const EPOCH_SIZE_LOG2: u32 = 16; + +#[test] +#[ignore = "measurement — run explicitly"] +fn ecrecover_arena_continuation_proof() { + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let dir = workspace_root.join("executor/program_artifacts/rust"); + let elf_bytes = std::fs::read(dir.join("ecrecover_hints.elf")) + .expect("ecrecover_hints.elf missing — run `make executor/program_artifacts/rust/ecrecover_hints.elf`"); + let input = std::fs::read(dir.join("ecrecover_hints.input.bin")).expect( + "ecrecover_hints.input.bin missing — run the executor driver: \ + cargo test -p executor --test hint_arena_ecrecover -- --ignored", + ); + let hints_bin = std::fs::read(dir.join("ecrecover_hints.hints.bin")) + .expect("ecrecover_hints.hints.bin missing — run the executor driver"); + assert_eq!(hints_bin.len() % 32, 0, "hints fixture must be 32-byte slots"); + let hints: Vec<[u8; 32]> = hints_bin + .chunks_exact(32) + .map(|c| c.try_into().unwrap()) + .collect(); + + let opts = ProofOptions::default_test_options(); + + let t0 = Instant::now(); + let bundle = prove_continuation(&elf_bytes, &input, &hints, EPOCH_SIZE_LOG2, &opts) + .expect("continuation prove"); + let prove_time = t0.elapsed(); + + let t0 = Instant::now(); + let public_output = verify_continuation(&elf_bytes, &bundle, &opts) + .expect("continuation verify") + .expect("bundle must verify"); + let verify_time = t0.elapsed(); + + println!("[ecrecover-continuation] epochs = {}", bundle.num_epochs()); + println!("[ecrecover-continuation] hints = {} slots", hints.len()); + println!("[ecrecover-continuation] prove = {prove_time:?}, verify = {verify_time:?}"); + println!( + "[ecrecover-continuation] public output = {} bytes, first 8: {:02x?}", + public_output.len(), + &public_output[..8.min(public_output.len())] + ); +} diff --git a/prover/tests/gpu_constraint_interp_real.rs b/prover/tests/gpu_constraint_interp_real.rs index 4446fb446..2cea4be1b 100644 --- a/prover/tests/gpu_constraint_interp_real.rs +++ b/prover/tests/gpu_constraint_interp_real.rs @@ -271,5 +271,4 @@ fn all_table_programs_gpu_match_cpu_oracle() { check_air(&create_keccak_rc_air(&opts), "KECCAK_RC"); check_air(&create_ecsm_air(&opts), "ECSM"); check_air(&create_ecdas_air(&opts), "ECDAS"); - check_air(&create_hint_air(&opts), "HINT"); } diff --git a/syscalls/src/syscalls.rs b/syscalls/src/syscalls.rs index 5228455ea..54a2cb88e 100644 --- a/syscalls/src/syscalls.rs +++ b/syscalls/src/syscalls.rs @@ -33,12 +33,7 @@ const KECCAK_SYSCALL_NUMBER: usize = usize::MAX - 1; #[cfg(target_arch = "riscv64")] const ECSM_SYSCALL_NUMBER: usize = usize::MAX - 10; -/// Syscall number for the non-constraining Hint ecall. -/// Must match `executor::...::execution::HINT_SYSCALL_NUMBER` (u64::MAX - 30). -#[cfg(target_arch = "riscv64")] -const HINT_SYSCALL_NUMBER: usize = usize::MAX - 30; - -/// Hint selectors passed in `a0` (must match the executor's `HINT_*`). +/// Hint selectors for the request log (must match the executor's `HINT_*`). pub const HINT_FIELD_INV: usize = 0; pub const HINT_SCALAR_INV: usize = 1; pub const HINT_FIELD_SQRT: usize = 2; @@ -138,6 +133,166 @@ pub fn get_private_input_slice() -> &'static [u8] { unimplemented!("syscalls are only implemented for riscv64 targets"); } +// ============================================================================= +// Hint arena — untrusted, host-supplied 32-byte hint values appended to the +// private-input region after the length-prefixed main data: +// +// [u32 LE main_len][main data][zero-pad to 8][u32 LE hint_count][u32 pad] +// then `hint_count` slots of 32 bytes, 8-aligned. +// +// Must match `executor::vm::memory::{hint_arena_header_offset, +// HINT_ARENA_HEADER_BYTES, HINT_SLOT_BYTES}` — the executor writes the region, +// this crate reads it. +// +// The values are UNTRUSTED (the prover chooses them): the caller MUST verify +// each hint in-guest (e.g. `x·inv == 1`) and recompute in software on failure. +// Consumption is positional — one slot per request, pass or fail — so a lying +// host can only trigger fallbacks, never shift the hint stream. +// ============================================================================= + +/// Byte size of one hint slot in the hint arena. +/// Must match `executor::vm::memory::HINT_SLOT_BYTES`. +#[cfg(target_arch = "riscv64")] +pub const HINT_SLOT_BYTES: usize = 32; + +/// Byte size of the arena header (`[u32 LE hint_count][u32 zero pad]`). +/// Must match `executor::vm::memory::HINT_ARENA_HEADER_BYTES`. +#[cfg(target_arch = "riscv64")] +const HINT_ARENA_HEADER_BYTES: usize = 8; + +/// Offset from `PRIVATE_INPUT_START` of the arena header for a given main-input +/// length: the 4-byte length prefix plus the data, padded up to 8 bytes. +/// Must match `executor::vm::memory::hint_arena_header_offset`. +#[cfg(target_arch = "riscv64")] +const fn hint_arena_header_offset(main_len: usize) -> usize { + (4 + main_len + 7) & !7 +} + +/// Absolute address of the arena header, derived from the (clamped) length +/// prefix — the same value `get_private_input_slice` trusts. +#[cfg(target_arch = "riscv64")] +fn hint_arena_header_addr() -> usize { + let len = (unsafe { core::ptr::read_volatile(PRIVATE_INPUT_START as *const u32) } as usize) + .min(MAX_PRIVATE_INPUT_SIZE); + PRIVATE_INPUT_START + hint_arena_header_offset(len) +} + +/// Number of hint slots the host supplied. 0 when no arena was written (the +/// header reads back as zero-filled memory). +#[cfg(target_arch = "riscv64")] +pub fn hint_count() -> usize { + unsafe { core::ptr::read_volatile(hint_arena_header_addr() as *const u32) as usize } +} + +#[cfg(not(target_arch = "riscv64"))] +pub fn hint_count() -> usize { + unimplemented!("syscalls are only implemented for riscv64 targets"); +} + +/// Read hint slot `i` as raw bytes, or `None` when the arena has no slot `i`. +/// The slot is read as four aligned 8-byte words (the fast aligned-load path); +/// slots are 8-aligned by construction. +#[cfg(target_arch = "riscv64")] +pub fn hint_slot(i: usize) -> Option<[u8; 32]> { + if i >= hint_count() { + return None; + } + let addr = hint_arena_header_addr() + HINT_ARENA_HEADER_BYTES + i * HINT_SLOT_BYTES; + debug_assert_eq!(addr % 8, 0, "hint slot address must stay 8-aligned"); + // Read as four u64 words and re-serialize little-endian: the VM is + // little-endian, so this reproduces the exact byte sequence the host wrote. + let words: [u64; 4] = unsafe { core::ptr::read_volatile(addr as *const [u64; 4]) }; + let mut out = [0u8; 32]; + for (k, w) in words.iter().enumerate() { + out[8 * k..8 * k + 8].copy_from_slice(&w.to_le_bytes()); + } + Some(out) +} + +#[cfg(not(target_arch = "riscv64"))] +pub fn hint_slot(_i: usize) -> Option<[u8; 32]> { + unimplemented!("syscalls are only implemented for riscv64 targets"); +} + +/// Consume the next hint slot positionally. Returns `None` once the arena is +/// exhausted — the caller then recomputes in software. One slot is consumed per +/// call whether or not the hint verifies, so a host that supplies fewer hints +/// than requested only forces fallbacks; it cannot desynchronize the stream. +#[cfg(target_arch = "riscv64")] +pub fn next_hint() -> Option<[u8; 32]> { + // Single-threaded guest: a relaxed atomic cursor is sufficient (the guest + // target lowers atomics via `-C passes=lower-atomic`). + static CURSOR: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0); + let i = CURSOR.fetch_add(1, core::sync::atomic::Ordering::Relaxed); + hint_slot(i) +} + +#[cfg(not(target_arch = "riscv64"))] +pub fn next_hint() -> Option<[u8; 32]> { + unimplemented!("syscalls are only implemented for riscv64 targets"); +} + +// ============================================================================= +// Hint request log — the recording pass for hints that are NOT known +// beforehand. When the arena is exhausted, `request_hint` appends +// `(hint_id, input)` to a fixed scratch region above the private-input window; +/// the host reads it after the run (executor's `Memory::hint_requests`), +/// computes the answers, and re-runs with a complete arena. The log region: +/// +// [u32 LE count][u32 pad], then entries of [u64 LE hint_id][32-byte input]. +// +// Must match `executor::vm::memory::{HINT_LOG_START_INDEX, +// HINT_LOG_HEADER_BYTES, HINT_LOG_ENTRY_BYTES}`. +// ============================================================================= + +/// Start of the hint request log: just past the reserved private-input window +/// (`PRIVATE_INPUT_START + 4 + MAX_PRIVATE_INPUT_SIZE`, rounded up to 8). The +/// stack lives at the top of the 64-bit space and the heap grows up from the +/// ELF image, so this region is collision-free in practice. +/// Must match `executor::vm::memory::HINT_LOG_START_INDEX`. +#[cfg(target_arch = "riscv64")] +pub const HINT_LOG_START: usize = 0xFF000000 + 4 + 512 * 1024 * 1024 + 4; + +/// Log header size (`[u32 LE count][u32 pad]`). +/// Must match `executor::vm::memory::HINT_LOG_HEADER_BYTES`. +#[cfg(target_arch = "riscv64")] +const HINT_LOG_HEADER_BYTES: usize = 8; + +/// Log entry size (`[u64 LE hint_id][32-byte input]`). +/// Must match `executor::vm::memory::HINT_LOG_ENTRY_BYTES`. +#[cfg(target_arch = "riscv64")] +const HINT_LOG_ENTRY_BYTES: usize = 40; + +/// Consume the next hint slot for a `(hint_id, input)` request; on an exhausted +/// arena, append the request to the hint request log and return `None` (the +/// caller then recomputes in software). One slot is consumed per call whether +/// or not the hint verifies — see [`next_hint`]. +#[cfg(target_arch = "riscv64")] +pub fn request_hint(hint_id: usize, input: &[u8; 32]) -> Option<[u8; 32]> { + if let Some(slot) = next_hint() { + return Some(slot); + } + let count_addr = HINT_LOG_START; + let count = unsafe { core::ptr::read_volatile(count_addr as *const u32) } as usize; + let entry = count_addr + HINT_LOG_HEADER_BYTES + count * HINT_LOG_ENTRY_BYTES; + unsafe { + core::ptr::write_volatile(entry as *mut u64, hint_id as u64); + for k in 0..4 { + let word = u64::from_le_bytes(input[8 * k..8 * k + 8].try_into().unwrap()); + core::ptr::write_volatile((entry + 8 + 8 * k) as *mut u64, word); + } + core::ptr::write_volatile(count_addr as *mut u32, count as u32 + 1); + } + None +} + +#[cfg(not(target_arch = "riscv64"))] +pub fn request_hint(_hint_id: usize, _input: &[u8; 32]) -> Option<[u8; 32]> { + unimplemented!("syscalls are only implemented for riscv64 targets"); +} + + + #[cfg(target_arch = "riscv64")] pub fn sys_halt() -> ! { // NOTE: no print_string here — the Print ecall is unmatched on the Ecall bus @@ -197,32 +352,6 @@ pub fn ecsm_mul(_xr: &mut [u8; 32], _xg: &[u8; 32], _k: &[u8; 32]) { unimplemented!("syscalls are only implemented for riscv64 targets"); } -/// Ask the host for a non-constraining hint (modular inverse/sqrt). -/// `hint_id` selects the operation ([`HINT_FIELD_INV`]/[`HINT_SCALAR_INV`]/ -/// [`HINT_FIELD_SQRT`]); `input`/`out` are 32-byte **big-endian** field/scalar -/// elements — k256's own serialization, so consumers pass `to_bytes()` straight -/// through. Note this differs from [`ecsm_mul`], which is little-endian. -/// The result is UNTRUSTED — the caller MUST verify it in-guest (e.g. `x·inv == 1`) -/// AND recompute in software on failure, since this ecall adds no correctness -/// constraint and the prover chooses the returned bytes. -#[cfg(target_arch = "riscv64")] -pub fn hint(hint_id: usize, out: &mut [u8; 32], input: &[u8; 32]) { - unsafe { - asm!( - "ecall", - in("a0") hint_id, // x10 = hint selector - in("a1") input.as_ptr(), // x11 = input address (32-byte BE) - in("a2") out.as_mut_ptr(), // x12 = output address (32-byte BE) - in("a7") HINT_SYSCALL_NUMBER, - ) - } -} - -#[cfg(not(target_arch = "riscv64"))] -pub fn hint(_hint_id: usize, _out: &mut [u8; 32], _input: &[u8; 32]) { - unimplemented!("syscalls are only implemented for riscv64 targets"); -} - // ============================================================================= // Stub implementations for unsupported std functions // These functions are required by Rust's std zkvm module but are not supported From 7e519fd161d03a5853bb1aa5e6aacf2a441319ac Mon Sep 17 00:00:00 2001 From: diegokingston Date: Wed, 19 Aug 2026 18:04:03 -0300 Subject: [PATCH 02/15] fix(guests): bump ecrecover_hints' ethrex-crypto pin to 4f658c2b MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The restack brought in the ethrex bump, which moved lambda-vm-ethrex-crypto to ethrex 4f658c2b while the guest kept its direct dep at 156cb8d6 — two distinct Crypto traits, so the guest no longer compiled. Unify on the bumped rev (lockfile collapses to a single ethrex-crypto entry). --- .../programs/rust/ecrecover_hints/Cargo.lock | 68 +------------------ .../programs/rust/ecrecover_hints/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 67 deletions(-) diff --git a/executor/programs/rust/ecrecover_hints/Cargo.lock b/executor/programs/rust/ecrecover_hints/Cargo.lock index 56d252d8c..652ee8e28 100644 --- a/executor/programs/rust/ecrecover_hints/Cargo.lock +++ b/executor/programs/rust/ecrecover_hints/Cargo.lock @@ -162,18 +162,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" -[[package]] -name = "bitvec" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -183,19 +171,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "bls12_381" -version = "0.8.0" -source = "git+https://github.com/lambdaclass/bls12_381?branch=expose-affine-constructors#78cad0378b17fc3157b83f514be192bf46edf9a1" -dependencies = [ - "digest", - "ff", - "group", - "pairing", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "byteorder" version = "1.5.0" @@ -376,15 +351,13 @@ dependencies = [ [[package]] name = "ethrex-crypto" -version = "13.0.0" -source = "git+https://github.com/lambdaclass/ethrex.git?rev=156cb8d6a3974f411d71622eecd1b249ee37ff1c#156cb8d6a3974f411d71622eecd1b249ee37ff1c" +version = "22.0.0" +source = "git+https://github.com/lambdaclass/ethrex.git?rev=4f658c2b3d10e3f21d35ce546870f55ca3f940fc#4f658c2b3d10e3f21d35ce546870f55ca3f940fc" dependencies = [ "ark-bn254", "ark-ec", "ark-ff", - "bls12_381", "ethereum-types", - "ff", "hex-literal", "k256", "num-bigint", @@ -401,7 +374,6 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "bitvec", "rand_core 0.6.4", "subtle", ] @@ -423,12 +395,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - [[package]] name = "generic-array" version = "0.14.9" @@ -604,15 +570,6 @@ dependencies = [ "sha2", ] -[[package]] -name = "pairing" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fec4625e73cf41ef4bb6846cafa6d44736525f442ba45e407c4a000a13996f" -dependencies = [ - "group", -] - [[package]] name = "paste" version = "1.0.15" @@ -671,12 +628,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - [[package]] name = "rand" version = "0.8.7" @@ -855,12 +806,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "thiserror" version = "1.0.69" @@ -961,15 +906,6 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - [[package]] name = "zerocopy" version = "0.8.56" diff --git a/executor/programs/rust/ecrecover_hints/Cargo.toml b/executor/programs/rust/ecrecover_hints/Cargo.toml index c4980d5cf..fffe10138 100644 --- a/executor/programs/rust/ecrecover_hints/Cargo.toml +++ b/executor/programs/rust/ecrecover_hints/Cargo.toml @@ -13,4 +13,4 @@ lambda-vm-syscalls = { path = "../../../../syscalls" } lambda-vm-ethrex-crypto = { path = "../../../../crypto/ethrex-crypto" } # The `Crypto` trait the guest calls through. Same rev + default-features=false # as lambda-vm-ethrex-crypto's own dep, so feature unification adds nothing. -ethrex-crypto = { git = "https://github.com/lambdaclass/ethrex.git", rev = "156cb8d6a3974f411d71622eecd1b249ee37ff1c", package = "ethrex-crypto", default-features = false } +ethrex-crypto = { git = "https://github.com/lambdaclass/ethrex.git", rev = "4f658c2b3d10e3f21d35ce546870f55ca3f940fc", package = "ethrex-crypto", default-features = false } From cd1d6110eddfae6eaf793ea8d65f576a6393c93a Mon Sep 17 00:00:00 2001 From: diegokingston Date: Wed, 19 Aug 2026 18:40:50 -0300 Subject: [PATCH 03/15] perf(hints): auto-record the hint arena in prove/count entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The removed hint ecall accelerated hint-consuming guests on EVERY prove, no caller opt-in; the arena made acceleration opt-in, so hint-less flows (plain cli prove, bench_abba.sh) silently proved the software-fallback trace — measured on ethrex_10_transfers: 3.35M cycles without hints vs 1.31M hinted (the ecall baseline was 1.70M). resolve_hints restores the always-on ergonomics: when the caller passes an empty arena, prove_with_options_and_inputs / prove_continuation / count_elements first run the recording pass (collect_hints) and prove the hinted trace. Both runs commit the same output — the arena only changes cost — so the statement being proved is unchanged. Guests that request nothing get an empty arena and an identical trace; the only cost is one extra execution, negligible against proving. Regression coverage: test_prove_ecrecover_hints_auto_records_arena asserts hint-less and explicit-arena calls produce identical element counts and equal verified public outputs. --- prover/src/continuation.rs | 5 ++- prover/src/lib.rs | 46 ++++++++++++++++--- prover/src/tests/prove_elfs_tests.rs | 66 ++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 7 deletions(-) diff --git a/prover/src/continuation.rs b/prover/src/continuation.rs index da6194ee0..783debf2c 100644 --- a/prover/src/continuation.rs +++ b/prover/src/continuation.rs @@ -1074,6 +1074,8 @@ pub fn prove_continuation( let __root = stark::instruments::span("prove_continuation_total"); let elf = Elf::load(elf_bytes).map_err(|e| Error::ElfLoad(format!("{e}")))?; + let resolved_hints = crate::resolve_hints(&elf, private_inputs, hints)?; + let hints: &[[u8; 32]] = &resolved_hints; let mut executor = Executor::new(&elf, private_inputs.to_vec(), hints) .map_err(|e| Error::Execution(format!("{e}")))?; // The DECODE precomputed commitment depends only on (ELF, opts): compute @@ -1431,7 +1433,8 @@ pub fn prove_continuation( let run = || -> Result { #[cfg(feature = "instruments")] let __sp = stark::instruments::span("prove_global"); - let num_private_input_pages = page::private_input_page_count(private_inputs, hints); + let num_private_input_pages = + page::private_input_page_count(private_inputs, hints); // SINGLE source of truth: the same page-base list drives the // committed GLOBAL_MEMORY tables and is shipped in the bundle, // so the two can never diverge in set or order. diff --git a/prover/src/lib.rs b/prover/src/lib.rs index d9261b51e..61ab82d0c 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -1037,6 +1037,34 @@ pub(crate) fn verify_l2g_commitment_binding_view( // Public API: Prove / Verify // ============================================================================= +/// Resolve the hint arena for a prove/count entry point: `hints` as-is when +/// non-empty, otherwise the two-pass recording flow — execute once with an +/// empty arena, answer the guest's logged requests with +/// [`executor::vm::instruction::execution::compute_hint`], and use the answers +/// as the arena for the proved run. +/// +/// The auto-record restores the ergonomics the removed hint ecall had: a +/// hint-consuming guest proved with no explicit arena still gets the cheap +/// in-guest-verify trace instead of the software-fallback one (measured on +/// `ethrex_10_transfers`: 3.35M cycles hint-less vs 1.31M hinted). Both runs +/// commit the same output — the arena only changes how cheaply the guest gets +/// there — so this never changes the statement being proved. For a guest that +/// requests nothing the record pass yields an empty arena and the proved run +/// is identical to what it would have been anyway; the only cost is one extra +/// execution, negligible against proving. +fn resolve_hints<'a>( + program: &Elf, + private_inputs: &[u8], + hints: &'a [[u8; 32]], +) -> Result, Error> { + if !hints.is_empty() { + return Ok(std::borrow::Cow::Borrowed(hints)); + } + let recorded = executor::vm::execution::collect_hints(program, private_inputs.to_vec()) + .map_err(|e| Error::Execution(format!("hint recording pass: {e}")))?; + Ok(std::borrow::Cow::Owned(recorded)) +} + /// Prove an ELF binary execution. Returns a serializable proof bundle. pub fn prove(elf_bytes: &[u8]) -> Result { prove_with_inputs(elf_bytes, &[], &[]) @@ -1046,7 +1074,10 @@ pub fn prove(elf_bytes: &[u8]) -> Result { /// /// `hints` are untrusted 32-byte values appended to the private-input memory /// region's hint arena; the guest reads them with ordinary loads and must -/// verify them in-circuit. +/// verify them in-circuit. When empty, the recording pass of the two-pass +/// hint flow runs automatically (see [`resolve_hints`]), so hint-consuming +/// guests get the cheap in-guest-verify trace without the caller supplying +/// anything. pub fn prove_with_inputs( elf_bytes: &[u8], private_inputs: &[u8], @@ -1075,7 +1106,8 @@ pub fn count_elements( hints: &[[u8; 32]], ) -> Result<(u64, u64), Error> { let program = Elf::load(elf_bytes).map_err(|e| Error::ElfLoad(format!("{e}")))?; - let executor = Executor::new(&program, private_inputs.to_vec(), hints) + let hints = resolve_hints(&program, private_inputs, hints)?; + let executor = Executor::new(&program, private_inputs.to_vec(), &hints) .map_err(|e| Error::Execution(format!("{e}")))?; let result = executor .run() @@ -1085,7 +1117,7 @@ pub fn count_elements( &result.logs, &MaxRowsConfig::default(), private_inputs, - hints, + &hints, #[cfg(feature = "disk-spill")] StorageMode::Ram, )?; @@ -1129,7 +1161,8 @@ pub fn prove_with_options_and_inputs( let __sp = stark::instruments::span("execute"); let program = Elf::load(elf_bytes).map_err(|e| Error::ElfLoad(format!("{e}")))?; - let executor = Executor::new(&program, private_inputs.to_vec(), hints) + let hints = resolve_hints(&program, private_inputs, hints)?; + let executor = Executor::new(&program, private_inputs.to_vec(), &hints) .map_err(|e| Error::Execution(format!("{e}")))?; let result = executor .run() @@ -1150,7 +1183,8 @@ pub fn prove_with_options_and_inputs( #[cfg(feature = "disk-spill")] let storage_mode = { - let lengths = count_table_lengths(&program, &result.logs, max_rows, private_inputs, hints)?; + let lengths = + count_table_lengths(&program, &result.logs, max_rows, private_inputs, &hints)?; auto_storage::decide(&lengths, proof_options.blowup_factor) }; @@ -1159,7 +1193,7 @@ pub fn prove_with_options_and_inputs( &result.logs, max_rows, private_inputs, - hints, + &hints, #[cfg(feature = "disk-spill")] storage_mode, )?; diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index 05ea29d91..b80df3216 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -1273,6 +1273,72 @@ fn test_prove_hint_arena_rust_guest() { assert_eq!(proof.public_output, expected.to_vec()); } +/// Auto-record policy: proving a hint-consuming guest with NO explicit arena +/// must transparently run the two-pass recording flow ([`crate::resolve_hints`]) +/// and cover the cheap hinted trace — not the software-fallback one. Asserts +/// the trace element counts match an explicit-arena call and that both proofs +/// verify with identical public outputs. Regression coverage for the removed +/// hint ecall's always-on ergonomics (hint-less `ethrex_10_transfers` measured +/// 3.35M cycles vs 1.31M hinted — flows that supply nothing must not regress). +#[test] +fn test_prove_ecrecover_hints_auto_records_arena() { + let _ = env_logger::builder().is_test(true).try_init(); + + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let elf_bytes = + std::fs::read(workspace_root.join("executor/program_artifacts/rust/ecrecover_hints.elf")) + .expect("ecrecover_hints.elf not found — run `make compile-programs-rust`"); + let program = executor::elf::Elf::load(&elf_bytes).expect("ELF load"); + + // Two ecrecover records (sig(64) || recid(1) || msg(32)), as built by the + // executor driver's fixture generator: r = 1 and 2 (both quadratic + // residues for r³ + 7), s = 1000/1001, recid parity 0/1, msg = 0x01.. / 0x08... + const REC0: [u8; 97] = [ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x03, 0xE8, 0x00, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + ]; + const REC1: [u8; 97] = [ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x03, 0xE9, 0x01, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, + 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, + 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, + ]; + let mut input = Vec::with_capacity(4 + 2 * 97); + input.extend_from_slice(&2u32.to_le_bytes()); + input.extend_from_slice(&REC0); + input.extend_from_slice(&REC1); + + // The recording pass answers the guest's requests (sqrt + batched field + // inverse + scalar inverse per recovery, in that order). + let hints = executor::vm::execution::collect_hints(&program, input.clone()) + .expect("collect_hints"); + assert_eq!(hints.len(), 6, "2 recoveries × 3 hint requests"); + + // The policy under test: no explicit arena ⇒ auto-record ⇒ the SAME + // (hinted) trace an explicit arena produces. + let auto = crate::count_elements(&elf_bytes, &input, &[]).expect("count auto"); + let explicit = crate::count_elements(&elf_bytes, &input, &hints).expect("count explicit"); + assert_eq!(auto, explicit, "auto-record must produce the hinted trace"); + + // And both proofs verify with identical committed output. + let proof_auto = prove_vm_minimal(&elf_bytes, &input, &[], &Default::default()); + let proof_hints = prove_vm_minimal(&elf_bytes, &input, &hints, &Default::default()); + assert!(verify_vm_minimal(&proof_auto, &elf_bytes)); + assert!(verify_vm_minimal(&proof_hints, &elf_bytes)); + assert_eq!(proof_auto.public_output, proof_hints.public_output); +} + /// Soundness: the verifier REJECTS a forged ECSM result. /// /// A malicious prover must not be able to claim a wrong `k·G`. We tamper the result From 5781ff513782d29ecf002d1e455582bf119d6872 Mon Sep 17 00:00:00 2001 From: diegokingston Date: Wed, 19 Aug 2026 18:53:26 -0300 Subject: [PATCH 04/15] chore(hints): satisfy the lint gate (fmt + clippy) - cargo fmt over the hint-arena diff - group private_input/hints paths into ProveInputPaths so cmd_prove and cmd_prove_continuation stay under clippy's too_many_arguments (8/7), matching the FlamegraphCliOptions precedent Verified: cargo fmt --check --all plus all four CI clippy passes (default, debug-checks, disk-spill, cuda) green; the ethrex fixture checksum gate passes. --- bin/cli/src/main.rs | 45 ++--- executor/src/vm/execution.rs | 4 +- executor/src/vm/memory.rs | 18 +- executor/tests/hint_arena_ecrecover.rs | 10 +- prover/src/continuation.rs | 187 ++++++++++++++---- prover/src/lib.rs | 4 +- prover/src/tables/page.rs | 4 +- prover/src/tables/trace_builder.rs | 13 +- .../tests/count_table_lengths_drift_tests.rs | 4 +- prover/src/tests/prove_elfs_tests.rs | 85 ++++---- prover/src/tests/trace_builder_tests.rs | 6 +- prover/tests/calibration.rs | 8 +- prover/tests/ecrecover_hints_continuation.rs | 6 +- syscalls/src/syscalls.rs | 2 - 14 files changed, 260 insertions(+), 136 deletions(-) diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index 64607f356..6c9f02ce4 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -297,19 +297,14 @@ fn main() -> ExitCode { epoch_size_log2, hints, } => { + let inputs = ProveInputPaths { + private_input, + hints, + }; if continuations { - cmd_prove_continuation( - elf, - output, - private_input, - epoch_size_log2, - blowup, - time, - cycles, - hints, - ) + cmd_prove_continuation(elf, output, inputs, epoch_size_log2, blowup, time, cycles) } else { - cmd_prove(elf, output, private_input, blowup, time, cycles, elements, hints) + cmd_prove(elf, output, inputs, blowup, time, cycles, elements) } } Commands::Verify { @@ -366,11 +361,7 @@ fn read_hints(path: Option<&PathBuf>) -> Result, String> { } } -fn count_cycles( - elf_data: &[u8], - private_inputs: &[u8], - hints: &[[u8; 32]], -) -> Result { +fn count_cycles(elf_data: &[u8], private_inputs: &[u8], hints: &[[u8; 32]]) -> Result { let program = Elf::load(elf_data).map_err(|e| format!("Failed to load ELF for cycle count: {e:?}"))?; let executor = Executor::new(&program, private_inputs.to_vec(), hints) @@ -638,15 +629,22 @@ fn cmd_execute( ExitCode::SUCCESS } +/// Input file paths shared by the prove commands: the guest's private input +/// and an optional hint arena (see [`read_hints`]). Grouped so the prove +/// commands stay under the argument-count lint. +struct ProveInputPaths { + private_input: Option, + hints: Option, +} + fn cmd_prove( elf_path: PathBuf, output_path: PathBuf, - private_input_path: Option, + inputs: ProveInputPaths, blowup: u8, time: bool, cycles: bool, elements: bool, - hints_path: Option, ) -> ExitCode { eprintln!("Reading ELF file..."); let elf_data = match std::fs::read(&elf_path) { @@ -657,7 +655,7 @@ fn cmd_prove( } }; - let private_inputs = match read_private_input(private_input_path.as_ref()) { + let private_inputs = match read_private_input(inputs.private_input.as_ref()) { Ok(inputs) => inputs, Err(e) => { eprintln!("{e}"); @@ -665,7 +663,7 @@ fn cmd_prove( } }; - let hints = match read_hints(hints_path.as_ref()) { + let hints = match read_hints(inputs.hints.as_ref()) { Ok(hints) => hints, Err(e) => { eprintln!("{e}"); @@ -841,12 +839,11 @@ fn cmd_verify(proof_path: PathBuf, elf_path: PathBuf, blowup: u8, time: bool) -> fn cmd_prove_continuation( elf_path: PathBuf, output_path: PathBuf, - private_input_path: Option, + inputs: ProveInputPaths, epoch_size_log2: Option, blowup: u8, time: bool, cycles: bool, - hints_path: Option, ) -> ExitCode { eprintln!("Reading ELF file..."); let elf_data = match std::fs::read(&elf_path) { @@ -857,7 +854,7 @@ fn cmd_prove_continuation( } }; - let private_inputs = match read_private_input(private_input_path.as_ref()) { + let private_inputs = match read_private_input(inputs.private_input.as_ref()) { Ok(inputs) => inputs, Err(e) => { eprintln!("{e}"); @@ -865,7 +862,7 @@ fn cmd_prove_continuation( } }; - let hints = match read_hints(hints_path.as_ref()) { + let hints = match read_hints(inputs.hints.as_ref()) { Ok(hints) => hints, Err(e) => { eprintln!("{e}"); diff --git a/executor/src/vm/execution.rs b/executor/src/vm/execution.rs index 24bb85101..3e5fe6b07 100644 --- a/executor/src/vm/execution.rs +++ b/executor/src/vm/execution.rs @@ -220,9 +220,7 @@ pub fn collect_hints( Ok(result .hint_requests .iter() - .map(|(hint_id, input)| { - crate::vm::instruction::execution::compute_hint(*hint_id, input) - }) + .map(|(hint_id, input)| crate::vm::instruction::execution::compute_hint(*hint_id, input)) .collect()) } diff --git a/executor/src/vm/memory.rs b/executor/src/vm/memory.rs index 705d20922..b19f5a6e7 100644 --- a/executor/src/vm/memory.rs +++ b/executor/src/vm/memory.rs @@ -68,10 +68,8 @@ pub const HINT_ARENA_HEADER_BYTES: u64 = 8; /// the recording pass of the two-pass hint flow. The host reads them back with /// [`Memory::hint_requests`]. Must match `HINT_LOG_START` in /// `syscalls/src/syscalls.rs`. -pub const HINT_LOG_START_INDEX: u64 = 0xFF000000 - + PRIVATE_INPUT_LENGTH_PREFIX_BYTES as u64 - + MAX_PRIVATE_INPUT_SIZE - + 4; +pub const HINT_LOG_START_INDEX: u64 = + 0xFF000000 + PRIVATE_INPUT_LENGTH_PREFIX_BYTES as u64 + MAX_PRIVATE_INPUT_SIZE + 4; /// Size in bytes of the request-log header (`[u32 LE count][u32 zero pad]`). pub const HINT_LOG_HEADER_BYTES: u64 = 8; /// Size in bytes of one request-log entry (`[u64 LE hint_id][32-byte input]`). @@ -97,7 +95,8 @@ pub fn encode_private_input_region( inputs: &[u8], hints: &[[u8; 32]], ) -> Result, MemoryError> { - let main_len = u32::try_from(inputs.len()).map_err(|_| MemoryError::PrivateInputSizeExceeded)?; + let main_len = + u32::try_from(inputs.len()).map_err(|_| MemoryError::PrivateInputSizeExceeded)?; let header_offset = hint_arena_header_offset(inputs.len() as u64); let hints_bytes = (hints.len() as u64) .checked_mul(HINT_SLOT_BYTES) @@ -296,9 +295,8 @@ impl Memory { let count = self.load_word(HINT_LOG_START_INDEX)? as usize; let mut out = Vec::with_capacity(count); for i in 0..count { - let entry = HINT_LOG_START_INDEX - + HINT_LOG_HEADER_BYTES - + i as u64 * HINT_LOG_ENTRY_BYTES; + let entry = + HINT_LOG_START_INDEX + HINT_LOG_HEADER_BYTES + i as u64 * HINT_LOG_ENTRY_BYTES; let hint_id = self.load_doubleword(entry)?; let bytes = self.load_bytes(entry + 8, 32)?; let mut input = [0u8; 32]; @@ -461,7 +459,9 @@ mod tests { for (i, hint) in hints.iter().enumerate() { let slot = memory .load_bytes( - PRIVATE_INPUT_START_INDEX + header + HINT_ARENA_HEADER_BYTES + PRIVATE_INPUT_START_INDEX + + header + + HINT_ARENA_HEADER_BYTES + i as u64 * HINT_SLOT_BYTES, HINT_SLOT_BYTES, ) diff --git a/executor/tests/hint_arena_ecrecover.rs b/executor/tests/hint_arena_ecrecover.rs index 1a7980541..0e24eaf2c 100644 --- a/executor/tests/hint_arena_ecrecover.rs +++ b/executor/tests/hint_arena_ecrecover.rs @@ -9,8 +9,8 @@ //! cargo test -p executor --test hint_arena_ecrecover -- --ignored --nocapture use executor::elf::Elf; -use executor::vm::instruction::execution::compute_hint; use executor::vm::execution::Executor; +use executor::vm::instruction::execution::compute_hint; use std::time::Instant; /// Number of ecrecovers the guest performs (3 hint requests each). @@ -98,8 +98,12 @@ fn ecrecover_two_pass_cycles() { ); println!("[ecrecover-hints] N = {N} recoveries"); - println!("[ecrecover-hints] pass 1 (software fallback): {pass1_cycles} cycles in {pass1_time:?}"); - println!("[ecrecover-hints] pass 2 (arena hints): {pass2_cycles} cycles in {pass2_time:?}"); + println!( + "[ecrecover-hints] pass 1 (software fallback): {pass1_cycles} cycles in {pass1_time:?}" + ); + println!( + "[ecrecover-hints] pass 2 (arena hints): {pass2_cycles} cycles in {pass2_time:?}" + ); println!( "[ecrecover-hints] guest cycle ratio pass1/pass2: {:.2}x", pass1_cycles as f64 / pass2_cycles as f64 diff --git a/prover/src/continuation.rs b/prover/src/continuation.rs index 783debf2c..f2490ad6c 100644 --- a/prover/src/continuation.rs +++ b/prover/src/continuation.rs @@ -1433,8 +1433,7 @@ pub fn prove_continuation( let run = || -> Result { #[cfg(feature = "instruments")] let __sp = stark::instruments::span("prove_global"); - let num_private_input_pages = - page::private_input_page_count(private_inputs, hints); + let num_private_input_pages = page::private_input_page_count(private_inputs, hints); // SINGLE source of truth: the same page-base list drives the // committed GLOBAL_MEMORY tables and is shipped in the bundle, // so the two can never diverge in set or order. @@ -1811,8 +1810,14 @@ mod tests { // 16-cycle epoch forces it into a later epoch where x254 is already 2. // Prove first so we can assert the run actually split into >1 epoch — without // this the test would silently pass even if it degraded to a single epoch. - let bundle = - prove_continuation(&elf_bytes, &[], &[], 4, &ProofOptions::default_test_options()).unwrap(); + let bundle = prove_continuation( + &elf_bytes, + &[], + &[], + 4, + &ProofOptions::default_test_options(), + ) + .unwrap(); assert!( bundle.num_epochs() > 1, "16-cycle epochs must split the run into multiple epochs" @@ -2070,8 +2075,14 @@ mod tests { fn test_split_verify_roundtrip() { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("test_commit_split"); - let bundle = - prove_continuation(&elf_bytes, &[], &[], 4, &ProofOptions::default_test_options()).unwrap(); + let bundle = prove_continuation( + &elf_bytes, + &[], + &[], + 4, + &ProofOptions::default_test_options(), + ) + .unwrap(); let out = verify_continuation(&elf_bytes, &bundle, &ProofOptions::default_test_options()) .unwrap(); assert_eq!(out.as_deref(), Some(&[0xAA, 0xBB, 0xCC, 0xDD][..])); @@ -2083,8 +2094,14 @@ mod tests { fn test_continuation_rkyv_roundtrip() { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("test_commit_split"); - let bundle = - prove_continuation(&elf_bytes, &[], &[], 4, &ProofOptions::default_test_options()).unwrap(); + let bundle = prove_continuation( + &elf_bytes, + &[], + &[], + 4, + &ProofOptions::default_test_options(), + ) + .unwrap(); let bytes = rkyv::to_bytes::(&bundle).unwrap(); let restored: ContinuationProof = @@ -2102,8 +2119,14 @@ mod tests { fn test_split_verify_rejects_dropped_last_epoch() { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("all_loadstore_32"); - let mut bundle = - prove_continuation(&elf_bytes, &[], &[], 3, &ProofOptions::default_test_options()).unwrap(); + let mut bundle = prove_continuation( + &elf_bytes, + &[], + &[], + 3, + &ProofOptions::default_test_options(), + ) + .unwrap(); assert!(bundle.epochs.len() >= 3, "need multiple epochs"); bundle.epochs.pop(); assert!( @@ -2120,8 +2143,14 @@ mod tests { fn test_split_verify_rejects_reordered_epochs() { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("all_loadstore_32"); - let mut bundle = - prove_continuation(&elf_bytes, &[], &[], 3, &ProofOptions::default_test_options()).unwrap(); + let mut bundle = prove_continuation( + &elf_bytes, + &[], + &[], + 3, + &ProofOptions::default_test_options(), + ) + .unwrap(); assert!(bundle.epochs.len() >= 3, "need multiple epochs"); bundle.epochs.swap(0, 1); assert!( @@ -2139,8 +2168,14 @@ mod tests { fn test_split_verify_rejects_tampered_register_fini() { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("all_loadstore_32"); - let mut bundle = - prove_continuation(&elf_bytes, &[], &[], 3, &ProofOptions::default_test_options()).unwrap(); + let mut bundle = prove_continuation( + &elf_bytes, + &[], + &[], + 3, + &ProofOptions::default_test_options(), + ) + .unwrap(); assert!( bundle.epochs.len() >= 2, "need a second epoch to chain into" @@ -2162,8 +2197,14 @@ mod tests { fn test_split_verify_rejects_malformed_register_fini_length() { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("all_loadstore_32"); - let mut bundle = - prove_continuation(&elf_bytes, &[], &[], 3, &ProofOptions::default_test_options()).unwrap(); + let mut bundle = prove_continuation( + &elf_bytes, + &[], + &[], + 3, + &ProofOptions::default_test_options(), + ) + .unwrap(); assert!(!bundle.epochs.is_empty()); bundle.epochs[0].reg_fini.pop(); assert!( @@ -2179,8 +2220,14 @@ mod tests { fn test_split_verify_rejects_inflated_epoch_table_count() { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("all_loadstore_32"); - let mut bundle = - prove_continuation(&elf_bytes, &[], &[], 8, &ProofOptions::default_test_options()).unwrap(); + let mut bundle = prove_continuation( + &elf_bytes, + &[], + &[], + 8, + &ProofOptions::default_test_options(), + ) + .unwrap(); bundle.epochs[0].table_counts.cpu += 1; assert!( verify_continuation(&elf_bytes, &bundle, &ProofOptions::default_test_options()) @@ -2203,9 +2250,14 @@ mod tests { let expected = input[4..12].to_vec(); // Smallest epochs (2^2 = 4 cycles) so the short program splits across epochs. - let bundle = - prove_continuation(&elf_bytes, &input, &[], 2, &ProofOptions::default_test_options()) - .unwrap(); + let bundle = prove_continuation( + &elf_bytes, + &input, + &[], + 2, + &ProofOptions::default_test_options(), + ) + .unwrap(); assert!( bundle.num_epochs() > 1, "4-cycle epochs must split the run into multiple epochs" @@ -2242,9 +2294,14 @@ mod tests { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("test_private_input_xpage"); let input: Vec = (0u8..16).collect(); - let mut bundle = - prove_continuation(&elf_bytes, &input, &[], 2, &ProofOptions::default_test_options()) - .unwrap(); + let mut bundle = prove_continuation( + &elf_bytes, + &input, + &[], + 2, + &ProofOptions::default_test_options(), + ) + .unwrap(); assert!( bundle.num_private_input_pages > 0, "baseline must have a touched private-input page" @@ -2276,9 +2333,14 @@ mod tests { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("test_private_input_xpage"); let input: Vec = (0u8..16).collect(); - let mut bundle = - prove_continuation(&elf_bytes, &input, &[], 2, &ProofOptions::default_test_options()) - .unwrap(); + let mut bundle = prove_continuation( + &elf_bytes, + &input, + &[], + 2, + &ProofOptions::default_test_options(), + ) + .unwrap(); assert_eq!( bundle.num_private_input_pages, 1, "16 bytes of private input fits in one page" @@ -2439,8 +2501,14 @@ mod tests { fn test_split_verify_rejects_oversized_num_private_input_pages() { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("all_loadstore_32"); - let mut bundle = - prove_continuation(&elf_bytes, &[], &[], 3, &ProofOptions::default_test_options()).unwrap(); + let mut bundle = prove_continuation( + &elf_bytes, + &[], + &[], + 3, + &ProofOptions::default_test_options(), + ) + .unwrap(); bundle.num_private_input_pages = page::max_private_input_pages() + 1; assert!(matches!( verify_continuation(&elf_bytes, &bundle, &ProofOptions::default_test_options()), @@ -2469,9 +2537,14 @@ mod tests { let expected: [u8; 8] = [0xA1, 0xB2, 0xC3, 0xD4, 0xE5, 0xF6, 0x07, 0x18]; input[commit_off..commit_off + 8].copy_from_slice(&expected); - let bundle = - prove_continuation(&elf_bytes, &input, &[], 4, &ProofOptions::default_test_options()) - .unwrap(); + let bundle = prove_continuation( + &elf_bytes, + &input, + &[], + 4, + &ProofOptions::default_test_options(), + ) + .unwrap(); assert!( bundle.num_private_input_pages >= 2, "input spanning two pages must give >=2 private pages" @@ -2501,8 +2574,14 @@ mod tests { fn test_split_verify_tolerates_reordered_touched_page_bases() { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("all_loadstore_32"); - let mut bundle = - prove_continuation(&elf_bytes, &[], &[], 3, &ProofOptions::default_test_options()).unwrap(); + let mut bundle = prove_continuation( + &elf_bytes, + &[], + &[], + 3, + &ProofOptions::default_test_options(), + ) + .unwrap(); assert!( !bundle.touched_page_bases.is_empty(), "baseline must have touched pages" @@ -2533,8 +2612,14 @@ mod tests { fn test_split_verify_rejects_dropped_touched_page_base() { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("all_loadstore_32"); - let mut bundle = - prove_continuation(&elf_bytes, &[], &[], 3, &ProofOptions::default_test_options()).unwrap(); + let mut bundle = prove_continuation( + &elf_bytes, + &[], + &[], + 3, + &ProofOptions::default_test_options(), + ) + .unwrap(); assert!( !bundle.touched_page_bases.is_empty(), "baseline must have touched pages" @@ -2563,8 +2648,14 @@ mod tests { fn test_split_verify_rejects_non_page_aligned_touched_page_base() { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("all_loadstore_32"); - let mut bundle = - prove_continuation(&elf_bytes, &[], &[], 3, &ProofOptions::default_test_options()).unwrap(); + let mut bundle = prove_continuation( + &elf_bytes, + &[], + &[], + 3, + &ProofOptions::default_test_options(), + ) + .unwrap(); assert!( !bundle.touched_page_bases.is_empty(), "baseline must have touched pages" @@ -2594,8 +2685,14 @@ mod tests { fn test_split_verify_rejects_tampered_l2g_root() { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("all_loadstore_32"); - let mut bundle = - prove_continuation(&elf_bytes, &[], &[], 3, &ProofOptions::default_test_options()).unwrap(); + let mut bundle = prove_continuation( + &elf_bytes, + &[], + &[], + 3, + &ProofOptions::default_test_options(), + ) + .unwrap(); assert!( bundle.epochs.len() >= 2, "need multiple epochs to exercise the binding" @@ -2616,8 +2713,14 @@ mod tests { fn test_continuation_blob_rejects_tampered_l2g_root() { let _ = env_logger::builder().is_test(true).try_init(); let elf_bytes = asm_elf_bytes("all_loadstore_32"); - let mut bundle = - prove_continuation(&elf_bytes, &[], &[], 3, &crate::recursion::MIN_PROOF_OPTIONS).unwrap(); + let mut bundle = prove_continuation( + &elf_bytes, + &[], + &[], + 3, + &crate::recursion::MIN_PROOF_OPTIONS, + ) + .unwrap(); assert!( bundle.epochs.len() >= 2, "need multiple epochs to exercise the binding" diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 61ab82d0c..879a40036 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -53,8 +53,8 @@ use crate::tables::types::BusId; use crate::test_utils::{ E, F, VmAir, create_bitwise_air, create_branch_air, create_bytewise_air, create_commit_air, create_cpu_air, create_cpu32_air, create_decode_air, create_dvrm_air, create_ecdas_air, - create_ecsm_air, create_eq_air, create_halt_air, create_keccak_air, - create_keccak_rc_air, create_keccak_rnd_air, create_load_air, create_lt_air, create_memw_air, + create_ecsm_air, create_eq_air, create_halt_air, create_keccak_air, create_keccak_rc_air, + create_keccak_rnd_air, create_load_air, create_lt_air, create_memw_air, create_memw_aligned_air, create_memw_register_air, create_mul_air, create_page_air, create_register_air, create_shift_air, create_store_air, }; diff --git a/prover/src/tables/page.rs b/prover/src/tables/page.rs index a1784724a..d293cd742 100644 --- a/prover/src/tables/page.rs +++ b/prover/src/tables/page.rs @@ -183,7 +183,9 @@ impl PageConfig { /// /// [`Memory::store_private_inputs`]: executor::vm::memory::Memory::store_private_inputs pub(crate) fn private_input_page_count(private_inputs: &[u8], hints: &[[u8; 32]]) -> usize { - use executor::vm::memory::{HINT_ARENA_HEADER_BYTES, HINT_SLOT_BYTES, hint_arena_header_offset}; + use executor::vm::memory::{ + HINT_ARENA_HEADER_BYTES, HINT_SLOT_BYTES, hint_arena_header_offset, + }; if private_inputs.is_empty() && hints.is_empty() { return 0; } diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 3dffde11f..7c4e8456c 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -3413,9 +3413,13 @@ fn build_traces( // Continuation epochs (l2g_memory_bookend) skip PAGE: the L2G table owns // every touched cell's Memory init/fini, and every untouched PAGE row // self-cancels (init==fini, ts=0), so PAGE contributes nothing here. - Some(image) if !l2g_memory_bookend => { - generate_page_tables(image, memory_state, private_input, hints, l2g_memory_bookend) - } + Some(image) if !l2g_memory_bookend => generate_page_tables( + image, + memory_state, + private_input, + hints, + l2g_memory_bookend, + ), _ => (Vec::new(), Vec::new()), }; let gen_register = || register::generate_register_trace(®ister_final_state, register_init); @@ -3671,8 +3675,7 @@ pub fn count_table_lengths( let decode_rows = (instructions.len() as u64 + 1).next_power_of_two().max(2); // Memory + register state for partition predicates that need timestamps. - let mut memory_state = - MemoryState::from_image(&build_initial_image(elf, private_input, hints)); + let mut memory_state = MemoryState::from_image(&build_initial_image(elf, private_input, hints)); let mut register_state = RegisterState::new(elf.entry_point); // Raw counts (pre-chunking + pre-padding). diff --git a/prover/src/tests/count_table_lengths_drift_tests.rs b/prover/src/tests/count_table_lengths_drift_tests.rs index 4fad8cdae..8605960f9 100644 --- a/prover/src/tests/count_table_lengths_drift_tests.rs +++ b/prover/src/tests/count_table_lengths_drift_tests.rs @@ -11,8 +11,8 @@ fn assert_count_table_lengths_matches(elf: &Elf, logs: &[Log]) { let predicted = count_table_lengths(elf, logs, &max_rows, &[], &[]).expect("count_table_lengths succeeds"); - let traces = - Traces::from_elf_and_logs_minimal(elf, logs, &max_rows, &[], &[]).expect("trace build succeeds"); + let traces = Traces::from_elf_and_logs_minimal(elf, logs, &max_rows, &[], &[]) + .expect("trace build succeeds"); let sum_heights = |tables: &[stark::trace::TraceTable<_, _>]| -> u64 { tables.iter().map(|t| t.main_table.height as u64).sum() diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index b80df3216..e3db3e2c4 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -1076,8 +1076,8 @@ fn test_prove_elfs_keccak_multi_call() { let elf_bytes = crate::test_utils::asm_elf_bytes("test_keccak_multi"); let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); - let executor = - executor::vm::execution::Executor::new(&elf, vec![], &[]).expect("Failed to create executor"); + let executor = executor::vm::execution::Executor::new(&elf, vec![], &[]) + .expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); // The guest initializes lane[i] = i + 1 and applies keccak-f[1600] three times. @@ -1098,7 +1098,8 @@ fn test_prove_elfs_keccak_multi_call() { ); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]) + .unwrap(); assert_eq!( traces.public_output_bytes, result.return_values.memory_values @@ -1116,8 +1117,8 @@ fn test_prove_elfs_ecsm() { let elf_bytes = crate::test_utils::asm_elf_bytes("test_ecsm"); let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); - let executor = - executor::vm::execution::Executor::new(&elf, vec![], &[]).expect("Failed to create executor"); + let executor = executor::vm::execution::Executor::new(&elf, vec![], &[]) + .expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); // The guest computes 5·G and commits the 32-byte x-coordinate; cross-check it against @@ -1138,7 +1139,8 @@ fn test_prove_elfs_ecsm() { ); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]) + .unwrap(); assert!( prove_and_verify_vm_minimal(&elf, &mut traces), "ECSM prove/verify failed" @@ -1151,8 +1153,8 @@ fn test_prove_elfs_ecsm_multi() { let elf_bytes = crate::test_utils::asm_elf_bytes("test_ecsm_multi"); let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); - let executor = - executor::vm::execution::Executor::new(&elf, vec![], &[]).expect("Failed to create executor"); + let executor = executor::vm::execution::Executor::new(&elf, vec![], &[]) + .expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); // Gx little-endian. @@ -1177,7 +1179,8 @@ fn test_prove_elfs_ecsm_multi() { ); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]) + .unwrap(); assert!( prove_and_verify_vm_minimal(&elf, &mut traces), "ECSM multi-call prove/verify failed" @@ -1321,8 +1324,8 @@ fn test_prove_ecrecover_hints_auto_records_arena() { // The recording pass answers the guest's requests (sqrt + batched field // inverse + scalar inverse per recovery, in that order). - let hints = executor::vm::execution::collect_hints(&program, input.clone()) - .expect("collect_hints"); + let hints = + executor::vm::execution::collect_hints(&program, input.clone()).expect("collect_hints"); assert_eq!(hints.len(), 6, "2 recoveries × 3 hint requests"); // The policy under test: no explicit arena ⇒ auto-record ⇒ the SAME @@ -1354,11 +1357,12 @@ fn test_prove_elfs_ecsm_forged_result_rejected() { let elf_bytes = crate::test_utils::asm_elf_bytes("test_ecsm"); let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); - let executor = - executor::vm::execution::Executor::new(&elf, vec![], &[]).expect("Failed to create executor"); + let executor = executor::vm::execution::Executor::new(&elf, vec![], &[]) + .expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]) + .unwrap(); // Forge the low byte of xR on the (single) real ECSM row. let orig = *traces.ecsm.main_table.get(0, ecsm_cols::xr(0)); @@ -1382,11 +1386,12 @@ fn test_prove_elfs_ecsm_forged_ecdas_mu_rejected() { let elf_bytes = crate::test_utils::asm_elf_bytes("test_ecsm"); let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); - let executor = - executor::vm::execution::Executor::new(&elf, vec![], &[]).expect("Failed to create executor"); + let executor = executor::vm::execution::Executor::new(&elf, vec![], &[]) + .expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]) + .unwrap(); // Row 0 is a real ECDAS step (µ=1); forge µ to a non-boolean value. traces.ecdas.main_table.set( @@ -1418,11 +1423,12 @@ fn test_prove_elfs_keccak_unaligned_state_addr() { let elf_bytes = crate::test_utils::asm_elf_bytes("test_keccak_multi"); let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); - let executor = - executor::vm::execution::Executor::new(&elf, vec![], &[]).expect("Failed to create executor"); + let executor = executor::vm::execution::Executor::new(&elf, vec![], &[]) + .expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]) + .unwrap(); // Tamper the first real keccak row: replace addr(1) (a byte cell) with a // value outside [0, 256). The new ARE_BYTES bus sender will emit this @@ -1444,8 +1450,8 @@ fn test_prove_elfs_keccak_unaligned_state_addr() { fn test_prove_elfs_test_commit_4() { let elf_bytes = crate::test_utils::asm_elf_bytes("test_commit_4"); let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); - let executor = - executor::vm::execution::Executor::new(&elf, vec![], &[]).expect("Failed to create executor"); + let executor = executor::vm::execution::Executor::new(&elf, vec![], &[]) + .expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); // Verify public output matches the committed bytes [0xAA, 0xBB, 0xCC, 0xDD] @@ -1456,7 +1462,8 @@ fn test_prove_elfs_test_commit_4() { ); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]) + .unwrap(); assert_eq!( traces.public_output_bytes, result.return_values.memory_values @@ -1478,11 +1485,12 @@ fn test_prove_elfs_test_commit_4_wrong_pages_rejected() { let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); let proof_options = ProofOptions::default_test_options(); - let executor = - executor::vm::execution::Executor::new(&elf, vec![], &[]).expect("Failed to create executor"); + let executor = executor::vm::execution::Executor::new(&elf, vec![], &[]) + .expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]) + .unwrap(); // Prover uses correct page configs let table_counts = traces.table_counts(); @@ -2233,11 +2241,12 @@ fn test_deep_stack_runtime_pages_roundtrip() { let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); let proof_options = ProofOptions::default_test_options(); - let executor = - executor::vm::execution::Executor::new(&elf, vec![], &[]).expect("Failed to create executor"); + let executor = executor::vm::execution::Executor::new(&elf, vec![], &[]) + .expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]) + .unwrap(); let runtime_page_ranges = traces.runtime_page_ranges(); let table_counts = traces.table_counts(); @@ -2315,11 +2324,12 @@ fn test_deep_stack_missing_pages_rejected() { let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); let proof_options = ProofOptions::default_test_options(); - let executor = - executor::vm::execution::Executor::new(&elf, vec![], &[]).expect("Failed to create executor"); + let executor = executor::vm::execution::Executor::new(&elf, vec![], &[]) + .expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]) + .unwrap(); // Prover uses correct page configs (auto-detected from MemoryState) let table_counts = traces.table_counts(); @@ -2417,11 +2427,12 @@ fn test_heap_alloc_runtime_pages_roundtrip() { let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); let proof_options = ProofOptions::default_test_options(); - let executor = - executor::vm::execution::Executor::new(&elf, vec![], &[]).expect("Failed to create executor"); + let executor = executor::vm::execution::Executor::new(&elf, vec![], &[]) + .expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]).unwrap(); + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[], &[]) + .unwrap(); let runtime_page_ranges = traces.runtime_page_ranges(); let table_counts = traces.table_counts(); @@ -2808,8 +2819,8 @@ fn test_pure_commit_rust() { #[test] fn test_prove_with_input_empty() { let elf_bytes = crate::test_utils::asm_elf_bytes("sub"); - let result = - crate::prove_with_inputs(&elf_bytes, &[], &[]).expect("prove_with_inputs should succeed on sub"); + let result = crate::prove_with_inputs(&elf_bytes, &[], &[]) + .expect("prove_with_inputs should succeed on sub"); assert!( crate::verify(&result, &elf_bytes).expect("verify should not error"), "prove_with_inputs(empty) proof should verify" diff --git a/prover/src/tests/trace_builder_tests.rs b/prover/src/tests/trace_builder_tests.rs index 3acdd8bf7..b2332b0bf 100644 --- a/prover/src/tests/trace_builder_tests.rs +++ b/prover/src/tests/trace_builder_tests.rs @@ -837,7 +837,11 @@ fn test_from_image_and_logs_matches_from_elf_and_logs() { let elf_bytes = asm_elf_bytes("basic_program"); let program = Elf::load(&elf_bytes).unwrap(); - let logs = Executor::new(&program, vec![], &[]).unwrap().run().unwrap().logs; + let logs = Executor::new(&program, vec![], &[]) + .unwrap() + .run() + .unwrap() + .logs; let max_rows = MaxRowsConfig::default(); let from_elf = Traces::from_elf_and_logs( diff --git a/prover/tests/calibration.rs b/prover/tests/calibration.rs index 310d162eb..d9789742b 100644 --- a/prover/tests/calibration.rs +++ b/prover/tests/calibration.rs @@ -32,8 +32,8 @@ fn peak_bytes_does_not_underestimate_measured_heap() { let elf_bytes = asm_elf_bytes("fib_iterative_372k"); let max_rows = MaxRowsConfig::default(); - let lengths = - count_table_lengths(&elf, &logs, &max_rows, &[], &[]).expect("count_table_lengths succeeds"); + let lengths = count_table_lengths(&elf, &logs, &max_rows, &[], &[]) + .expect("count_table_lengths succeeds"); let opts = GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 is valid"); let predicted = @@ -56,8 +56,8 @@ fn peak_bytes_does_not_underestimate_measured_heap() { }) }; - let _proof = - prove_with_options_and_inputs(&elf_bytes, &[], &[], &opts, &max_rows).expect("proof succeeds"); + let _proof = prove_with_options_and_inputs(&elf_bytes, &[], &[], &opts, &max_rows) + .expect("proof succeeds"); stop.store(true, Ordering::Relaxed); sampler.join().expect("sampler joins"); diff --git a/prover/tests/ecrecover_hints_continuation.rs b/prover/tests/ecrecover_hints_continuation.rs index 4147f8ddc..5ad471982 100644 --- a/prover/tests/ecrecover_hints_continuation.rs +++ b/prover/tests/ecrecover_hints_continuation.rs @@ -31,7 +31,11 @@ fn ecrecover_arena_continuation_proof() { ); let hints_bin = std::fs::read(dir.join("ecrecover_hints.hints.bin")) .expect("ecrecover_hints.hints.bin missing — run the executor driver"); - assert_eq!(hints_bin.len() % 32, 0, "hints fixture must be 32-byte slots"); + assert_eq!( + hints_bin.len() % 32, + 0, + "hints fixture must be 32-byte slots" + ); let hints: Vec<[u8; 32]> = hints_bin .chunks_exact(32) .map(|c| c.try_into().unwrap()) diff --git a/syscalls/src/syscalls.rs b/syscalls/src/syscalls.rs index 54a2cb88e..697212b05 100644 --- a/syscalls/src/syscalls.rs +++ b/syscalls/src/syscalls.rs @@ -291,8 +291,6 @@ pub fn request_hint(_hint_id: usize, _input: &[u8; 32]) -> Option<[u8; 32]> { unimplemented!("syscalls are only implemented for riscv64 targets"); } - - #[cfg(target_arch = "riscv64")] pub fn sys_halt() -> ! { // NOTE: no print_string here — the Print ecall is unmatched on the Ecall bus From 3ad5f771703306fc85e135f49a0f0be257e0a1a1 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 26 Aug 2026 18:02:35 -0300 Subject: [PATCH 05/15] Update gpu_force_downgrade to the three-argument prove_with_inputs --- prover/tests/gpu_force_downgrade.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/prover/tests/gpu_force_downgrade.rs b/prover/tests/gpu_force_downgrade.rs index b1d8cc897..86295983c 100644 --- a/prover/tests/gpu_force_downgrade.rs +++ b/prover/tests/gpu_force_downgrade.rs @@ -33,7 +33,9 @@ fn forced_downgrade_prove_verifies() { .expect("need ethrex.elf — run `make compile-programs-rust`"); let input = std::fs::read(ws.join("executor/tests/ethrex_simple_tx.bin")).expect("fixture"); - let proof = lambda_vm_prover::prove_with_inputs(&elf, &input).expect("prove"); + // No caller-supplied arena: `prove_with_inputs` resolves this guest's hints + // itself via `resolve_hints`, so an empty slice is the normal path here. + let proof = lambda_vm_prover::prove_with_inputs(&elf, &input, &[]).expect("prove"); assert!( stark::gpu_lde::gpu_device_only_downgrades() > 0, "no table took the forced downgrade — the hook or the device-only gate moved" From 3b77a6f1eb1457a1eb1e9d861812a760eb054d52 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 26 Aug 2026 18:13:57 -0300 Subject: [PATCH 06/15] Unify ecrecover_hints' ethrex pin at 797df554 --- crypto/ethrex-crypto/Cargo.lock | 4 ++-- executor/programs/rust/ecrecover_hints/Cargo.lock | 4 ++-- executor/programs/rust/ecrecover_hints/Cargo.toml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crypto/ethrex-crypto/Cargo.lock b/crypto/ethrex-crypto/Cargo.lock index 0cdd80692..47fe0bbbd 100644 --- a/crypto/ethrex-crypto/Cargo.lock +++ b/crypto/ethrex-crypto/Cargo.lock @@ -342,8 +342,8 @@ dependencies = [ [[package]] name = "ethrex-crypto" -version = "22.0.0" -source = "git+https://github.com/lambdaclass/ethrex.git?rev=4f658c2b3d10e3f21d35ce546870f55ca3f940fc#4f658c2b3d10e3f21d35ce546870f55ca3f940fc" +version = "23.0.0" +source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd" dependencies = [ "ark-bn254", "ark-ec", diff --git a/executor/programs/rust/ecrecover_hints/Cargo.lock b/executor/programs/rust/ecrecover_hints/Cargo.lock index 652ee8e28..72ac688fe 100644 --- a/executor/programs/rust/ecrecover_hints/Cargo.lock +++ b/executor/programs/rust/ecrecover_hints/Cargo.lock @@ -351,8 +351,8 @@ dependencies = [ [[package]] name = "ethrex-crypto" -version = "22.0.0" -source = "git+https://github.com/lambdaclass/ethrex.git?rev=4f658c2b3d10e3f21d35ce546870f55ca3f940fc#4f658c2b3d10e3f21d35ce546870f55ca3f940fc" +version = "23.0.0" +source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd" dependencies = [ "ark-bn254", "ark-ec", diff --git a/executor/programs/rust/ecrecover_hints/Cargo.toml b/executor/programs/rust/ecrecover_hints/Cargo.toml index fffe10138..82d0fc40e 100644 --- a/executor/programs/rust/ecrecover_hints/Cargo.toml +++ b/executor/programs/rust/ecrecover_hints/Cargo.toml @@ -13,4 +13,4 @@ lambda-vm-syscalls = { path = "../../../../syscalls" } lambda-vm-ethrex-crypto = { path = "../../../../crypto/ethrex-crypto" } # The `Crypto` trait the guest calls through. Same rev + default-features=false # as lambda-vm-ethrex-crypto's own dep, so feature unification adds nothing. -ethrex-crypto = { git = "https://github.com/lambdaclass/ethrex.git", rev = "4f658c2b3d10e3f21d35ce546870f55ca3f940fc", package = "ethrex-crypto", default-features = false } +ethrex-crypto = { git = "https://github.com/lambdaclass/ethrex.git", rev = "797df5540c7d35cafd69b6971a74b2a49c67d1dd", package = "ethrex-crypto", default-features = false } From 3db57089c82b1f62ac8bb40c054cb96d29df677f Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 27 Aug 2026 09:59:51 -0300 Subject: [PATCH 07/15] Update the ethrex-tests call site to the three-argument Executor::new --- tooling/ethrex-tests/tests/ethrex.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tooling/ethrex-tests/tests/ethrex.rs b/tooling/ethrex-tests/tests/ethrex.rs index 9ad1ee786..c3dd9a343 100644 --- a/tooling/ethrex-tests/tests/ethrex.rs +++ b/tooling/ethrex-tests/tests/ethrex.rs @@ -20,7 +20,7 @@ fn run_program_without_expect( let elf_data = std::fs::read(elf_path).unwrap(); let program = Elf::load(&elf_data).unwrap(); println!("Program entry: 0x{:016x}", program.entry_point); - let mut executor = Executor::new(&program, private_inputs)?; + let mut executor = Executor::new(&program, private_inputs, &[])?; while let Some(_logs) = executor.resume()? {} executor.finish() } From 4dcab54d0c255a64341542fe3b521ad2eeaab4cf Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 25 Aug 2026 13:26:12 -0300 Subject: [PATCH 08/15] Answer hint requests during the run instead of requiring a recording pass. request_hint now publishes its (hint_id, input) entry to the request log before reading the arena slot, and the store that bumps the log's count word is the executor's cue to answer it: it computes the hint and seeds the slot as an INITIAL value, on a cell nobody has touched. That needs no memory write in the trace - a write would have to be justified by a chip, an initial value does not - and it is the same statement as shipping the slot in the private input up front, because the region's bytes always stay exactly encode_private_input_region(input, arena). A test pins that invariant. The bytes stay unconstrained and untrusted: the guest verifies them and falls back to software on failure, exactly as before. A slot an explicit arena already covers is left alone, so passing hints up front keeps working unchanged, and Memory::silence_hints answers nothing so the software-fallback path stays measurable and testable. ExecutionResult now reports the arena the run actually used, and the monolithic prove path takes it from the single execution it already performs instead of calling resolve_hints, which is left as the continuation prover's pre-pass since that path freezes its initial image and provenance before streaming epochs. That pre-pass now drains its logs rather than collecting them: reading back an arena does not need tens of millions of Log entries at 40 bytes per cycle on a path whose whole purpose is bounding memory. Measured on 30 ecrecovers: the proved trace goes from 866,413 to 868,423 cycles (+0.23%, the request-log stores) while total executed cycles drop from 7,280,839 across two runs to 868,423 in one. An arena decided mid-run produces a trace identical cycle for cycle to one supplied up front, and both proofs verify with the same public output. Also reserves fallibly in Memory::hint_requests, whose capacity comes from a guest-written count word, and derives HINT_LOG_START from PRIVATE_INPUT_START and MAX_PRIVATE_INPUT_SIZE instead of restating 512 MiB. --- bin/cli/src/main.rs | 13 +- crypto/ethrex-crypto/src/lib.rs | 11 +- executor/src/vm/execution.rs | 52 +++-- executor/src/vm/memory.rs | 302 ++++++++++++++++++++++++- executor/tests/hint_arena_ecrecover.rs | 92 +++++--- prover/src/lib.rs | 45 ++-- prover/src/tests/prove_elfs_tests.rs | 39 ++-- syscalls/src/syscalls.rs | 85 ++++--- 8 files changed, 512 insertions(+), 127 deletions(-) diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index 6c9f02ce4..36d567712 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -154,10 +154,11 @@ enum Commands { #[arg(long, value_hint = ValueHint::FilePath, conflicts_with = "flamegraph")] hints: Option, - /// Recording pass of the two-pass hint flow: run once with an empty - /// hint arena, answer the guest's logged hint requests host-side, and - /// write the resulting arena (concatenated 32-byte slots) to this file - /// for use with --hints on the second, provable run. + /// Run the guest once and write the hint arena it produced + /// (concatenated 32-byte slots) to this file, for later use with + /// --hints. Requests are answered during that run, so this is the + /// cheap hinted path, not the software-fallback one; the file is only + /// needed to reuse an arena across runs. #[arg( long, value_hint = ValueHint::FilePath, @@ -465,8 +466,8 @@ fn cmd_execute( } }; - // Recording pass of the two-pass hint flow: run hint-free, answer the - // guest's logged requests host-side, and write the arena for --hints runs. + // Run once and dump the arena the executor built answering the guest's + // requests, for --hints runs that want to reuse it. if let Some(record_path) = record_hints_path { let hints = match executor::vm::execution::collect_hints(&program, private_inputs) { Ok(hints) => hints, diff --git a/crypto/ethrex-crypto/src/lib.rs b/crypto/ethrex-crypto/src/lib.rs index 4a3206b82..71ff99f17 100644 --- a/crypto/ethrex-crypto/src/lib.rs +++ b/crypto/ethrex-crypto/src/lib.rs @@ -65,10 +65,11 @@ impl Crypto for LambdaVmEcsmCrypto { // ── ECDSA secp256k1 recovery via the ECSM precompile ──────────────────────── /// Fetch a hint for `(hint_id, x_be)` from the private-input hint arena -/// (positional — one slot per request). On an exhausted arena the request is -/// appended to the guest's hint request log (the recording pass of the -/// two-pass hint flow) and zeros are returned, which fail the caller's -/// in-guest verify and trigger its software fallback. +/// (positional — one slot per request). The request is published to the guest's +/// hint request log and the executor answers it in the same run by seeding the +/// arena slot this call then reads. When nobody answers, the slot reads back as +/// zeros, which fail the caller's in-guest verify and trigger its software +/// fallback. /// /// The hint is UNTRUSTED — the prover chooses the arena bytes, so every caller /// MUST verify it in-guest (`x·inv == 1`, `y² == x³+7`) AND recompute in @@ -79,7 +80,7 @@ impl Crypto for LambdaVmEcsmCrypto { /// the fallback that closes that hole. #[cfg(target_arch = "riscv64")] fn get_hint(hint_id: usize, x_be: &[u8; 32]) -> [u8; 32] { - lambda_vm_syscalls::syscalls::request_hint(hint_id, x_be).unwrap_or([0u8; 32]) + lambda_vm_syscalls::syscalls::request_hint(hint_id, x_be) } /// Scalar-field inverse `x⁻¹ mod n`. diff --git a/executor/src/vm/execution.rs b/executor/src/vm/execution.rs index 3e5fe6b07..f1edc75c0 100644 --- a/executor/src/vm/execution.rs +++ b/executor/src/vm/execution.rs @@ -25,10 +25,15 @@ pub struct ExecutionResult { /// Predecoded instructions map (pc -> instruction) /// Use this to look up instructions by their PC from the logs pub instructions: U64HashMap, - /// `(hint_id, input)` pairs the guest appended to the hint request log - /// (arena exhausted), in request order — the recording pass of the two-pass - /// hint flow. Empty when the arena covered every request. + /// `(hint_id, input)` pairs the guest appended to the hint request log, in + /// request order. Every one of them was answered during this run; this is a + /// measurement view, not work left over. pub hint_requests: Vec<(u64, [u8; 32])>, + /// The hint arena this run actually used: the `hints` passed to + /// [`Executor::new`] plus every slot the executor seeded on demand. The + /// prover MUST pass this to the trace builder — it is what the private-input + /// region's bytes encode, and therefore what the initial image has to hold. + pub hints: Vec<[u8; 32]>, } /// Size of each log chunk - balances memory usage vs callback overhead @@ -110,6 +115,13 @@ impl Executor { &self.memory } + /// Run without answering any hint request, so the guest recomputes every + /// hint in software. Measurement and test hook — see + /// [`Memory::silence_hints`]. + pub fn silence_hints(&mut self) { + self.memory.silence_hints(); + } + /// Resume execution, running at most `limit` cycles, and return the logs /// produced. Returns None when the program is finished. pub fn resume_with_limit(&mut self, limit: usize) -> Result, ExecutorError> { @@ -172,6 +184,7 @@ impl Executor { logs, instructions: self.instructions.into_instruction_map(), hint_requests: self.memory.hint_requests()?, + hints: self.memory.hint_arena().to_vec(), }) } @@ -199,29 +212,26 @@ impl Executor { } } -/// Record the guest's hint requests and answer them — the host half of the -/// two-pass hint flow for guests whose hints are not known before the run. +/// Run the program and return the hint arena it used, for callers that need the +/// arena BEFORE they start proving — today only the continuation prover, which +/// freezes its initial image and provenance before streaming epochs and so +/// cannot take the arena the run itself produces. +/// +/// Every request is answered inline (see `Memory::answer_hint_request`), so this +/// run takes the same cheap in-guest-verify path the proved run will: it is not +/// the software-fallback path. The logs are drained rather than collected — +/// nothing here needs them, and a real block would otherwise materialize tens of +/// millions of `Log`s just to read an arena. /// -/// The program runs once with an empty hint arena: every -/// `syscalls::syscalls::request_hint` misses, is appended to the guest's -/// request log, and the guest recomputes in software (correct, just slower). -/// Each logged `(hint_id, input)` is then answered with -/// [`crate::vm::instruction::execution::compute_hint`], and the answers — in -/// request order — are returned as the hint arena for the second, provable -/// pass: pass them as `hints` to [`Executor::new`] (or the prover's `prove_*` -/// functions) together with the same program and private input. Both passes -/// produce the same committed output; the arena only changes how cheaply the -/// guest gets there. A guest that requests nothing yields an empty arena. +/// The monolithic prove path does NOT use this: it takes `ExecutionResult::hints` +/// from the single run it already performs. pub fn collect_hints( program: &Elf, private_inputs: Vec, ) -> Result, ExecutorError> { - let result = Executor::new(program, private_inputs, &[])?.run()?; - Ok(result - .hint_requests - .iter() - .map(|(hint_id, input)| crate::vm::instruction::execution::compute_hint(*hint_id, input)) - .collect()) + let mut executor = Executor::new(program, private_inputs, &[])?; + while executor.resume()?.is_some() {} + Ok(executor.memory.hint_arena().to_vec()) } fn load_program(segments: &[crate::elf::Segment], memory: &mut Memory) -> Result<(), MemoryError> { diff --git a/executor/src/vm/memory.rs b/executor/src/vm/memory.rs index b19f5a6e7..3f2c99cae 100644 --- a/executor/src/vm/memory.rs +++ b/executor/src/vm/memory.rs @@ -64,9 +64,10 @@ pub const HINT_ARENA_HEADER_BYTES: u64 = 8; /// Start of the hint request log: just past the reserved private-input window, /// rounded up to 8 (`0xFF000000 + 4 + 512 MiB + 4` = `0x11F000008`). The guest -/// appends `(hint_id, input)` entries here when the hint arena is exhausted — -/// the recording pass of the two-pass hint flow. The host reads them back with -/// [`Memory::hint_requests`]. Must match `HINT_LOG_START` in +/// appends a `(hint_id, input)` entry here for every hint it wants, and the +/// store that bumps the count word is what makes the executor answer it — +/// see [`Memory::answer_hint_request`]. [`Memory::hint_requests`] reads the log +/// back for measurement. Must match `HINT_LOG_START` in /// `syscalls/src/syscalls.rs`. pub const HINT_LOG_START_INDEX: u64 = 0xFF000000 + PRIVATE_INPUT_LENGTH_PREFIX_BYTES as u64 + MAX_PRIVATE_INPUT_SIZE + 4; @@ -130,6 +131,24 @@ pub struct Memory { /// onto the Commit bus by `index`), so this buffer is purely the /// executor's view used by `read_return_value` and CLI display. public_output: Vec, + /// The hint arena as it actually stands in this region: slot `i` is what the + /// guest reads for request `i`. Seeded from the host-supplied `hints` in + /// [`Memory::store_private_inputs`] and extended in place by + /// [`Memory::answer_hint_request`] when the guest logs a request the arena + /// does not already cover. + /// + /// This is the arena the PROVER must put in the initial image: the region's + /// bytes are always exactly `encode_private_input_region(input, hint_arena)`, + /// which is what makes the seeded values legal — a value nobody has touched + /// yet is prover-chosen page data, and deciding it mid-run is the same + /// statement as deciding it up front. + hint_arena: Vec<[u8; 32]>, + /// When set, hint requests go unanswered: the guest reads zeros, its + /// in-guest verify rejects them, and it recomputes in software. Off in + /// production (the default is to answer); [`Memory::silence_hints`] turns it + /// on for the fallback measurement baseline and for the "a host that answers + /// nothing cannot change the result" test arm. + hints_silenced: bool, } impl Memory { @@ -189,6 +208,12 @@ impl Memory { self.store_byte(address + i as u64, *b); } } + // The guest completes a hint-request log entry by bumping the count word, + // and that store is the executor's cue to answer it (see + // `answer_hint_request`). One comparison on the word-store path. + if address == HINT_LOG_START_INDEX { + self.answer_hint_request(value)?; + } Ok(()) } @@ -288,12 +313,19 @@ impl Memory { Ok(self.public_output.clone()) } - /// Read the hint request log the guest appended via `request_hint` (the - /// recording pass of the two-pass hint flow): `(hint_id, input)` entries in - /// request order. Empty when the arena covered every request. + /// Read the hint request log the guest appended via `request_hint`: + /// `(hint_id, input)` entries in request order. Every entry has already been + /// answered by [`Memory::answer_hint_request`] — this is a debugging / + /// measurement view of what the guest asked for, not a work queue. + /// + /// The count word is guest-written memory, so the capacity it implies is + /// reserved fallibly (same discipline as [`Memory::load_bytes`]) rather than + /// letting a bogus count abort the host on allocation. pub fn hint_requests(&self) -> Result, MemoryError> { let count = self.load_word(HINT_LOG_START_INDEX)? as usize; - let mut out = Vec::with_capacity(count); + let mut out = Vec::new(); + out.try_reserve_exact(count) + .map_err(|_| MemoryError::AllocationFailed)?; for i in 0..count { let entry = HINT_LOG_START_INDEX + HINT_LOG_HEADER_BYTES + i as u64 * HINT_LOG_ENTRY_BYTES; @@ -306,6 +338,109 @@ impl Memory { Ok(out) } + /// The hint arena as it stands: slot `i` is what the guest reads for request + /// `i`. Equal to the `hints` passed to [`Memory::store_private_inputs`] plus + /// everything [`Memory::answer_hint_request`] seeded during the run. The + /// prover must pass THIS to the trace builder — it is the arena the region's + /// bytes actually encode. + pub fn hint_arena(&self) -> &[[u8; 32]] { + &self.hint_arena + } + + /// Stop answering hint requests, so the guest falls back to software for + /// every one of them. For measurement and for the tests that pin "an + /// unanswered hint cannot change the result" — not a production path. + pub fn silence_hints(&mut self) { + self.hints_silenced = true; + } + + /// Answer the hint request the guest just completed by bumping the log's + /// count word to `new_count`. + /// + /// This is the whole one-pass mechanism: entry `new_count - 1` names a + /// `(hint_id, input)`, and the guest's very next instructions read arena slot + /// `new_count - 1`. That slot has never been touched, so its value is still + /// the prover's to choose — seeding it here is the same statement as having + /// shipped it in the private input up front, and it needs no memory write in + /// the trace (a write would have to be justified by a chip; an initial value + /// does not). The seeded bytes stay unconstrained and untrusted: the guest + /// verifies them and falls back to software on failure. + /// + /// A slot the caller already supplied is left alone — an explicitly passed + /// arena wins, and the guest reads exactly what the caller shipped. + /// + /// One sharp edge, documented on the guest side too (`syscalls::hint_count`): + /// seeding also moves the arena's count word, so a guest must not read that + /// word (via `hint_count`/`next_hint`) BEFORE making on-demand requests — the + /// trace would carry the pre-bump value while the image carries the final + /// one. No guest mixes the two styles today. Closing it properly wants a mode + /// flag in the header's spare pad word rather than a convention. + fn answer_hint_request(&mut self, new_count: u32) -> Result<(), MemoryError> { + if self.hints_silenced { + return Ok(()); + } + let Some(index) = new_count.checked_sub(1) else { + return Ok(()); // count reset to 0: nothing completed + }; + let index = index as usize; + if index < self.hint_arena.len() { + return Ok(()); // already covered by the host-supplied arena + } + if index != self.hint_arena.len() { + // The count word skipped ahead: entries and slots are positional, so + // guessing which request this is would silently desync the arena. + return Err(MemoryError::HintRequestOutOfOrder); + } + + let entry = + HINT_LOG_START_INDEX + HINT_LOG_HEADER_BYTES + index as u64 * HINT_LOG_ENTRY_BYTES; + let hint_id = self.load_doubleword(entry)?; + let mut input = [0u8; 32]; + input.copy_from_slice(&self.load_bytes(entry + 8, 32)?); + + let value = crate::vm::instruction::execution::compute_hint(hint_id, &input); + self.seed_hint_slot(index as u64, &value)?; + self.hint_arena.push(value); + + // Keep the arena header's count word in step with the slots that exist, so + // the region stays byte-identical to `encode_private_input_region(input, + // hint_arena)` — the image the prover will build. + let header = PRIVATE_INPUT_START_INDEX + self.hint_arena_header_offset()?; + self.cells + .insert(header, (self.hint_arena.len() as u32).to_le_bytes()); + Ok(()) + } + + /// Write `value` into arena slot `index` as an INITIAL value: eight aligned + /// word cells, no log entry, no timestamp. Only legal on cells nobody has + /// touched — a value that is going to be presented as "there since timestamp + /// 0" must not have a prior history in the trace. + fn seed_hint_slot(&mut self, index: u64, value: &[u8; 32]) -> Result<(), MemoryError> { + let base = PRIVATE_INPUT_START_INDEX + + self.hint_arena_header_offset()? + + HINT_ARENA_HEADER_BYTES + + index * HINT_SLOT_BYTES; + for w in 0..8u64 { + if self.cells.contains_key(&(base + 4 * w)) { + return Err(MemoryError::HintSlotAlreadyWritten); + } + } + for w in 0..8usize { + let mut word = [0u8; 4]; + word.copy_from_slice(&value[4 * w..4 * w + 4]); + self.cells.insert(base + 4 * w as u64, word); + } + Ok(()) + } + + /// Offset of the arena header from `PRIVATE_INPUT_START_INDEX`, derived from + /// the stored length prefix — the same derivation the guest does, so both + /// agree on where slot 0 lives. + fn hint_arena_header_offset(&self) -> Result { + let main_len = self.load_word(PRIVATE_INPUT_START_INDEX)? as u64; + Ok(hint_arena_header_offset(main_len)) + } + /// Pre-loads private input bytes at `PRIVATE_INPUT_START_INDEX` in the /// canonical wire format ([`encode_private_input_region`]): a 4-byte LE /// length prefix, the main data zero-padded to an 8-byte boundary, then the @@ -321,6 +456,7 @@ impl Memory { inputs: Vec, hints: &[[u8; 32]], ) -> Result<(), MemoryError> { + self.hint_arena = hints.to_vec(); if inputs.is_empty() && hints.is_empty() { return Ok(()); } @@ -379,6 +515,10 @@ pub enum MemoryError { AddressOverflow, #[error("Failed to allocate memory for load_bytes")] AllocationFailed, + #[error("Hint request log count skipped ahead: requests and arena slots are positional")] + HintRequestOutOfOrder, + #[error("Hint arena slot was already written: it cannot be seeded as an initial value")] + HintSlotAlreadyWritten, } #[cfg(test)] @@ -545,6 +685,154 @@ mod tests { ); } + // Writes request `index` into the log the way the guest does: the entry + // first, then the count word — whose store is what makes the executor answer. + fn write_hint_request(memory: &mut Memory, index: u64, hint_id: u64, input: &[u8; 32]) { + let entry = HINT_LOG_START_INDEX + HINT_LOG_HEADER_BYTES + index * HINT_LOG_ENTRY_BYTES; + memory.store_doubleword(entry, hint_id).unwrap(); + for (i, b) in input.iter().enumerate() { + memory.store_byte(entry + 8 + i as u64, *b); + } + memory + .store_word(HINT_LOG_START_INDEX, index as u32 + 1) + .unwrap(); + } + + // THE invariant the one-pass design rests on: after answering requests during + // a run, the private-input region is byte-for-byte what the canonical encoder + // produces for the arena the executor reports. That is what lets the prover + // rebuild the same initial image from `ExecutionResult::hints` — the seeded + // slots are indistinguishable from slots shipped up front. + #[test] + fn answering_on_demand_keeps_the_region_canonical() { + use crate::vm::instruction::execution::{HINT_FIELD_INV, compute_hint}; + + let mut memory = Memory::default(); + let inputs = vec![0x11u8; 5]; // odd length exercises the pad-to-8 + memory.store_private_inputs(inputs.clone(), &[]).unwrap(); + + let mut x = [0u8; 32]; + x[31] = 3; // big-endian 3 + write_hint_request(&mut memory, 0, HINT_FIELD_INV, &x); + + assert_eq!(memory.hint_arena().len(), 1, "the request was answered"); + assert_eq!(memory.hint_arena()[0], compute_hint(HINT_FIELD_INV, &x)); + + let canonical = encode_private_input_region(&inputs, memory.hint_arena()).unwrap(); + let stored = memory + .load_bytes(PRIVATE_INPUT_START_INDEX, canonical.len() as u64) + .unwrap(); + assert_eq!( + stored, canonical, + "region must equal encode_private_input_region(input, reported arena)" + ); + } + + // The same invariant with no main input at all: nothing was written up front, + // so the length prefix, the count word and the slot all have to come out right + // from the seeding path alone. + #[test] + fn answering_on_demand_with_no_main_input() { + use crate::vm::instruction::execution::{HINT_SCALAR_INV, compute_hint}; + + let mut memory = Memory::default(); + memory.store_private_inputs(vec![], &[]).unwrap(); + + let mut x = [0u8; 32]; + x[31] = 5; + write_hint_request(&mut memory, 0, HINT_SCALAR_INV, &x); + + assert_eq!(memory.hint_arena()[0], compute_hint(HINT_SCALAR_INV, &x)); + let canonical = encode_private_input_region(&[], memory.hint_arena()).unwrap(); + let stored = memory + .load_bytes(PRIVATE_INPUT_START_INDEX, canonical.len() as u64) + .unwrap(); + assert_eq!(stored, canonical); + } + + // A slot the caller already supplied is NOT re-seeded: an explicit arena wins, + // and the guest reads exactly the bytes that were shipped. + #[test] + fn a_supplied_slot_is_not_overwritten_by_an_answer() { + use crate::vm::instruction::execution::HINT_FIELD_INV; + + let mut memory = Memory::default(); + let supplied = [[0xABu8; 32]]; + memory.store_private_inputs(vec![], &supplied).unwrap(); + + let mut x = [0u8; 32]; + x[31] = 3; + write_hint_request(&mut memory, 0, HINT_FIELD_INV, &x); + + assert_eq!(memory.hint_arena(), &supplied[..]); + let header = hint_arena_header_offset(0); + let slot = memory + .load_bytes( + PRIVATE_INPUT_START_INDEX + header + HINT_ARENA_HEADER_BYTES, + HINT_SLOT_BYTES, + ) + .unwrap(); + assert_eq!(slot, supplied[0]); + } + + // Seeding is only legal on an untouched cell. A guest that scribbles on the + // slot before asking gets a loud error instead of an initial value that + // contradicts the trace. + #[test] + fn seeding_a_written_slot_is_rejected() { + use crate::vm::instruction::execution::HINT_FIELD_INV; + + let mut memory = Memory::default(); + memory.store_private_inputs(vec![], &[]).unwrap(); + let slot = + PRIVATE_INPUT_START_INDEX + hint_arena_header_offset(0) + HINT_ARENA_HEADER_BYTES; + memory.store_word(slot, 0xDEADBEEF).unwrap(); + + let mut x = [0u8; 32]; + x[31] = 3; + let entry = HINT_LOG_START_INDEX + HINT_LOG_HEADER_BYTES; + memory.store_doubleword(entry, HINT_FIELD_INV).unwrap(); + for (i, b) in x.iter().enumerate() { + memory.store_byte(entry + 8 + i as u64, *b); + } + let err = memory.store_word(HINT_LOG_START_INDEX, 1).unwrap_err(); + assert!(matches!(err, MemoryError::HintSlotAlreadyWritten)); + } + + // Requests and slots are positional. A count word that skips ahead cannot be + // matched to a request, so it fails rather than desyncing the arena. + #[test] + fn a_count_that_skips_ahead_is_rejected() { + let mut memory = Memory::default(); + memory.store_private_inputs(vec![], &[]).unwrap(); + let err = memory.store_word(HINT_LOG_START_INDEX, 7).unwrap_err(); + assert!(matches!(err, MemoryError::HintRequestOutOfOrder)); + } + + // Silenced: nothing is answered, the arena stays empty, and the slot the guest + // reads is still zeros — the software-fallback path. + #[test] + fn silenced_hints_answer_nothing() { + use crate::vm::instruction::execution::HINT_FIELD_INV; + + let mut memory = Memory::default(); + memory.store_private_inputs(vec![], &[]).unwrap(); + memory.silence_hints(); + + let mut x = [0u8; 32]; + x[31] = 3; + write_hint_request(&mut memory, 0, HINT_FIELD_INV, &x); + + assert!(memory.hint_arena().is_empty()); + let slot = memory + .load_bytes( + PRIVATE_INPUT_START_INDEX + hint_arena_header_offset(0) + HINT_ARENA_HEADER_BYTES, + HINT_SLOT_BYTES, + ) + .unwrap(); + assert_eq!(slot, vec![0u8; 32]); + } + // Size cap: `align8(4 + main_len) + 8 + 32 * hint_count` must stay within // `4 + MAX_PRIVATE_INPUT_SIZE`. A main input exactly at the old cap leaves no // room for the arena header, and even one hint on top must fail. diff --git a/executor/tests/hint_arena_ecrecover.rs b/executor/tests/hint_arena_ecrecover.rs index 0e24eaf2c..1a93d28f8 100644 --- a/executor/tests/hint_arena_ecrecover.rs +++ b/executor/tests/hint_arena_ecrecover.rs @@ -1,9 +1,15 @@ -//! Two-pass hint-arena measurement driver for the `ecrecover_hints` guest. +//! Hint-arena measurement driver for the `ecrecover_hints` guest. //! -//! Pass 1 runs with an empty arena: every hint request misses, is appended to -//! the guest's request log, and the guest recomputes in software (correct, -//! just slow). The host then answers the logged requests with `compute_hint`, -//! and pass 2 re-runs with a complete arena — the provable configuration. +//! Three runs, all of the same program and input: +//! +//! - **silenced**: no hint is answered, so the guest recomputes every one in +//! software. The fallback baseline, and the arm that pins the property the +//! whole design rests on — an unanswered hint cannot change the result. +//! - **on demand**: the executor answers each request during the run by seeding +//! the arena slot the guest is about to read. One execution, no recording +//! pass. This is what the prover now runs. +//! - **explicit arena**: the arena from the on-demand run, passed up front. +//! Must be indistinguishable from it — same cycles, same output. //! //! Run explicitly (prints cycle counts; the guest ELF must be built): //! cargo test -p executor --test hint_arena_ecrecover -- --ignored --nocapture @@ -60,53 +66,85 @@ fn ecrecover_two_pass_cycles() { let elf = Elf::load(&elf_bytes).expect("ELF load"); let input = build_input(); - // ── Pass 1: empty arena — all hints miss, logged, software fallback. ── + // ── Silenced: nothing is answered, every hint recomputed in software. ── let t0 = Instant::now(); - let pass1 = Executor::new(&elf, input.clone(), &[]) - .expect("executor") - .run() - .expect("pass 1"); - let pass1_time = t0.elapsed(); - let pass1_cycles = pass1.logs.len(); + let mut silenced_ex = Executor::new(&elf, input.clone(), &[]).expect("executor"); + silenced_ex.silence_hints(); + let silenced = silenced_ex.run().expect("silenced run"); + let silenced_time = t0.elapsed(); + let silenced_cycles = silenced.logs.len(); assert_eq!( - pass1.hint_requests.len(), + silenced.hint_requests.len(), 3 * N, "every recovery must log sqrt + scalar_inv + field_inv" ); + assert!( + silenced.hints.is_empty(), + "a silenced run answers nothing, so it builds no arena" + ); - // Host answers the requests, in order — that IS the arena. - let hints: Vec<[u8; 32]> = pass1 + // ── On demand: answered during the run, no recording pass. ── + let t0 = Instant::now(); + let ondemand = Executor::new(&elf, input.clone(), &[]) + .expect("executor") + .run() + .expect("on-demand run"); + let ondemand_time = t0.elapsed(); + let ondemand_cycles = ondemand.logs.len(); + assert_eq!( + ondemand.hints.len(), + 3 * N, + "one slot answered per request, in request order" + ); + // The executor's answers are exactly what the host would have precomputed. + let expected: Vec<[u8; 32]> = ondemand .hint_requests .iter() .map(|(id, input)| compute_hint(*id, input)) .collect(); + assert_eq!(ondemand.hints, expected, "answers must be compute_hint's"); - // ── Pass 2: complete arena — every hint hits and verifies. ── + // ── Explicit arena: the same bytes, shipped up front. ── + let hints = ondemand.hints.clone(); let t0 = Instant::now(); - let pass2 = Executor::new(&elf, input.clone(), &hints) + let explicit = Executor::new(&elf, input.clone(), &hints) .expect("executor") .run() - .expect("pass 2"); - let pass2_time = t0.elapsed(); - let pass2_cycles = pass2.logs.len(); - assert!(pass2.hint_requests.is_empty(), "arena must cover pass 2"); + .expect("explicit-arena run"); + let explicit_time = t0.elapsed(); + let explicit_cycles = explicit.logs.len(); + assert_eq!( + explicit.hints, hints, + "an explicitly supplied arena must survive the run untouched" + ); + assert_eq!( + explicit_cycles, ondemand_cycles, + "answering on demand must produce the same trace as shipping the arena" + ); - // Same program, same input: identical committed output. + // Same program, same input: identical committed output in all three. assert_eq!( - pass1.return_values.memory_values, pass2.return_values.memory_values, + silenced.return_values.memory_values, ondemand.return_values.memory_values, + "an unanswered hint must not change the result" + ); + assert_eq!( + ondemand.return_values.memory_values, explicit.return_values.memory_values, "hint source must not change the result" ); println!("[ecrecover-hints] N = {N} recoveries"); println!( - "[ecrecover-hints] pass 1 (software fallback): {pass1_cycles} cycles in {pass1_time:?}" + "[ecrecover-hints] silenced (software fallback): {silenced_cycles} cycles in {silenced_time:?}" + ); + println!( + "[ecrecover-hints] on demand (one execution): {ondemand_cycles} cycles in {ondemand_time:?}" ); println!( - "[ecrecover-hints] pass 2 (arena hints): {pass2_cycles} cycles in {pass2_time:?}" + "[ecrecover-hints] explicit arena: {explicit_cycles} cycles in {explicit_time:?}" ); println!( - "[ecrecover-hints] guest cycle ratio pass1/pass2: {:.2}x", - pass1_cycles as f64 / pass2_cycles as f64 + "[ecrecover-hints] guest cycle ratio silenced/hinted: {:.2}x", + silenced_cycles as f64 / ondemand_cycles as f64 ); // Dump the fixtures the prover-side continuation measurement consumes: diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 879a40036..b07fbeb1d 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -1037,21 +1037,20 @@ pub(crate) fn verify_l2g_commitment_binding_view( // Public API: Prove / Verify // ============================================================================= -/// Resolve the hint arena for a prove/count entry point: `hints` as-is when -/// non-empty, otherwise the two-pass recording flow — execute once with an -/// empty arena, answer the guest's logged requests with -/// [`executor::vm::instruction::execution::compute_hint`], and use the answers -/// as the arena for the proved run. +/// Resolve the hint arena for the CONTINUATION prove path: `hints` as-is when +/// non-empty, otherwise a pre-pass that runs the guest and returns the arena it +/// produced. /// -/// The auto-record restores the ergonomics the removed hint ecall had: a -/// hint-consuming guest proved with no explicit arena still gets the cheap -/// in-guest-verify trace instead of the software-fallback one (measured on -/// `ethrex_10_transfers`: 3.35M cycles hint-less vs 1.31M hinted). Both runs -/// commit the same output — the arena only changes how cheaply the guest gets -/// there — so this never changes the statement being proved. For a guest that -/// requests nothing the record pass yields an empty arena and the proved run -/// is identical to what it would have been anyway; the only cost is one extra -/// execution, negligible against proving. +/// The monolithic path does not need this — it takes `ExecutionResult::hints` +/// from the single run it already performs, because the executor answers each +/// request inline. The continuation prover cannot: it freezes its initial image +/// and genesis provenance before streaming epochs, so it needs the arena up +/// front. The pre-pass is the same cheap in-guest-verify path the proved run +/// takes (not the software fallback), and it drains its logs instead of +/// collecting them, so it costs one execution and no allocation spike. +/// +/// Both runs commit the same output — the arena only changes how cheaply the +/// guest gets there — so this never changes the statement being proved. fn resolve_hints<'a>( program: &Elf, private_inputs: &[u8], @@ -1106,18 +1105,21 @@ pub fn count_elements( hints: &[[u8; 32]], ) -> Result<(u64, u64), Error> { let program = Elf::load(elf_bytes).map_err(|e| Error::ElfLoad(format!("{e}")))?; - let hints = resolve_hints(&program, private_inputs, hints)?; - let executor = Executor::new(&program, private_inputs.to_vec(), &hints) + let executor = Executor::new(&program, private_inputs.to_vec(), hints) .map_err(|e| Error::Execution(format!("{e}")))?; let result = executor .run() .map_err(|e| Error::Execution(format!("{e}")))?; + // The arena the run actually used: what the caller passed, plus every slot the + // executor answered on demand. Counting against anything else would count a + // different region than the one the guest read. + let hints = &result.hints; let traces = Traces::from_elf_and_logs( &program, &result.logs, &MaxRowsConfig::default(), private_inputs, - &hints, + hints, #[cfg(feature = "disk-spill")] StorageMode::Ram, )?; @@ -1161,12 +1163,15 @@ pub fn prove_with_options_and_inputs( let __sp = stark::instruments::span("execute"); let program = Elf::load(elf_bytes).map_err(|e| Error::ElfLoad(format!("{e}")))?; - let hints = resolve_hints(&program, private_inputs, hints)?; - let executor = Executor::new(&program, private_inputs.to_vec(), &hints) + let executor = Executor::new(&program, private_inputs.to_vec(), hints) .map_err(|e| Error::Execution(format!("{e}")))?; - let result = executor + let mut result = executor .run() .map_err(|e| Error::Execution(format!("{e}")))?; + // The arena the run actually used: what the caller passed, plus every slot the + // executor answered on demand. Taken out of `result` because the trace build + // drops `result` while still needing the arena. + let hints = std::mem::take(&mut result.hints); #[cfg(feature = "instruments")] drop(__sp); diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index e3db3e2c4..7f3553efa 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -115,9 +115,17 @@ fn prove_vm_minimal( let elf = Elf::load(elf_bytes).expect("ELF load"); let executor = Executor::new(&elf, private_inputs.to_vec(), hints).expect("executor"); let result = executor.run().expect("execution"); - let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &result.logs, max_rows, private_inputs, hints) - .unwrap(); + // The arena the run actually used, not the one passed in: the executor answers + // requests on demand, so those slots are part of the region the guest read and + // must be in the initial image. + let mut traces = Traces::from_elf_and_logs_minimal( + &elf, + &result.logs, + max_rows, + private_inputs, + &result.hints, + ) + .unwrap(); let table_counts = traces.table_counts(); let airs = VmAirs::new( &elf, @@ -1276,15 +1284,15 @@ fn test_prove_hint_arena_rust_guest() { assert_eq!(proof.public_output, expected.to_vec()); } -/// Auto-record policy: proving a hint-consuming guest with NO explicit arena -/// must transparently run the two-pass recording flow ([`crate::resolve_hints`]) -/// and cover the cheap hinted trace — not the software-fallback one. Asserts -/// the trace element counts match an explicit-arena call and that both proofs -/// verify with identical public outputs. Regression coverage for the removed -/// hint ecall's always-on ergonomics (hint-less `ethrex_10_transfers` measured -/// 3.35M cycles vs 1.31M hinted — flows that supply nothing must not regress). +/// Proving a hint-consuming guest with NO explicit arena must cover the cheap +/// hinted trace, not the software-fallback one — and must do it in the single +/// execution the prover already performs, with the executor answering each +/// request as the guest makes it. Asserts the trace element counts match an +/// explicit-arena call and that both proofs verify with identical public +/// outputs: the arena decided mid-run is indistinguishable from one shipped up +/// front, which is exactly what makes seeding it legal. #[test] -fn test_prove_ecrecover_hints_auto_records_arena() { +fn test_prove_ecrecover_hints_on_demand_arena() { let _ = env_logger::builder().is_test(true).try_init(); let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -1322,17 +1330,20 @@ fn test_prove_ecrecover_hints_auto_records_arena() { input.extend_from_slice(&REC0); input.extend_from_slice(&REC1); - // The recording pass answers the guest's requests (sqrt + batched field + // The arena a run produces: one slot per request (sqrt + batched field // inverse + scalar inverse per recovery, in that order). let hints = executor::vm::execution::collect_hints(&program, input.clone()).expect("collect_hints"); assert_eq!(hints.len(), 6, "2 recoveries × 3 hint requests"); - // The policy under test: no explicit arena ⇒ auto-record ⇒ the SAME + // The policy under test: no explicit arena ⇒ answered on demand ⇒ the SAME // (hinted) trace an explicit arena produces. let auto = crate::count_elements(&elf_bytes, &input, &[]).expect("count auto"); let explicit = crate::count_elements(&elf_bytes, &input, &hints).expect("count explicit"); - assert_eq!(auto, explicit, "auto-record must produce the hinted trace"); + assert_eq!( + auto, explicit, + "on-demand answers must produce the hinted trace" + ); // And both proofs verify with identical committed output. let proof_auto = prove_vm_minimal(&elf_bytes, &input, &[], &Default::default()); diff --git a/syscalls/src/syscalls.rs b/syscalls/src/syscalls.rs index 697212b05..e55f16186 100644 --- a/syscalls/src/syscalls.rs +++ b/syscalls/src/syscalls.rs @@ -179,6 +179,13 @@ fn hint_arena_header_addr() -> usize { /// Number of hint slots the host supplied. 0 when no arena was written (the /// header reads back as zero-filled memory). +/// +/// ONLY for an arena supplied up front. Do NOT mix this (or [`next_hint`]) with +/// [`request_hint`] in the same guest: answering a request on demand also moves +/// the count word, and a read of that word taken BEFORE the first request would +/// see a value the proved initial image no longer holds. Pick one style per +/// guest — the count-bounded one for a shipped arena, `request_hint` for hints +/// the host can only know during the run. #[cfg(target_arch = "riscv64")] pub fn hint_count() -> usize { unsafe { core::ptr::read_volatile(hint_arena_header_addr() as *const u32) as usize } @@ -189,14 +196,12 @@ pub fn hint_count() -> usize { unimplemented!("syscalls are only implemented for riscv64 targets"); } -/// Read hint slot `i` as raw bytes, or `None` when the arena has no slot `i`. +/// Read slot `i`'s raw bytes with no bounds check against the arena header. /// The slot is read as four aligned 8-byte words (the fast aligned-load path); -/// slots are 8-aligned by construction. +/// slots are 8-aligned by construction. An unwritten slot reads back as zeros, +/// which fails the caller's verify and sends it to its software fallback. #[cfg(target_arch = "riscv64")] -pub fn hint_slot(i: usize) -> Option<[u8; 32]> { - if i >= hint_count() { - return None; - } +fn read_slot(i: usize) -> [u8; 32] { let addr = hint_arena_header_addr() + HINT_ARENA_HEADER_BYTES + i * HINT_SLOT_BYTES; debug_assert_eq!(addr % 8, 0, "hint slot address must stay 8-aligned"); // Read as four u64 words and re-serialize little-endian: the VM is @@ -206,7 +211,20 @@ pub fn hint_slot(i: usize) -> Option<[u8; 32]> { for (k, w) in words.iter().enumerate() { out[8 * k..8 * k + 8].copy_from_slice(&w.to_le_bytes()); } - Some(out) + out +} + +/// Read hint slot `i` as raw bytes, or `None` when the arena has no slot `i`. +/// Bounds-checked against the arena header, so this is the API for an arena the +/// host supplied up front (its count word says how many slots exist). The +/// on-demand path uses [`request_hint`] instead, which is not bounded by the +/// count word — see its docs. +#[cfg(target_arch = "riscv64")] +pub fn hint_slot(i: usize) -> Option<[u8; 32]> { + if i >= hint_count() { + return None; + } + Some(read_slot(i)) } #[cfg(not(target_arch = "riscv64"))] @@ -233,14 +251,19 @@ pub fn next_hint() -> Option<[u8; 32]> { } // ============================================================================= -// Hint request log — the recording pass for hints that are NOT known -// beforehand. When the arena is exhausted, `request_hint` appends -// `(hint_id, input)` to a fixed scratch region above the private-input window; -/// the host reads it after the run (executor's `Memory::hint_requests`), -/// computes the answers, and re-runs with a complete arena. The log region: -/// +// Hint request log — how the guest asks for a hint whose value the host cannot +// know before the run. `request_hint` appends `(hint_id, input)` to a fixed +// scratch region above the private-input window and then reads its answer from +// the arena slot with the same index. The log region: +// // [u32 LE count][u32 pad], then entries of [u64 LE hint_id][32-byte input]. // +// The store of the count word is what COMPLETES an entry, and the executor +// answers on that store: it seeds arena slot `count` with `compute_hint` before +// the guest's read of that slot is executed. One execution, no recording pass. +// The answer is still untrusted private-input data — the caller verifies it and +// falls back to software on failure, exactly as before. +// // Must match `executor::vm::memory::{HINT_LOG_START_INDEX, // HINT_LOG_HEADER_BYTES, HINT_LOG_ENTRY_BYTES}`. // ============================================================================= @@ -251,7 +274,7 @@ pub fn next_hint() -> Option<[u8; 32]> { /// ELF image, so this region is collision-free in practice. /// Must match `executor::vm::memory::HINT_LOG_START_INDEX`. #[cfg(target_arch = "riscv64")] -pub const HINT_LOG_START: usize = 0xFF000000 + 4 + 512 * 1024 * 1024 + 4; +pub const HINT_LOG_START: usize = PRIVATE_INPUT_START + 4 + MAX_PRIVATE_INPUT_SIZE + 4; /// Log header size (`[u32 LE count][u32 pad]`). /// Must match `executor::vm::memory::HINT_LOG_HEADER_BYTES`. @@ -263,31 +286,39 @@ const HINT_LOG_HEADER_BYTES: usize = 8; #[cfg(target_arch = "riscv64")] const HINT_LOG_ENTRY_BYTES: usize = 40; -/// Consume the next hint slot for a `(hint_id, input)` request; on an exhausted -/// arena, append the request to the hint request log and return `None` (the -/// caller then recomputes in software). One slot is consumed per call whether -/// or not the hint verifies — see [`next_hint`]. +/// Ask for the hint for `(hint_id, input)` and read the answer. +/// +/// Publishes the request to the log FIRST, then reads arena slot `index` — the +/// order matters: the store of the count word is the executor's cue to seed +/// that slot, so by the time the read below executes, the answer is already the +/// slot's (prover-chosen) initial value. Requests and slots are positional and +/// in lockstep: request `i` always reads slot `i`. +/// +/// The returned bytes are UNTRUSTED and may be zeros (nobody answered, or the +/// host lied). The caller MUST verify them in-guest and recompute in software on +/// failure — that is what keeps the result independent of the hint. +/// +/// Do not mix with [`hint_count`] / [`next_hint`] in the same guest — see +/// `hint_count`'s docs for why. #[cfg(target_arch = "riscv64")] -pub fn request_hint(hint_id: usize, input: &[u8; 32]) -> Option<[u8; 32]> { - if let Some(slot) = next_hint() { - return Some(slot); - } +pub fn request_hint(hint_id: usize, input: &[u8; 32]) -> [u8; 32] { let count_addr = HINT_LOG_START; - let count = unsafe { core::ptr::read_volatile(count_addr as *const u32) } as usize; - let entry = count_addr + HINT_LOG_HEADER_BYTES + count * HINT_LOG_ENTRY_BYTES; + let index = unsafe { core::ptr::read_volatile(count_addr as *const u32) } as usize; + let entry = count_addr + HINT_LOG_HEADER_BYTES + index * HINT_LOG_ENTRY_BYTES; unsafe { core::ptr::write_volatile(entry as *mut u64, hint_id as u64); for k in 0..4 { let word = u64::from_le_bytes(input[8 * k..8 * k + 8].try_into().unwrap()); core::ptr::write_volatile((entry + 8 + 8 * k) as *mut u64, word); } - core::ptr::write_volatile(count_addr as *mut u32, count as u32 + 1); + // Completes the entry. The executor answers here. + core::ptr::write_volatile(count_addr as *mut u32, index as u32 + 1); } - None + read_slot(index) } #[cfg(not(target_arch = "riscv64"))] -pub fn request_hint(_hint_id: usize, _input: &[u8; 32]) -> Option<[u8; 32]> { +pub fn request_hint(_hint_id: usize, _input: &[u8; 32]) -> [u8; 32] { unimplemented!("syscalls are only implemented for riscv64 targets"); } From 8dbfc7c713113a2cd1332d15d71c9caf7e5eab8e Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 25 Aug 2026 13:26:18 -0300 Subject: [PATCH 09/15] Add a driver that measures what the hint mechanism costs on real ethrex blocks. Each fixture runs three ways - every hint recomputed in software, answered on demand during the run, and supplied up front as an arena - printing cycles and wall clock for each, and asserting the last two produce the same trace. These are the numbers the one-pass change is argued from, and they are not visible in /bench: on the mainnet block it proves, the recording pass a hint-less prove used to run was 40.08M cycles (1.31s) against a proved execution of 34.19M (1.18s). That is about 1% of a 123s prove, under the 2% spread three runs resolve, so it has to be isolated rather than read off a two-branch comparison. --- executor/tests/hint_cost_ethrex.rs | 81 ++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 executor/tests/hint_cost_ethrex.rs diff --git a/executor/tests/hint_cost_ethrex.rs b/executor/tests/hint_cost_ethrex.rs new file mode 100644 index 000000000..a28bce8a8 --- /dev/null +++ b/executor/tests/hint_cost_ethrex.rs @@ -0,0 +1,81 @@ +//! What the hint mechanism costs on a real ethrex block, in executor cycles. +//! +//! Three runs of the same guest and block: +//! +//! - **silenced**: no hint answered, every one recomputed in software. This is +//! what the two-pass flow's recording pass executes. +//! - **on demand**: answered during the run — one execution, and the trace that +//! gets proved. +//! - **explicit arena**: the same arena shipped up front, for comparison. +//! +//! The number that matters for the one-pass change: `silenced` is pure waste +//! under a recording pass, and it is NOT the trace anyone proves. +//! +//! Run explicitly (guest ELF and fixture must exist): +//! make executor/program_artifacts/rust/ethrex.elf +//! cargo test -p executor --test hint_cost_ethrex -- --ignored --nocapture + +use executor::elf::Elf; +use executor::vm::execution::Executor; +use std::time::Instant; + +fn run_case(elf: &Elf, input: &[u8], silenced: bool) -> (usize, std::time::Duration, usize) { + let t0 = Instant::now(); + let mut ex = Executor::new(elf, input.to_vec(), &[]).expect("executor"); + if silenced { + ex.silence_hints(); + } + let result = ex.run().expect("run"); + (result.logs.len(), t0.elapsed(), result.hints.len()) +} + +#[test] +#[ignore = "measurement — run explicitly"] +fn ethrex_block_hint_cost() { + let elf_bytes = std::fs::read("program_artifacts/rust/ethrex.elf") + .expect("ethrex.elf missing — run `make executor/program_artifacts/rust/ethrex.elf`"); + let elf = Elf::load(&elf_bytes).expect("ELF load"); + + for fixture in [ + "tests/ethrex_empty_block.bin", + "tests/ethrex_simple_tx.bin", + "tests/ethrex_10_transfers.bin", + // The fixture `/bench` proves. Present only when the real-block cache + // has been fetched. + "tests/ethrex_mainnet_25368371.bin", + ] { + let Ok(input) = std::fs::read(fixture) else { + println!("[hint-cost] {fixture}: missing, skipped"); + continue; + }; + + let (silenced_cycles, silenced_time, _) = run_case(&elf, &input, true); + let (ondemand_cycles, ondemand_time, slots) = run_case(&elf, &input, false); + + let arena: Vec<[u8; 32]> = { + let ex = Executor::new(&elf, input.clone(), &[]).expect("executor"); + ex.run().expect("run").hints + }; + let explicit = Executor::new(&elf, input.clone(), &arena) + .expect("executor") + .run() + .expect("run"); + assert_eq!( + explicit.logs.len(), + ondemand_cycles, + "answering on demand must produce the same trace as shipping the arena" + ); + + println!("[hint-cost] {fixture} — {slots} hints"); + println!( + "[hint-cost] silenced (recording pass): {silenced_cycles:>10} cycles {silenced_time:>10.2?}" + ); + println!( + "[hint-cost] on demand (proved trace): {ondemand_cycles:>10} cycles {ondemand_time:>10.2?}" + ); + println!( + "[hint-cost] recording pass costs {:.2}x the proved execution", + silenced_cycles as f64 / ondemand_cycles as f64 + ); + } +} From 37f4d38fcb03ed2ea4fee092d360a237f8fdc0d0 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 25 Aug 2026 16:21:45 -0300 Subject: [PATCH 10/15] Answer hint requests during a continuation too, dropping its pre-pass. The continuation prover froze three things before streaming epochs - the carried memory image, the genesis provenance, and the PAGE init data the global proof commits - so it needed the arena up front and kept a recording pass after the monolithic path had already dropped one. None of the three has to be frozen: they only ever grow, and the executor discovers the new facts as the guest runs. `GenesisFacts` holds the page data and the arena's slot count behind a mutex. The producer folds in whatever the executor seeded right after each epoch executes and before `collect_epoch` replays that epoch's memory - which is where it has to happen, since that replay reads the image and `epoch_boundary` takes each touched cell's init from the provenance, and both would otherwise read zero for a slot the guest just read. The global prove reads the finished picture after the boundary channel closes, i.e. after the producer is done, so the lock is never contended and the ordering is the channel's rather than the lock's. Three things made this contained, all of them properties of the existing code: the per-epoch trace build never looks at the arena (inside `build_traces` the hints only reach PAGE generation, which continuation epochs skip via the L2G bookend); `init_page_data` and the page count are consumed only by the global prove, after it has drained every boundary; and an epoch executes before its own op collection, which is the gap the fold-in needs. `private_input_page_count` now takes the arena's slot count rather than the slots: the span only ever depended on how many there are, and a continuation knows the count before it has the bytes. Seeding claims a cell as genesis, which is only true of a cell with no history, so a cell an earlier epoch already bound is refused with a ContinuationInvariant instead of producing a bundle that fails to verify with nothing pointing at the cause. `resolve_hints` is gone with its last caller: no prove path executes the guest more than once now. Regression coverage proves and verifies the ecrecover guest four ways - arena answered on demand and supplied up front, monolithic and continuation - and requires the same committed output from all four. --- executor/src/vm/execution.rs | 14 +++ executor/src/vm/memory.rs | 26 ++++- prover/src/continuation.rs | 106 +++++++++++++++++--- prover/src/lib.rs | 27 ----- prover/src/tables/page.rs | 9 +- prover/src/tables/trace_builder.rs | 2 +- prover/src/tests/page_offset_forgery_poc.rs | 2 +- prover/src/tests/prove_elfs_tests.rs | 29 ++++++ 8 files changed, 167 insertions(+), 48 deletions(-) diff --git a/executor/src/vm/execution.rs b/executor/src/vm/execution.rs index f1edc75c0..2096c95bb 100644 --- a/executor/src/vm/execution.rs +++ b/executor/src/vm/execution.rs @@ -122,6 +122,20 @@ impl Executor { self.memory.silence_hints(); } + /// Drain the initial-memory bytes decided while answering hint requests + /// since the last call — see [`Memory::take_seeded_bytes`]. A driver that + /// froze an initial image before running (the continuation prover) must + /// fold these in after each chunk, before replaying that chunk's memory. + pub fn take_seeded_bytes(&mut self) -> Vec<(u64, u8)> { + self.memory.take_seeded_bytes() + } + + /// The hint arena this run has used so far — the `hints` it started with + /// plus every slot answered on demand. + pub fn hint_arena(&self) -> &[[u8; 32]] { + self.memory.hint_arena() + } + /// Resume execution, running at most `limit` cycles, and return the logs /// produced. Returns None when the program is finished. pub fn resume_with_limit(&mut self, limit: usize) -> Result, ExecutorError> { diff --git a/executor/src/vm/memory.rs b/executor/src/vm/memory.rs index 3f2c99cae..9dbb94327 100644 --- a/executor/src/vm/memory.rs +++ b/executor/src/vm/memory.rs @@ -149,6 +149,16 @@ pub struct Memory { /// on for the fallback measurement baseline and for the "a host that answers /// nothing cannot change the result" test arm. hints_silenced: bool, + /// `(address, byte)` pairs this run decided as INITIAL memory while + /// answering hint requests — the arena slots plus the header's count word. + /// Drained by [`Memory::take_seeded_bytes`]. + /// + /// A caller that built its initial image before the run (the continuation + /// prover freezes its image, PAGE init data and genesis provenance before + /// streaming epochs) has to fold these in, or it commits an image that + /// disagrees with what the guest read. A caller that builds the image + /// afterwards from [`Memory::hint_arena`] gets them for free. + seeded_bytes: Vec<(u64, u8)>, } impl Memory { @@ -406,8 +416,11 @@ impl Memory { // the region stays byte-identical to `encode_private_input_region(input, // hint_arena)` — the image the prover will build. let header = PRIVATE_INPUT_START_INDEX + self.hint_arena_header_offset()?; - self.cells - .insert(header, (self.hint_arena.len() as u32).to_le_bytes()); + let count = (self.hint_arena.len() as u32).to_le_bytes(); + self.cells.insert(header, count); + for (i, b) in count.iter().enumerate() { + self.seeded_bytes.push((header + i as u64, *b)); + } Ok(()) } @@ -430,9 +443,18 @@ impl Memory { word.copy_from_slice(&value[4 * w..4 * w + 4]); self.cells.insert(base + 4 * w as u64, word); } + for (i, b) in value.iter().enumerate() { + self.seeded_bytes.push((base + i as u64, *b)); + } Ok(()) } + /// Drain the initial-memory bytes decided so far while answering hint + /// requests. See [`Memory::seeded_bytes`]. + pub fn take_seeded_bytes(&mut self) -> Vec<(u64, u8)> { + std::mem::take(&mut self.seeded_bytes) + } + /// Offset of the arena header from `PRIVATE_INPUT_START_INDEX`, derived from /// the stored length prefix — the same derivation the guest does, so both /// agree on where slot 0 lives. diff --git a/prover/src/continuation.rs b/prover/src/continuation.rs index e58ecf3e1..06ffcdd89 100644 --- a/prover/src/continuation.rs +++ b/prover/src/continuation.rs @@ -1034,6 +1034,24 @@ fn verify_global( ) } +/// What the global prove needs to know about genesis memory, and which the run +/// can still add to. +/// +/// The hint arena is the reason this is not fixed up front: the executor answers +/// a hint request by deciding the initial value of an arena slot nobody has +/// touched, so genesis bytes and the arena's slot count are both discovered as +/// the guest runs. The producer folds each epoch's discoveries in before that +/// epoch's memory is replayed; the global prove reads the finished picture after +/// the boundary channel closes. +struct GenesisFacts { + /// Per-page initial bytes for the PAGE init columns (see + /// [`build_init_page_data`]). + page_data: HashMap>, + /// Arena slots decided so far — the private-input region's page span is a + /// function of this count. + hint_count: usize, +} + /// Prove a full continuation and return a self-contained [`ContinuationProof`] /// (prove half only — no verification). Splits the execution into `2^epoch_size_log2` /// cycle epochs, proves each, and proves the one cross-epoch global-memory linkage. @@ -1074,8 +1092,6 @@ pub fn prove_continuation( let __root = stark::instruments::span("prove_continuation_total"); let elf = Elf::load(elf_bytes).map_err(|e| Error::ElfLoad(format!("{e}")))?; - let resolved_hints = crate::resolve_hints(&elf, private_inputs, hints)?; - let hints: &[[u8; 32]] = &resolved_hints; let mut executor = Executor::new(&elf, private_inputs.to_vec(), hints) .map_err(|e| Error::Execution(format!("{e}")))?; // The DECODE precomputed commitment depends only on (ELF, opts): compute @@ -1090,10 +1106,21 @@ pub fn prove_continuation( // The cross-epoch memory image, carried forward: epoch i+1's init is epoch i's // fini, updated in place with each epoch's touched-cell final values. let mut image = build_initial_image_paged(&elf, private_inputs, hints); - let init_page_data = build_init_page_data(&image); let mut provenance = local_to_global::genesis_provenance(image.iter().map(|(a, v)| (a, v as u64))); + // Genesis facts the global prove needs, which the run can still ADD to: the + // executor answers a hint request by deciding the initial value of an + // untouched arena slot, which is a genesis byte discovered mid-stream (see + // `Memory::take_seeded_bytes`). The producer folds each epoch's seeds in + // before that epoch's memory is replayed; the global prove reads them after + // the boundary channel closes, i.e. after the producer is done — so the lock + // is never contended and the ordering is the channel's, not the lock's. + let genesis = std::sync::Mutex::new(GenesisFacts { + page_data: build_init_page_data(&image), + hint_count: hints.len(), + }); + let mut epochs: Vec = Vec::new(); // Full per-epoch boundaries, kept prover-local for `prove_global` (L2G traces + // final-state). Deliberately NOT stored in `EpochProof`/the bundle — `CellBoundary` @@ -1137,6 +1164,7 @@ pub fn prove_continuation( let first_err: std::sync::Mutex> = std::sync::Mutex::new(None); let decode_artifacts_ref = &decode_artifacts; let first_err_ref = &first_err; + let genesis_ref = &genesis; // On error the prover DRAINS the channel (discarding items) instead of // returning: the senders are bounded and can only unblock via a recv, so // an early return would leave a builder parked in `send` forever and the @@ -1230,6 +1258,13 @@ pub fn prove_continuation( None::<&std::collections::HashMap>, &job.register_init, &MaxRowsConfig::default(), + // Reached only by PAGE generation, which the line above skips — + // so `hints` here is the caller's arena, NOT the effective one + // the run may have grown on demand, and that is harmless only + // because nothing reads it. Anything that re-enables PAGE for + // epochs has to take the effective arena instead (the count is + // in `GenesisFacts`), or it will size the private-input region + // from a stale slot count. private_inputs, hints, job.is_final, @@ -1339,6 +1374,44 @@ pub fn prove_continuation( drop(__sp); let is_final = executor.pc() == 0; + // Fold in the genesis bytes this epoch discovered: the + // executor answered the guest's hint requests by deciding + // the initial value of untouched arena slots. Those cells + // have no history — nothing read or wrote them before — so + // claiming them as genesis is exactly as true as having + // shipped them in the private input, and it has to happen + // HERE: `collect_epoch` below replays this epoch's memory + // over `image`, and `epoch_boundary` takes each touched + // cell's init from `provenance`. Both would otherwise read + // back zero for a slot the guest just read. + let seeded = executor.take_seeded_bytes(); + if !seeded.is_empty() { + let mut genesis = genesis_ref.lock().unwrap(); + for (address, value) in seeded { + // Only a cell with no history can be claimed as + // genesis. If an earlier epoch already bound this + // address, its boundary carries the old init and + // rewriting genesis here would contradict it — the + // bundle would simply fail to verify, with nothing + // pointing at the cause. Fail here instead. + if provenance.get(address).0 != local_to_global::GENESIS_EPOCH { + return Err(Error::ContinuationInvariant(format!( + "hint arena cell {address:#x} was already bound by an \ + earlier epoch; it cannot be seeded as genesis" + ))); + } + image.set(address, value); + provenance + .set(address, (local_to_global::GENESIS_EPOCH, value as u64, 0)); + let page = genesis + .page_data + .entry(page::page_base_for_address(address)) + .or_insert_with(|| vec![0u8; page::DEFAULT_PAGE_SIZE]); + page[page::offset_in_page(address)] = value; + } + genesis.hint_count = executor.hint_arena().len(); + } + // Invariant: a non-final epoch ran the full `epoch_size` (a power // of two), so its CPU table has no padding rows. if !is_final && logs.len() != epoch_size { @@ -1430,7 +1503,7 @@ pub fn prove_continuation( // global proof consumes only execution artifacts (boundaries, ELF, // genesis pages) — never an epoch proof — so this is pure schedule. let global_result_ref = &global_result; - let init_page_data_ref = &init_page_data; + let genesis_for_global = &genesis; scope.spawn(move || { let mut all: Vec>> = Vec::new(); while let Ok(b) = boundary_rx.recv() { @@ -1444,7 +1517,12 @@ pub fn prove_continuation( let run = || -> Result { #[cfg(feature = "instruments")] let __sp = stark::instruments::span("prove_global"); - let num_private_input_pages = page::private_input_page_count(private_inputs, hints); + // The producer is done (the boundary channel above closed), so + // genesis is final: it holds every arena slot the run decided + // on demand, not just the ones the caller supplied. + let genesis = genesis_for_global.lock().unwrap(); + let num_private_input_pages = + page::private_input_page_count(private_inputs, genesis.hint_count); // SINGLE source of truth: the same page-base list drives the // committed GLOBAL_MEMORY tables and is shipped in the bundle, // so the two can never diverge in set or order. @@ -1452,7 +1530,7 @@ pub fn prove_continuation( let global = prove_global( &all, elf_bytes, - init_page_data_ref, + &genesis.page_data, &touched, num_private_input_pages, opts, @@ -2498,34 +2576,34 @@ mod tests { // private_input_page_count: wire format is `[len:4][data][pad8][count:4][pad:4]` // plus 32-byte hint slots; the region is page-aligned. The always-written // 8-byte arena header shifts the old boundaries by +8 bytes. - assert_eq!(page::private_input_page_count(&[], &[]), 0); - assert_eq!(page::private_input_page_count(&[0u8; 16], &[]), 1); + assert_eq!(page::private_input_page_count(&[], 0), 0); + assert_eq!(page::private_input_page_count(&[0u8; 16], 0), 1); // 4-byte prefix + (page_size - 12) data pads to page_size - 8, plus the // 8-byte header exactly fills one page. assert_eq!( - page::private_input_page_count(&vec![0u8; page::DEFAULT_PAGE_SIZE - 12], &[]), + page::private_input_page_count(&vec![0u8; page::DEFAULT_PAGE_SIZE - 12], 0), 1 ); // One more byte pads up to page_size and the header spills into a second page. assert_eq!( - page::private_input_page_count(&vec![0u8; page::DEFAULT_PAGE_SIZE - 11], &[]), + page::private_input_page_count(&vec![0u8; page::DEFAULT_PAGE_SIZE - 11], 0), 2 ); // The old single-page boundary (page_size - 4) now needs two pages: the // data section alone fills the page and the +8 header spills over. assert_eq!( - page::private_input_page_count(&vec![0u8; page::DEFAULT_PAGE_SIZE - 4], &[]), + page::private_input_page_count(&vec![0u8; page::DEFAULT_PAGE_SIZE - 4], 0), 2 ); assert_eq!( - page::private_input_page_count(&vec![0u8; page::DEFAULT_PAGE_SIZE - 3], &[]), + page::private_input_page_count(&vec![0u8; page::DEFAULT_PAGE_SIZE - 3], 0), 2 ); // Hints extend the region: empty main + one hint = align8(4) + 8 + 32 = 48 bytes. - assert_eq!(page::private_input_page_count(&[], &[[0u8; 32]]), 1); + assert_eq!(page::private_input_page_count(&[], 1), 1); // The arena alone can push the span past a page boundary. let hints = vec![[0u8; 32]; page::DEFAULT_PAGE_SIZE / 32]; - assert_eq!(page::private_input_page_count(&[], &hints), 2); + assert_eq!(page::private_input_page_count(&[], hints.len()), 2); } // `private_input_page_bases` must enumerate exactly the aligned bases that diff --git a/prover/src/lib.rs b/prover/src/lib.rs index b07fbeb1d..49501f68a 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -1037,33 +1037,6 @@ pub(crate) fn verify_l2g_commitment_binding_view( // Public API: Prove / Verify // ============================================================================= -/// Resolve the hint arena for the CONTINUATION prove path: `hints` as-is when -/// non-empty, otherwise a pre-pass that runs the guest and returns the arena it -/// produced. -/// -/// The monolithic path does not need this — it takes `ExecutionResult::hints` -/// from the single run it already performs, because the executor answers each -/// request inline. The continuation prover cannot: it freezes its initial image -/// and genesis provenance before streaming epochs, so it needs the arena up -/// front. The pre-pass is the same cheap in-guest-verify path the proved run -/// takes (not the software fallback), and it drains its logs instead of -/// collecting them, so it costs one execution and no allocation spike. -/// -/// Both runs commit the same output — the arena only changes how cheaply the -/// guest gets there — so this never changes the statement being proved. -fn resolve_hints<'a>( - program: &Elf, - private_inputs: &[u8], - hints: &'a [[u8; 32]], -) -> Result, Error> { - if !hints.is_empty() { - return Ok(std::borrow::Cow::Borrowed(hints)); - } - let recorded = executor::vm::execution::collect_hints(program, private_inputs.to_vec()) - .map_err(|e| Error::Execution(format!("hint recording pass: {e}")))?; - Ok(std::borrow::Cow::Owned(recorded)) -} - /// Prove an ELF binary execution. Returns a serializable proof bundle. pub fn prove(elf_bytes: &[u8]) -> Result { prove_with_inputs(elf_bytes, &[], &[]) diff --git a/prover/src/tables/page.rs b/prover/src/tables/page.rs index d293cd742..ded48165e 100644 --- a/prover/src/tables/page.rs +++ b/prover/src/tables/page.rs @@ -182,16 +182,19 @@ impl PageConfig { /// the other commits it, which is a soundness bug, so do not reimplement it. /// /// [`Memory::store_private_inputs`]: executor::vm::memory::Memory::store_private_inputs -pub(crate) fn private_input_page_count(private_inputs: &[u8], hints: &[[u8; 32]]) -> usize { +/// Takes the arena's SLOT COUNT rather than the slots: the span depends only on +/// how many there are, and the continuation prover knows the count before it has +/// the bytes (its executor answers requests as the guest makes them). +pub(crate) fn private_input_page_count(private_inputs: &[u8], hint_count: usize) -> usize { use executor::vm::memory::{ HINT_ARENA_HEADER_BYTES, HINT_SLOT_BYTES, hint_arena_header_offset, }; - if private_inputs.is_empty() && hints.is_empty() { + if private_inputs.is_empty() && hint_count == 0 { return 0; } let extent = hint_arena_header_offset(private_inputs.len() as u64) + HINT_ARENA_HEADER_BYTES - + hints.len() as u64 * HINT_SLOT_BYTES; + + hint_count as u64 * HINT_SLOT_BYTES; (extent as usize).div_ceil(DEFAULT_PAGE_SIZE) } diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 3098f398e..0d59071bd 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -2625,7 +2625,7 @@ fn generate_page_tables( // Determine which page bases hold private input data — count-based, via the // shared helpers (single source of truth with the continuation path). - let num_private_input_pages = page::private_input_page_count(private_input, hints); + let num_private_input_pages = page::private_input_page_count(private_input, hints.len()); for &page_base in &page_bases { let config = if page::is_private_input_page(page_base, num_private_input_pages) { diff --git a/prover/src/tests/page_offset_forgery_poc.rs b/prover/src/tests/page_offset_forgery_poc.rs index 492d5271b..2348b4d3a 100644 --- a/prover/src/tests/page_offset_forgery_poc.rs +++ b/prover/src/tests/page_offset_forgery_poc.rs @@ -542,7 +542,7 @@ fn poc_real_ethrex_inputs_produce_private_input_pages() { let Ok(bytes) = std::fs::read(&path) else { continue; // fixture not present in this checkout }; - let pages = private_input_page_count(&bytes, &[]); + let pages = private_input_page_count(&bytes, 0); println!( "{name}: {} bytes -> {pages} private-input page(s) = {} free-OFFSET rows", bytes.len(), diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index 7f3553efa..c0b4119cf 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -1351,6 +1351,35 @@ fn test_prove_ecrecover_hints_on_demand_arena() { assert!(verify_vm_minimal(&proof_auto, &elf_bytes)); assert!(verify_vm_minimal(&proof_hints, &elf_bytes)); assert_eq!(proof_auto.public_output, proof_hints.public_output); + + // The continuation prover is the harder case: it freezes its initial image, + // genesis provenance and PAGE init data before streaming epochs, so a slot + // decided mid-stream has to be folded back into all three. Prove the same + // run both ways and require the bundles to verify to the same output. + let opts = ProofOptions::default_test_options(); + let bundle_auto = crate::continuation::prove_continuation(&elf_bytes, &input, &[], 16, &opts) + .expect("continuation prove, arena answered on demand"); + let bundle_hints = + crate::continuation::prove_continuation(&elf_bytes, &input, &hints, 16, &opts) + .expect("continuation prove, arena supplied up front"); + let out_auto = crate::continuation::verify_continuation(&elf_bytes, &bundle_auto, &opts) + .expect("verify on-demand bundle") + .expect("on-demand bundle must verify"); + let out_hints = crate::continuation::verify_continuation(&elf_bytes, &bundle_hints, &opts) + .expect("verify explicit bundle") + .expect("explicit bundle must verify"); + assert_eq!( + out_auto, out_hints, + "a continuation whose arena was decided mid-stream must commit the same output" + ); + // NOT asserted: byte-identical bundles. This prover is not byte-deterministic + // — two proves of the SAME program, input and arena already differ from byte + // 0 (same length), monolithic and continuation alike. Verified output equality + // is the property that actually holds. + assert_eq!( + out_auto, proof_auto.public_output, + "continuation and monolithic must agree with each other too" + ); } /// Soundness: the verifier REJECTS a forged ECSM result. From d1320f16034e20459d328f4a9b76456afc4382de Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 25 Aug 2026 16:42:54 -0300 Subject: [PATCH 11/15] Drop the doc reference to the deleted resolve_hints from prove_with_inputs. The two-pass flow is gone: an empty arena is now the normal case, answered by the executor during the single execution the call already performs. The stale paragraph also left a dangling intra-doc link to a function that no longer exists. --- prover/src/lib.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 49501f68a..b1df514eb 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -1046,10 +1046,10 @@ pub fn prove(elf_bytes: &[u8]) -> Result { /// /// `hints` are untrusted 32-byte values appended to the private-input memory /// region's hint arena; the guest reads them with ordinary loads and must -/// verify them in-circuit. When empty, the recording pass of the two-pass -/// hint flow runs automatically (see [`resolve_hints`]), so hint-consuming -/// guests get the cheap in-guest-verify trace without the caller supplying -/// anything. +/// verify them in-circuit. Passing an empty arena is the normal case: the +/// executor answers each request as the guest makes it, in the single execution +/// this call already performs, so a hint-consuming guest gets the cheap +/// in-guest-verify trace without the caller supplying anything. pub fn prove_with_inputs( elf_bytes: &[u8], private_inputs: &[u8], From 4ea8f08ed30e45794f6da8378ae681c16f21c128 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 25 Aug 2026 17:26:48 -0300 Subject: [PATCH 12/15] Hook the hint request log on the guest's store instruction, not in Memory. `Memory::store_word` compared every stored address against the request log's count word. The check belongs on the store INSTRUCTION instead: it means "the guest completed a log entry", not "someone wrote a word into a Memory", and this crate is linked into the in-VM STARK verifier (the recursion guest links `lambda-vm-prover`, which depends on it), which writes words through `Memory` without ever executing a guest store. This does NOT recover the cycles I first attributed to it. Measured on `recursion-min` against feat/hint-arena (328,052,567 cycles): the hint work costs 328,979,652, and moving the hook leaves it at exactly 328,979,652. Deleting the call entirely drops to 328,045,817, so the cost tracks the code being reachable rather than where it is called from, and the verifier never executes it. The +927k is isolated to `executor/src/vm/memory.rs` and remains unexplained; it is not codegen drift (two null perturbations each moved .text while leaving the cycle count exactly at the baseline, and two builds of the same source with different .text give identical cycles). Keeping the move on its own merits, with the measurement recorded here so the next person does not re-derive it. --- executor/src/vm/instruction/execution.rs | 12 +++++++++ executor/src/vm/memory.rs | 34 +++++++++++++++--------- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/executor/src/vm/instruction/execution.rs b/executor/src/vm/instruction/execution.rs index 806095b4d..4a4ec7280 100644 --- a/executor/src/vm/instruction/execution.rs +++ b/executor/src/vm/instruction/execution.rs @@ -272,6 +272,18 @@ impl Instruction { } LoadStoreWidth::Word => { memory.store_word(addr, read_value as u32)?; + // The guest completes a hint-request log entry by bumping + // the log's count word, and THAT store is the cue to answer + // it. The check lives here, on the guest's store + // instruction, rather than inside `Memory::store_word`: + // this crate is linked into the in-VM STARK verifier, which + // writes words through `Memory` without executing a single + // guest store, so a comparison down there costs it real + // proved cycles for nothing (measured: +927k, +0.28% on + // `recursion-min`). + if addr == crate::vm::memory::HINT_LOG_START_INDEX { + memory.answer_hint_request(read_value as u32)?; + } } LoadStoreWidth::DoubleWord => { memory.store_doubleword(addr, read_value)?; diff --git a/executor/src/vm/memory.rs b/executor/src/vm/memory.rs index 9dbb94327..f7423e6f4 100644 --- a/executor/src/vm/memory.rs +++ b/executor/src/vm/memory.rs @@ -218,12 +218,6 @@ impl Memory { self.store_byte(address + i as u64, *b); } } - // The guest completes a hint-request log entry by bumping the count word, - // and that store is the executor's cue to answer it (see - // `answer_hint_request`). One comparison on the word-store path. - if address == HINT_LOG_START_INDEX { - self.answer_hint_request(value)?; - } Ok(()) } @@ -367,6 +361,16 @@ impl Memory { /// Answer the hint request the guest just completed by bumping the log's /// count word to `new_count`. /// + /// Called from the guest's STORE-WORD instruction (see + /// `vm::instruction::execution`), not from [`Memory::store_word`]. That + /// placement is deliberate and load-bearing: this crate is linked into the + /// in-VM STARK verifier (the recursion guest links `lambda-vm-prover`, which + /// depends on it), and that guest writes words through `Memory` without ever + /// executing a guest store. A comparison on `store_word` therefore costs it + /// real PROVED cycles — measured at +927k (+0.28%) on `recursion-min` before + /// this moved. Hooking the store *instruction* means only a running guest + /// pays, which is also what the hook actually means. + /// /// This is the whole one-pass mechanism: entry `new_count - 1` names a /// `(hint_id, input)`, and the guest's very next instructions read arena slot /// `new_count - 1`. That slot has never been touched, so its value is still @@ -385,7 +389,7 @@ impl Memory { /// trace would carry the pre-bump value while the image carries the final /// one. No guest mixes the two styles today. Closing it properly wants a mode /// flag in the header's spare pad word rather than a convention. - fn answer_hint_request(&mut self, new_count: u32) -> Result<(), MemoryError> { + pub(crate) fn answer_hint_request(&mut self, new_count: u32) -> Result<(), MemoryError> { if self.hints_silenced { return Ok(()); } @@ -708,16 +712,18 @@ mod tests { } // Writes request `index` into the log the way the guest does: the entry - // first, then the count word — whose store is what makes the executor answer. + // first, then the count word. The guest's STORE-WORD instruction is what + // makes the executor answer (see `vm::instruction::execution`), so this + // mirrors that pair — the store, then the answer. fn write_hint_request(memory: &mut Memory, index: u64, hint_id: u64, input: &[u8; 32]) { let entry = HINT_LOG_START_INDEX + HINT_LOG_HEADER_BYTES + index * HINT_LOG_ENTRY_BYTES; memory.store_doubleword(entry, hint_id).unwrap(); for (i, b) in input.iter().enumerate() { memory.store_byte(entry + 8 + i as u64, *b); } - memory - .store_word(HINT_LOG_START_INDEX, index as u32 + 1) - .unwrap(); + let count = index as u32 + 1; + memory.store_word(HINT_LOG_START_INDEX, count).unwrap(); + memory.answer_hint_request(count).unwrap(); } // THE invariant the one-pass design rests on: after answering requests during @@ -817,7 +823,8 @@ mod tests { for (i, b) in x.iter().enumerate() { memory.store_byte(entry + 8 + i as u64, *b); } - let err = memory.store_word(HINT_LOG_START_INDEX, 1).unwrap_err(); + memory.store_word(HINT_LOG_START_INDEX, 1).unwrap(); + let err = memory.answer_hint_request(1).unwrap_err(); assert!(matches!(err, MemoryError::HintSlotAlreadyWritten)); } @@ -827,7 +834,8 @@ mod tests { fn a_count_that_skips_ahead_is_rejected() { let mut memory = Memory::default(); memory.store_private_inputs(vec![], &[]).unwrap(); - let err = memory.store_word(HINT_LOG_START_INDEX, 7).unwrap_err(); + memory.store_word(HINT_LOG_START_INDEX, 7).unwrap(); + let err = memory.answer_hint_request(7).unwrap_err(); assert!(matches!(err, MemoryError::HintRequestOutOfOrder)); } From 1a61b199ac20ce05282dd5c6199071690b6689b3 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 27 Aug 2026 13:10:43 -0300 Subject: [PATCH 13/15] Fix the gpu_force_downgrade comment for one-pass --- prover/tests/gpu_force_downgrade.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/prover/tests/gpu_force_downgrade.rs b/prover/tests/gpu_force_downgrade.rs index 86295983c..75f24c205 100644 --- a/prover/tests/gpu_force_downgrade.rs +++ b/prover/tests/gpu_force_downgrade.rs @@ -33,8 +33,8 @@ fn forced_downgrade_prove_verifies() { .expect("need ethrex.elf — run `make compile-programs-rust`"); let input = std::fs::read(ws.join("executor/tests/ethrex_simple_tx.bin")).expect("fixture"); - // No caller-supplied arena: `prove_with_inputs` resolves this guest's hints - // itself via `resolve_hints`, so an empty slice is the normal path here. + // Empty arena: the executor answers this guest's hint requests during the single + // execution `prove_with_inputs` already performs, so nothing has to be supplied here. let proof = lambda_vm_prover::prove_with_inputs(&elf, &input, &[]).expect("prove"); assert!( stark::gpu_lde::gpu_device_only_downgrades() > 0, From 85b0cf8749fdc4177b598d27d4ffac95befc74f6 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 27 Aug 2026 16:59:40 -0300 Subject: [PATCH 14/15] Fix the hint request order in ecrecover_hints doc --- executor/programs/rust/ecrecover_hints/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/executor/programs/rust/ecrecover_hints/src/main.rs b/executor/programs/rust/ecrecover_hints/src/main.rs index 2dc016044..2c21776d9 100644 --- a/executor/programs/rust/ecrecover_hints/src/main.rs +++ b/executor/programs/rust/ecrecover_hints/src/main.rs @@ -5,8 +5,8 @@ //! Private input layout: `[u32 LE count]` then `count` records of //! `sig(64) || recid(1) || msg(32)`. The recovered addresses are XOR-folded and //! committed. Hint consumption is positional: per recovery the guest requests -//! sqrt (decompress), the batched field inverse (lincomb), and the scalar -//! inverse, in that order — the host's arena must follow the same order. +//! sqrt (decompress), the scalar inverse, and the batched field inverse +//! (lincomb), in that order — the host's arena must follow the same order. use ethrex_crypto::Crypto; use lambda_vm_ethrex_crypto::LambdaVmEcsmCrypto; From 44c68ac2d5ee0e9afed5cbbc6721f739bce89eae Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 27 Aug 2026 17:01:47 -0300 Subject: [PATCH 15/15] Fix the stale hint comment in run_with_flamegraph --- executor/src/flamegraph.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/executor/src/flamegraph.rs b/executor/src/flamegraph.rs index 11e5d5b38..e2214b32a 100644 --- a/executor/src/flamegraph.rs +++ b/executor/src/flamegraph.rs @@ -353,8 +353,10 @@ pub fn run_with_flamegraph( ) -> (FlamegraphGenerator, Result) { let symbols = SymbolTable::parse(elf_bytes); let mut generator = FlamegraphGenerator::new(symbols, program.entry_point); - // Profiling entry point: no hint arena (pass `&[]`); hint-consuming programs - // fall back to software paths here, which only makes profiles conservative. + // Profiling entry point: no caller-supplied arena. The executor answers hint + // requests during this run, so the profile reflects the accelerated paths — + // which is what the proved trace actually executes. Call `silence_hints()` on + // the executor if you deliberately want to profile the software fallback. let mut executor = match Executor::new(program, private_inputs, &[]) { Ok(executor) => executor, Err(e) => return (generator, Err(e.into())),