From 72e17f76e77f5c0af2e556707df4654e22a1e6e8 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Fri, 24 Jul 2026 09:33:02 -0300 Subject: [PATCH 01/28] Add on-demand hint ecall (host-computed) --- Cargo.lock | 1 + bench_vs/lambda/recursion/Cargo.lock | 1 + executor/Cargo.toml | 3 + executor/src/vm/instruction/execution.rs | 82 +++++++++++++++++++++++- syscalls/src/syscalls.rs | 33 ++++++++++ tooling/ethrex-tests/Cargo.lock | 1 + 6 files changed, 120 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 556caa510..d16e3db96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -584,6 +584,7 @@ name = "executor" version = "0.1.0" dependencies = [ "ecsm", + "k256", "rustc-demangle", "serde", "serde_json", diff --git a/bench_vs/lambda/recursion/Cargo.lock b/bench_vs/lambda/recursion/Cargo.lock index bf31738e2..2b3411743 100644 --- a/bench_vs/lambda/recursion/Cargo.lock +++ b/bench_vs/lambda/recursion/Cargo.lock @@ -233,6 +233,7 @@ name = "executor" version = "0.1.0" dependencies = [ "ecsm", + "k256", "rustc-demangle", "thiserror", ] diff --git a/executor/Cargo.toml b/executor/Cargo.toml index 3f278e1c6..fb890e353 100644 --- a/executor/Cargo.toml +++ b/executor/Cargo.toml @@ -8,6 +8,9 @@ license.workspace = true thiserror = "1.0.68" rustc-demangle = "0.1" ecsm = { path = "../crypto/ecsm" } +# Host-side computation of non-constraining hints (modular inverse / sqrt) for the +# `Hint` ecall — same k256 arithmetic the guest verifies against. BENCH ONLY. +k256 = { version = "0.13", default-features = false, features = ["arithmetic", "expose-field"] } [dev-dependencies] serde = { version = "1.0", features = ["derive"] } diff --git a/executor/src/vm/instruction/execution.rs b/executor/src/vm/instruction/execution.rs index c92c0ab88..78d10c1b4 100644 --- a/executor/src/vm/instruction/execution.rs +++ b/executor/src/vm/instruction/execution.rs @@ -16,6 +16,9 @@ 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. + // BENCH ONLY: non-constraining hint (host computes modular inverse/sqrt, guest verifies). + Hint = 95, } /// Syscall number for KeccakPermute (u64::MAX - 1 = 0xFFFF_FFFF_FFFF_FFFE). @@ -31,6 +34,19 @@ 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 (BENCH ONLY). +/// +/// 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`). This adds no in-circuit +/// correctness constraint of its own — it exists to measure the cost of the +/// hint-then-verify pattern versus computing the operation in the guest. +pub const HINT_SYSCALL_NUMBER: u64 = u64::MAX - 20; + +/// Hint operation selector passed in `a0`. +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 + /// `2^32`. ECSM memory operands must not overflow their lower 32-bit address limb when the /// largest per-access offset is added: the 32-byte operands reach offset +31 (last byte). const LOW_LIMB: u64 = 1 << 32; @@ -45,6 +61,7 @@ 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(()), } } @@ -68,7 +85,8 @@ impl SyscallNumbers { SyscallNumbers::Print | SyscallNumbers::Panic | SyscallNumbers::Commit - | SyscallNumbers::Halt => None, + | SyscallNumbers::Halt + | SyscallNumbers::Hint => None, } } } @@ -93,6 +111,55 @@ fn store_u256_le(memory: &mut Memory, addr: u64, bytes: &[u8; 32]) -> Result<(), Ok(()) } +/// BENCH ONLY. Compute a non-constraining hint (modular inverse / sqrt) with the +/// same k256 arithmetic the guest verifies against. Input/output are 32-byte +/// little-endian (matching the ECSM ABI). On any failure (non-canonical input, +/// no inverse/sqrt) returns zeros; the guest's verify then fails loudly. +/// +/// `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). +pub fn compute_hint(hint_id: u64, in_le: &[u8; 32]) -> [u8; 32] { + use k256::elliptic_curve::PrimeField; + // k256 serialization is big-endian; the ABI is little-endian. + let mut be = [0u8; 32]; + for i in 0..32 { + be[i] = in_le[31 - i]; + } + let mut fb = k256::FieldBytes::default(); + fb.copy_from_slice(&be); + + let out_be: [u8; 32] = match hint_id { + HINT_FIELD_INV => { + let x: Option = Option::from(k256::FieldElement::from_bytes(&fb)); + match x.and_then(|x| Option::::from(x.invert())) { + Some(inv) => inv.to_bytes().into(), + None => [0u8; 32], + } + } + HINT_SCALAR_INV => { + let x: Option = Option::from(k256::Scalar::from_repr(fb)); + match x.and_then(|x| Option::::from(x.invert())) { + Some(inv) => inv.to_bytes().into(), + None => [0u8; 32], + } + } + HINT_FIELD_SQRT => { + let x: Option = Option::from(k256::FieldElement::from_bytes(&fb)); + match x.and_then(|x| Option::::from(x.sqrt())) { + Some(r) => r.to_bytes().into(), + None => [0u8; 32], + } + } + _ => [0u8; 32], + }; + + let mut out_le = [0u8; 32]; + for i in 0..32 { + out_le[i] = out_be[31 - i]; + } + out_le +} + /// Checks the ECSM address-alignment assumption: `(addr mod 2^32) + max_offset < 2^32`. fn ecsm_addr_ok(addr: u64, max_offset: u64) -> bool { (addr % LOW_LIMB) + max_offset < LOW_LIMB @@ -454,6 +521,19 @@ impl Instruction { src2_val = addr_xg; dst_val = addr_k; } + SyscallNumbers::Hint => { + // BENCH ONLY. Non-constraining hint: host computes a modular + // inverse/sqrt and writes it to the guest, which verifies it. + // a0 = hint_id, a1 = input addr (32-byte LE), a2 = output addr. + let hint_id = registers.read(10)?; + let in_addr = registers.read(11)?; + let out_addr = registers.read(12)?; + 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 { diff --git a/syscalls/src/syscalls.rs b/syscalls/src/syscalls.rs index 7165dff81..e7cc397a8 100644 --- a/syscalls/src/syscalls.rs +++ b/syscalls/src/syscalls.rs @@ -33,6 +33,16 @@ 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 (BENCH ONLY). +/// Must match `executor::...::execution::HINT_SYSCALL_NUMBER` (u64::MAX - 20). +#[cfg(target_arch = "riscv64")] +const HINT_SYSCALL_NUMBER: usize = usize::MAX - 20; + +/// Hint selectors passed in `a0` (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; + /// No-op. The `Print` ecall (a7=1) has no receiver on the Ecall bus, so emitting /// it makes the LogUp bus unbalance and the proof fail to verify. Printing isn't /// needed in provable programs, so `print_string` does nothing on every target. @@ -187,6 +197,29 @@ pub fn ecsm_mul(_xr: &mut [u8; 32], _xg: &[u8; 32], _k: &[u8; 32]) { unimplemented!("syscalls are only implemented for riscv64 targets"); } +/// BENCH ONLY. 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 little-endian field/scalar +/// elements. The result is UNVERIFIED — the caller MUST check it in-guest +/// (e.g. `x·inv == 1`), since this ecall adds no correctness constraint. +#[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 LE) + in("a2") out.as_mut_ptr(), // x12 = output address (32-byte LE) + 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 diff --git a/tooling/ethrex-tests/Cargo.lock b/tooling/ethrex-tests/Cargo.lock index 26f991c7a..46b6e075d 100644 --- a/tooling/ethrex-tests/Cargo.lock +++ b/tooling/ethrex-tests/Cargo.lock @@ -874,6 +874,7 @@ name = "executor" version = "0.1.0" dependencies = [ "ecsm", + "k256", "rustc-demangle", "thiserror 1.0.69", ] From d01d1458f5c7e8a121911748c28853c10ac1f78c Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Fri, 24 Jul 2026 09:35:05 -0300 Subject: [PATCH 02/28] Add HINT prover table for the hint ecall --- prover/src/lib.rs | 13 ++- prover/src/tables/cpu.rs | 8 ++ prover/src/tables/hint.rs | 171 +++++++++++++++++++++++++++++ prover/src/tables/mod.rs | 1 + prover/src/tables/trace_builder.rs | 93 ++++++++++++++++ prover/src/test_utils.rs | 14 +++ 6 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 prover/src/tables/hint.rs diff --git a/prover/src/lib.rs b/prover/src/lib.rs index a8e89f989..359e9c16b 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_hint_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. -pub const FIXED_TABLE_COUNT: usize = 10; +/// keccak_rc, register, ecsm, ecdas, hint. +pub const FIXED_TABLE_COUNT: usize = 11; /// Number of chunks for each split table. /// The verifier needs this to reconstruct matching AIRs. @@ -517,6 +517,7 @@ 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, @@ -542,6 +543,7 @@ 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 { @@ -616,6 +618,7 @@ 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 { @@ -773,6 +776,7 @@ 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( @@ -879,6 +883,7 @@ impl VmAirs { keccak_rc, ecsm, ecdas, + hint, register, pages, memw_registers, diff --git a/prover/src/tables/cpu.rs b/prover/src/tables/cpu.rs index 781bb02b0..0bb29aaf1 100644 --- a/prover/src/tables/cpu.rs +++ b/prover/src/tables/cpu.rs @@ -188,6 +188,11 @@ 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 (BENCH ONLY). 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 { @@ -235,6 +240,8 @@ 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 @@ -353,6 +360,7 @@ 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 new file mode 100644 index 000000000..55edc4fe0 --- /dev/null +++ b/prover/src/tables/hint.rs @@ -0,0 +1,171 @@ +//! HINT table — receiver for the non-constraining `hint` ecall (BENCH ONLY). +//! +//! The `hint` ecall (syscall `u64::MAX - 20`) 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 two things, and constrains **nothing** about +//! the hinted value (that is the point — soundness lives in the guest's verify): +//! +//! 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. **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. +//! +//! 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. +//! +//! ## Columns (37) +//! - `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 + +use stark::lookup::{BusInteraction, BusValue, LinearTerm, Multiplicity, Packing}; +use stark::trace::TraceTable; + +use super::types::{BusId, FE, GoldilocksExtension, GoldilocksField, VmTable}; + +/// `hint` ecall syscall number — must match +/// `executor::vm::instruction::execution::HINT_SYSCALL_NUMBER`. +const HINT_SYSCALL_NUMBER: u64 = u64::MAX - 20; + +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; + + pub const NUM_COLUMNS: usize = 37; + + /// 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], +} + +/// 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_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 +} + +/// Bus interactions: +/// - **`Ecall` receiver** (mult `mu`): `[timestamp, cast(HINT_SYSCALL_NUMBER, +/// DWordWL)]` — HALT-shaped, balances the CPU's ECALL send. +/// - **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. +pub fn bus_interactions() -> Vec { + let mu = || Multiplicity::Column(cols::MU); + let mut out = Vec::with_capacity(5); + + // 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), + ], + )); + + // 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)), + )); + } + + out +} diff --git a/prover/src/tables/mod.rs b/prover/src/tables/mod.rs index 0a86e4149..f1a899f56 100644 --- a/prover/src/tables/mod.rs +++ b/prover/src/tables/mod.rs @@ -34,6 +34,7 @@ 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/trace_builder.rs b/prover/src/tables/trace_builder.rs index ee68f0be9..8cf0c1e63 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -51,6 +51,7 @@ 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}; @@ -549,6 +550,7 @@ 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); @@ -560,6 +562,7 @@ 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 @@ -654,6 +657,13 @@ fn collect_ops_from_cpu( ecdas_ops.extend(ecdas_rows); } + // Collect Hint ecall operations (the 32-byte output write). BENCH ONLY. + 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 @@ -709,6 +719,7 @@ fn collect_ops_from_cpu( cpu32_ops, ecsm_ops, ecdas_ops, + hint_ops, ) } @@ -948,6 +959,63 @@ fn collect_ecsm_ops( (memw_ops, ecsm_op, ecdas_ops) } +/// Collects the memory operations for a `Hint` ecall (BENCH ONLY). +/// +/// 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; + + // Read the 32-byte little-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. + let mut memw_ops = Vec::with_capacity(4); + 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, + }; + (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( @@ -2714,6 +2782,9 @@ pub struct Traces { /// ECDAS double/add table (variable rows per ecall) pub ecdas: TraceTable, + /// HINT table (one row per non-constraining hint ecall). BENCH ONLY. + 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 @@ -2756,6 +2827,8 @@ struct CollectedOps { // EC scalar-multiplication accelerator chips. ecsm_ops: Vec, ecdas_ops: Vec, + // Non-constraining hint ecall (BENCH ONLY). + hint_ops: Vec, } /// Chunk raw ops and generate one trace table per chunk. When `storage_mode` @@ -2810,6 +2883,7 @@ fn collect_all_ops( cpu32_ops: Vec, ecsm_ops: Vec, ecdas_ops: Vec, + hint_ops: Vec, register_state: &mut RegisterState, is_final: bool, ) -> CollectedOps { @@ -2952,6 +3026,7 @@ fn collect_all_ops( cpu32_ops, ecsm_ops, ecdas_ops, + hint_ops, } } @@ -2995,6 +3070,7 @@ fn build_traces( cpu32_ops, ecsm_ops, ecdas_ops, + hint_ops, } = ops; // ===================================================================== @@ -3356,6 +3432,8 @@ 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). BENCH ONLY. + 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); @@ -3368,6 +3446,7 @@ 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")); @@ -3409,6 +3488,7 @@ 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()); @@ -3436,6 +3516,7 @@ 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"; @@ -3470,6 +3551,7 @@ 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. @@ -3537,6 +3619,7 @@ 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, @@ -3799,6 +3882,7 @@ 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; @@ -3837,6 +3921,7 @@ impl Traces { keccak_rc, ecsm, ecdas, + hint, memw_registers, eqs, bytewises, @@ -3904,6 +3989,7 @@ 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 } @@ -3945,6 +4031,7 @@ 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, @@ -3967,6 +4054,7 @@ impl Traces { keccak_rc, ecsm, ecdas, + hint, memw_registers, eqs, bytewises, @@ -4034,6 +4122,7 @@ 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 } @@ -4256,6 +4345,7 @@ 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); @@ -4274,6 +4364,7 @@ impl Traces { cpu32_ops, ecsm_ops, ecdas_ops, + hint_ops, &mut register_state, is_final, ); @@ -4333,6 +4424,7 @@ 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( @@ -4347,6 +4439,7 @@ impl Traces { cpu32_ops, ecsm_ops, ecdas_ops, + hint_ops, &mut register_state, true, ); diff --git a/prover/src/test_utils.rs b/prover/src/test_utils.rs index 6dd28ce71..005a22abb 100644 --- a/prover/src/test_utils.rs +++ b/prover/src/test_utils.rs @@ -65,6 +65,7 @@ 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::{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, }; @@ -840,6 +841,19 @@ pub fn create_halt_air(proof_options: &ProofOptions) -> ConcreteVmAir ConcreteVmAir { + build_air( + hint_cols::NUM_COLUMNS, + hint_bus_interactions(), + proof_options, + 1, + EmptyConstraints, + "HINT", + ) +} + /// Create COMMIT AIR with constraints and bus interactions. pub fn create_commit_air(proof_options: &ProofOptions) -> ConcreteVmAir { build_air( From c378f567e811ef91be2d31db690ecb130742a072 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Fri, 24 Jul 2026 09:36:25 -0300 Subject: [PATCH 03/28] Add hint ecall guest tests and test programs --- .../programs/rust/hint_min/.cargo/config.toml | 9 + executor/programs/rust/hint_min/Cargo.lock | 331 ++++++++++++++++++ executor/programs/rust/hint_min/Cargo.toml | 9 + executor/programs/rust/hint_min/src/main.rs | 26 ++ .../rust/hint_multi/.cargo/config.toml | 9 + executor/programs/rust/hint_multi/Cargo.lock | 331 ++++++++++++++++++ executor/programs/rust/hint_multi/Cargo.toml | 9 + executor/programs/rust/hint_multi/src/main.rs | 35 ++ prover/src/tests/prove_elfs_tests.rs | 109 ++++++ 9 files changed, 868 insertions(+) create mode 100644 executor/programs/rust/hint_min/.cargo/config.toml create mode 100644 executor/programs/rust/hint_min/Cargo.lock create mode 100644 executor/programs/rust/hint_min/Cargo.toml create mode 100644 executor/programs/rust/hint_min/src/main.rs create mode 100644 executor/programs/rust/hint_multi/.cargo/config.toml create mode 100644 executor/programs/rust/hint_multi/Cargo.lock create mode 100644 executor/programs/rust/hint_multi/Cargo.toml create mode 100644 executor/programs/rust/hint_multi/src/main.rs diff --git a/executor/programs/rust/hint_min/.cargo/config.toml b/executor/programs/rust/hint_min/.cargo/config.toml new file mode 100644 index 000000000..8ef8239bb --- /dev/null +++ b/executor/programs/rust/hint_min/.cargo/config.toml @@ -0,0 +1,9 @@ +[target.riscv64im-lambda-vm-elf] +rustflags = [ + "--cfg", "getrandom_backend=\"custom\"", + "-C", "passes=lower-atomic" +] + +[env] +CC_riscv64im_lambda_vm_elf = "clang" +CFLAGS_riscv64im_lambda_vm_elf = "--target=riscv64 -march=rv64im -mabi=lp64 --sysroot=/opt/lambda-vm-sysroot" diff --git a/executor/programs/rust/hint_min/Cargo.lock b/executor/programs/rust/hint_min/Cargo.lock new file mode 100644 index 000000000..cc02eff98 --- /dev/null +++ b/executor/programs/rust/hint_min/Cargo.lock @@ -0,0 +1,331 @@ +# 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_min" +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_min/Cargo.toml b/executor/programs/rust/hint_min/Cargo.toml new file mode 100644 index 000000000..4bfe4614f --- /dev/null +++ b/executor/programs/rust/hint_min/Cargo.toml @@ -0,0 +1,9 @@ +[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 new file mode 100644 index 000000000..6903326f9 --- /dev/null +++ b/executor/programs/rust/hint_min/src/main.rs @@ -0,0 +1,26 @@ +//! 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 + one 32-byte MEMW read + one 32-byte +//! MEMW write) so we can get prove→verify to balance before scaling to ethrex. +//! +//! Buffers are 8-byte aligned so the MEMW accesses land in the aligned MEMW table. + +use lambda_vm_syscalls as syscalls; + +#[repr(align(8))] +struct Aligned32([u8; 32]); + +pub fn main() { + // input = 3 (little-endian), a valid invertible field element. + let mut x = Aligned32([0u8; 32]); + x.0[0] = 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/config.toml b/executor/programs/rust/hint_multi/.cargo/config.toml new file mode 100644 index 000000000..8ef8239bb --- /dev/null +++ b/executor/programs/rust/hint_multi/.cargo/config.toml @@ -0,0 +1,9 @@ +[target.riscv64im-lambda-vm-elf] +rustflags = [ + "--cfg", "getrandom_backend=\"custom\"", + "-C", "passes=lower-atomic" +] + +[env] +CC_riscv64im_lambda_vm_elf = "clang" +CFLAGS_riscv64im_lambda_vm_elf = "--target=riscv64 -march=rv64im -mabi=lp64 --sysroot=/opt/lambda-vm-sysroot" diff --git a/executor/programs/rust/hint_multi/Cargo.lock b/executor/programs/rust/hint_multi/Cargo.lock new file mode 100644 index 000000000..9803c875a --- /dev/null +++ b/executor/programs/rust/hint_multi/Cargo.lock @@ -0,0 +1,331 @@ +# 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/Cargo.toml b/executor/programs/rust/hint_multi/Cargo.toml new file mode 100644 index 000000000..faacdb38e --- /dev/null +++ b/executor/programs/rust/hint_multi/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] + +[package] +name = "hint_multi" +version = "0.1.0" +edition = "2024" + +[dependencies] +lambda-vm-syscalls = { path = "../../../../syscalls" } diff --git a/executor/programs/rust/hint_multi/src/main.rs b/executor/programs/rust/hint_multi/src/main.rs new file mode 100644 index 000000000..ddc422d92 --- /dev/null +++ b/executor/programs/rust/hint_multi/src/main.rs @@ -0,0 +1,35 @@ +//! Multi-hint P0/P2 guest for the Hint prover table: THREE `hint` ecalls +//! (field inverse of three different small values), 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) 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]); + + for seed in [3u8, 5u8, 7u8] { + let mut x = Aligned32([0u8; 32]); + x.0[0] = seed; + let mut inv = Aligned32([0u8; 32]); + + syscalls::syscalls::hint(syscalls::syscalls::HINT_FIELD_INV, &mut inv.0, &x.0); + + // Read the hinted output back via ordinary loads and fold it in, so the + // MEMW reads of `inv` must chain to the HINT table's writes. + for i in 0..32 { + acc.0[i] ^= inv.0[i]; + } + } + + syscalls::syscalls::commit(&acc.0); +} diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index ffe9071b2..c954fb5c1 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -1210,6 +1210,115 @@ fn test_prove_ecsm_rust_guest() { ); } +/// P0 for the non-constraining `Hint` ecall (BENCH ONLY): the minimal Rust guest +/// does one `hint` call (secp256k1 base-field inverse of 3) and commits the result. +/// This exercises exactly the HINT table's bus surface (Ecall receive + the four +/// 8-byte output MEMW writes) 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). +#[test] +fn test_prove_hint_min_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_min.elf")) + .expect("hint_min.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 LE). + let mut input = [0u8; 32]; + input[0] = 3; + let expected = + executor::vm::instruction::execution::compute_hint(0 /* HINT_FIELD_INV */, &input); + assert_eq!(proof.public_output, expected.to_vec()); +} + +/// Multi-hint P0/P2 (BENCH ONLY): three `hint` ecalls, 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) and **read-back via normal LOAD** (MEMW reads chaining +/// to the HINT writes). Committed output = XOR of the three field inverses. +#[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 field-inverses of 3, 5, 7 (32-byte LE), matching the guest. + let mut expected = [0u8; 32]; + for seed in [3u8, 5u8, 7u8] { + let mut input = [0u8; 32]; + input[0] = seed; + let inv = + executor::vm::instruction::execution::compute_hint(0 /* HINT_FIELD_INV */, &input); + for i in 0..32 { + expected[i] ^= inv[i]; + } + } + assert_eq!(proof.public_output, expected.to_vec()); +} + +/// Soundness (BENCH ONLY): the verifier REJECTS a forged hint output. +/// +/// The HINT table's `out_bytes` are unconstrained *by the table* — the point of a +/// non-constraining hint. They are pinned instead by the memory argument: the HINT +/// table *sends* the output as MEMW writes, and the MEMW table *receives* the honest +/// values `collect_hint_ops` derived (recomputed from the input, written into +/// `memory_state`). Forge one output byte on the (single) real HINT row and the MEMW +/// write it sends no longer matches the received write → the Memw LogUp bus unbalances +/// → the proof must fail to verify. This is what makes the hint value load-bearing +/// even though the guest here does no in-circuit verify. Mirrors the ECSM analog. +#[test] +fn test_prove_hint_min_forged_result_rejected() { + use crate::tables::hint::cols as hint_cols; + + 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" + ); +} + /// 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 3b599b31f02e5f97666bc8d49e194dcf65610990 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Fri, 24 Jul 2026 09:39:20 -0300 Subject: [PATCH 04/28] Route ecsm inverses and sqrt through hint ecall --- crypto/ethrex-crypto/src/lib.rs | 132 ++++++++++++++++++++++++++++++-- 1 file changed, 126 insertions(+), 6 deletions(-) diff --git a/crypto/ethrex-crypto/src/lib.rs b/crypto/ethrex-crypto/src/lib.rs index c1e5d8446..a51777b3b 100644 --- a/crypto/ethrex-crypto/src/lib.rs +++ b/crypto/ethrex-crypto/src/lib.rs @@ -19,8 +19,11 @@ use ethrex_crypto::keccak::keccak_hash; use ethrex_crypto::{Crypto, CryptoError}; use k256::elliptic_curve::group::prime::PrimeCurveAffine; -use k256::elliptic_curve::ops::{Invert, LinearCombination, Reduce}; -use k256::elliptic_curve::point::DecompressPoint; +use k256::elliptic_curve::ops::{LinearCombination, Reduce}; +// `Invert` (software `x.invert()`) is only used by the host fallback; on the +// riscv64 guest all inversions go through the `hint` ecall. +#[cfg(not(target_arch = "riscv64"))] +use k256::elliptic_curve::ops::Invert; use k256::elliptic_curve::sec1::ToEncodedPoint; use k256::elliptic_curve::PrimeField; use k256::{AffinePoint, FieldBytes, ProjectivePoint, Scalar, U256}; @@ -77,6 +80,94 @@ impl Crypto for LambdaVmEcsmCrypto { /// We compute the recovery directly rather than calling k256's /// `recover_from_prehash`, which internally runs a *second* lincomb to /// re-verify the key — doubling the ECSM ecalls for no gain here. +/// Obtain a 32-byte little-endian hint for `x_le` 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 UNVERIFIED — every caller MUST check it +/// in-guest (`x·inv == 1`, `y² == x³+7`), since the ecall adds no correctness +/// constraint. BENCH scaffolding. +#[cfg(target_arch = "riscv64")] +fn get_hint(hint_id: usize, x_le: &[u8; 32]) -> [u8; 32] { + let mut out = [0u8; 32]; + lambda_vm_syscalls::syscalls::hint(hint_id, &mut out, x_le); + out +} + +/// Scalar-field inverse `x⁻¹ mod n`. On riscv64 (guest) the inverse comes from the +/// `hint` ecall and we verify `x·inv == 1`; off-target (host tests) it computes the +/// inverse in software. BENCH scaffolding. +fn scalar_inv(x: &Scalar) -> Option { + #[cfg(target_arch = "riscv64")] + { + use k256::elliptic_curve::subtle::ConstantTimeEq; + let x_be = x.to_bytes(); + let mut x_le = [0u8; 32]; + for i in 0..32 { + x_le[i] = x_be[31 - i]; + } + let inv_le = get_hint(lambda_vm_syscalls::syscalls::HINT_SCALAR_INV, &x_le); + let mut inv_be = k256::FieldBytes::default(); + for i in 0..32 { + inv_be[i] = inv_le[31 - i]; + } + let inv: Scalar = Option::from(Scalar::from_repr(inv_be))?; + // Verify the untrusted hint: x·inv must equal 1 (mod n). + if bool::from((*x * inv).ct_eq(&Scalar::ONE)) { + Some(inv) + } else { + None + } + } + #[cfg(not(target_arch = "riscv64"))] + { + x.invert_vartime().into() + } +} + +/// Decompress R from its x-coordinate + parity. On riscv64 the `y = sqrt(x³+7)` +/// is an `hint`-ecall value verified in-guest (`y² == x³+7`), with parity +/// selection; off-target it uses k256's software `decompress`. BENCH scaffolding. +fn decompress_r(r_bytes: &FieldBytes, y_is_odd: bool) -> Option { + #[cfg(target_arch = "riscv64")] + { + let x: FieldElement = Option::from(FieldElement::from_bytes(r_bytes))?; + // secp256k1: y² = x³ + 7. + let mut seven_bytes = [0u8; 32]; + seven_bytes[31] = 7; + let seven: FieldElement = Option::from(FieldElement::from_bytes(&seven_bytes.into()))?; + let x3: FieldElement = x.square() * x; + let rhs: FieldElement = x3 + seven; + // Hinted sqrt (LE in/out), then verify y² == rhs canonically. + let rhs_be = rhs.to_bytes(); + let mut rhs_le = [0u8; 32]; + for i in 0..32 { + rhs_le[i] = rhs_be[31 - i]; + } + let y_le = get_hint(lambda_vm_syscalls::syscalls::HINT_FIELD_SQRT, &rhs_le); + let mut y_be = [0u8; 32]; + for i in 0..32 { + y_be[i] = y_le[31 - i]; + } + let mut y: FieldElement = Option::from(FieldElement::from_bytes(&y_be.into()))?; + let y2: FieldElement = y.square(); + if y2.to_bytes() != rhs.to_bytes() { + return None; + } + // Select the root whose canonical LSB matches the requested parity. + let y_odd = (y.to_bytes()[31] & 1) == 1; + if y_odd != y_is_odd { + y = -y; + } + // Build the affine point; `from_encoded_point` re-checks it's on-curve. + let ep = EncodedPoint::from_affine_coordinates(&x.to_bytes(), &y.to_bytes(), false); + Option::from(AffinePoint::from_encoded_point(&ep)) + } + #[cfg(not(target_arch = "riscv64"))] + { + use k256::elliptic_curve::point::DecompressPoint; + AffinePoint::decompress(r_bytes, u8::from(y_is_odd).into()).into() + } +} + fn ecsm_ecrecover(sig: &[u8; 64], recid: u8, msg: &[u8; 32]) -> Result<[u8; 64], CryptoError> { let r_bytes = <&FieldBytes>::from(&sig[..32]); let s_bytes = <&FieldBytes>::from(&sig[32..]); @@ -96,15 +187,14 @@ fn ecsm_ecrecover(sig: &[u8; 64], recid: u8, msg: &[u8; 32]) -> Result<[u8; 64], // precompile; we don't handle it (decompression simply fails), matching the // trait default. let y_is_odd = (recid & 1) != 0; - let r_point: Option = - AffinePoint::decompress(r_bytes, u8::from(y_is_odd).into()).into(); + let r_point: Option = decompress_r(r_bytes, y_is_odd); let Some(r_point) = r_point else { return Err(CryptoError::RecoveryFailed); }; let r_proj = ProjectivePoint::from(r_point); let z = >::reduce_bytes(&FieldBytes::from(*msg)); - let r_inv: Option = r.invert_vartime().into(); + let r_inv: Option = scalar_inv(&r); let Some(r_inv) = r_inv else { return Err(CryptoError::RecoveryFailed); }; @@ -194,6 +284,36 @@ fn ecsm_oracle(x: &FieldElement, k: &Scalar) -> Option { /// then `Q = A + B` is one affine addition. All three inversions are batched. /// /// Generic over the oracle so unit tests can substitute a software stand-in. +/// Base-field inverse `x⁻¹ mod p`. On riscv64 the host supplies it via the +/// `hint` ecall and we verify `x·inv == 1` (canonical byte compare to sidestep +/// k256's lazy-normalized magnitudes); off-target it inverts in software. +#[cfg(any(target_arch = "riscv64", test))] +fn field_inv(x: &FieldElement) -> Option { + #[cfg(target_arch = "riscv64")] + { + let x_be = x.to_bytes(); + let mut x_le = [0u8; 32]; + for i in 0..32 { + x_le[i] = x_be[31 - i]; + } + let inv_le = get_hint(lambda_vm_syscalls::syscalls::HINT_FIELD_INV, &x_le); + let mut inv_be = [0u8; 32]; + for i in 0..32 { + inv_be[i] = inv_le[31 - i]; + } + let inv: FieldElement = Option::from(FieldElement::from_bytes(&inv_be.into()))?; + if (*x * inv).to_bytes() == FieldElement::ONE.to_bytes() { + Some(inv) + } else { + None + } + } + #[cfg(not(target_arch = "riscv64"))] + { + Option::from(x.invert()) + } +} + #[cfg(any(target_arch = "riscv64", test))] fn lincomb2_with_oracle( a1: &AffinePoint, @@ -232,7 +352,7 @@ where // One shared inversion for the two λ denominators and the final chord. let den1 = y1.double() * dx1; let den2 = y2.double() * dx2; - let inv = Option::::from((den1 * den2 * dxq).invert())?; + let inv = field_inv(&(den1 * den2 * dxq))?; let inv_den1 = inv * den2 * dxq; let inv_den2 = inv * den1 * dxq; let inv_dxq = inv * den1 * den2; From e8f421cb5a2698f115c231c69ba8029ba78a993c Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Mon, 27 Jul 2026 18:50:31 -0300 Subject: [PATCH 05/28] Make the hint ecall ABI big-endian --- crypto/ethrex-crypto/src/lib.rs | 46 +++++-------------- executor/programs/rust/hint_min/src/main.rs | 4 +- executor/programs/rust/hint_multi/src/main.rs | 2 +- executor/src/vm/instruction/execution.rs | 28 +++++------ prover/src/tables/trace_builder.rs | 2 +- prover/src/tests/prove_elfs_tests.rs | 8 ++-- syscalls/src/syscalls.rs | 10 ++-- 7 files changed, 36 insertions(+), 64 deletions(-) diff --git a/crypto/ethrex-crypto/src/lib.rs b/crypto/ethrex-crypto/src/lib.rs index a51777b3b..0177f598d 100644 --- a/crypto/ethrex-crypto/src/lib.rs +++ b/crypto/ethrex-crypto/src/lib.rs @@ -80,15 +80,15 @@ impl Crypto for LambdaVmEcsmCrypto { /// We compute the recovery directly rather than calling k256's /// `recover_from_prehash`, which internally runs a *second* lincomb to /// re-verify the key — doubling the ECSM ecalls for no gain here. -/// Obtain a 32-byte little-endian hint for `x_le` via the executor `hint` ecall +/// 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 UNVERIFIED — every caller MUST check it /// in-guest (`x·inv == 1`, `y² == x³+7`), since the ecall adds no correctness /// constraint. BENCH scaffolding. #[cfg(target_arch = "riscv64")] -fn get_hint(hint_id: usize, x_le: &[u8; 32]) -> [u8; 32] { +fn get_hint(hint_id: usize, x_be: &[u8; 32]) -> [u8; 32] { let mut out = [0u8; 32]; - lambda_vm_syscalls::syscalls::hint(hint_id, &mut out, x_le); + lambda_vm_syscalls::syscalls::hint(hint_id, &mut out, x_be); out } @@ -99,17 +99,9 @@ fn scalar_inv(x: &Scalar) -> Option { #[cfg(target_arch = "riscv64")] { use k256::elliptic_curve::subtle::ConstantTimeEq; - let x_be = x.to_bytes(); - let mut x_le = [0u8; 32]; - for i in 0..32 { - x_le[i] = x_be[31 - i]; - } - let inv_le = get_hint(lambda_vm_syscalls::syscalls::HINT_SCALAR_INV, &x_le); - let mut inv_be = k256::FieldBytes::default(); - for i in 0..32 { - inv_be[i] = inv_le[31 - i]; - } - let inv: Scalar = Option::from(Scalar::from_repr(inv_be))?; + let x_be: [u8; 32] = x.to_bytes().into(); + let inv_be = get_hint(lambda_vm_syscalls::syscalls::HINT_SCALAR_INV, &x_be); + let inv: Scalar = Option::from(Scalar::from_repr(inv_be.into()))?; // Verify the untrusted hint: x·inv must equal 1 (mod n). if bool::from((*x * inv).ct_eq(&Scalar::ONE)) { Some(inv) @@ -136,17 +128,9 @@ fn decompress_r(r_bytes: &FieldBytes, y_is_odd: bool) -> Option { let seven: FieldElement = Option::from(FieldElement::from_bytes(&seven_bytes.into()))?; let x3: FieldElement = x.square() * x; let rhs: FieldElement = x3 + seven; - // Hinted sqrt (LE in/out), then verify y² == rhs canonically. - let rhs_be = rhs.to_bytes(); - let mut rhs_le = [0u8; 32]; - for i in 0..32 { - rhs_le[i] = rhs_be[31 - i]; - } - let y_le = get_hint(lambda_vm_syscalls::syscalls::HINT_FIELD_SQRT, &rhs_le); - let mut y_be = [0u8; 32]; - for i in 0..32 { - y_be[i] = y_le[31 - i]; - } + // Hinted sqrt (BE in/out), then verify y² == rhs canonically. + let rhs_be: [u8; 32] = rhs.to_bytes().into(); + let y_be = get_hint(lambda_vm_syscalls::syscalls::HINT_FIELD_SQRT, &rhs_be); let mut y: FieldElement = Option::from(FieldElement::from_bytes(&y_be.into()))?; let y2: FieldElement = y.square(); if y2.to_bytes() != rhs.to_bytes() { @@ -291,16 +275,8 @@ fn ecsm_oracle(x: &FieldElement, k: &Scalar) -> Option { fn field_inv(x: &FieldElement) -> Option { #[cfg(target_arch = "riscv64")] { - let x_be = x.to_bytes(); - let mut x_le = [0u8; 32]; - for i in 0..32 { - x_le[i] = x_be[31 - i]; - } - let inv_le = get_hint(lambda_vm_syscalls::syscalls::HINT_FIELD_INV, &x_le); - let mut inv_be = [0u8; 32]; - for i in 0..32 { - inv_be[i] = inv_le[31 - i]; - } + let x_be: [u8; 32] = x.to_bytes().into(); + let inv_be = get_hint(lambda_vm_syscalls::syscalls::HINT_FIELD_INV, &x_be); let inv: FieldElement = Option::from(FieldElement::from_bytes(&inv_be.into()))?; if (*x * inv).to_bytes() == FieldElement::ONE.to_bytes() { Some(inv) diff --git a/executor/programs/rust/hint_min/src/main.rs b/executor/programs/rust/hint_min/src/main.rs index 6903326f9..f736a6706 100644 --- a/executor/programs/rust/hint_min/src/main.rs +++ b/executor/programs/rust/hint_min/src/main.rs @@ -11,9 +11,9 @@ use lambda_vm_syscalls as syscalls; struct Aligned32([u8; 32]); pub fn main() { - // input = 3 (little-endian), a valid invertible field element. + // input = 3 (big-endian), a valid invertible field element. let mut x = Aligned32([0u8; 32]); - x.0[0] = 3; + x.0[31] = 3; let mut inv = Aligned32([0u8; 32]); syscalls::syscalls::hint( diff --git a/executor/programs/rust/hint_multi/src/main.rs b/executor/programs/rust/hint_multi/src/main.rs index ddc422d92..67f2ab90f 100644 --- a/executor/programs/rust/hint_multi/src/main.rs +++ b/executor/programs/rust/hint_multi/src/main.rs @@ -19,7 +19,7 @@ pub fn main() { for seed in [3u8, 5u8, 7u8] { let mut x = Aligned32([0u8; 32]); - x.0[0] = seed; + x.0[31] = seed; let mut inv = Aligned32([0u8; 32]); syscalls::syscalls::hint(syscalls::syscalls::HINT_FIELD_INV, &mut inv.0, &x.0); diff --git a/executor/src/vm/instruction/execution.rs b/executor/src/vm/instruction/execution.rs index 78d10c1b4..0d7ae1ae0 100644 --- a/executor/src/vm/instruction/execution.rs +++ b/executor/src/vm/instruction/execution.rs @@ -113,22 +113,20 @@ fn store_u256_le(memory: &mut Memory, addr: u64, bytes: &[u8; 32]) -> Result<(), /// BENCH ONLY. Compute a non-constraining hint (modular inverse / sqrt) with the /// same k256 arithmetic the guest verifies against. Input/output are 32-byte -/// little-endian (matching the ECSM ABI). On any failure (non-canonical input, -/// no inverse/sqrt) returns zeros; the guest's verify then fails loudly. +/// 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. On any failure (non-canonical input, no inverse/sqrt) returns zeros; +/// the guest's verify then fails loudly. /// /// `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). -pub fn compute_hint(hint_id: u64, in_le: &[u8; 32]) -> [u8; 32] { +pub fn compute_hint(hint_id: u64, in_be: &[u8; 32]) -> [u8; 32] { use k256::elliptic_curve::PrimeField; - // k256 serialization is big-endian; the ABI is little-endian. - let mut be = [0u8; 32]; - for i in 0..32 { - be[i] = in_le[31 - i]; - } let mut fb = k256::FieldBytes::default(); - fb.copy_from_slice(&be); + fb.copy_from_slice(in_be); - let out_be: [u8; 32] = match hint_id { + match hint_id { HINT_FIELD_INV => { let x: Option = Option::from(k256::FieldElement::from_bytes(&fb)); match x.and_then(|x| Option::::from(x.invert())) { @@ -151,13 +149,7 @@ pub fn compute_hint(hint_id: u64, in_le: &[u8; 32]) -> [u8; 32] { } } _ => [0u8; 32], - }; - - let mut out_le = [0u8; 32]; - for i in 0..32 { - out_le[i] = out_be[31 - i]; } - out_le } /// Checks the ECSM address-alignment assumption: `(addr mod 2^32) + max_offset < 2^32`. @@ -524,7 +516,9 @@ impl Instruction { SyscallNumbers::Hint => { // BENCH ONLY. Non-constraining hint: host computes a modular // inverse/sqrt and writes it to the guest, which verifies it. - // a0 = hint_id, a1 = input addr (32-byte LE), a2 = output addr. + // 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)?; diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 8cf0c1e63..76b08b68b 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -982,7 +982,7 @@ fn collect_hint_ops( let in_addr = register_state.read(11).0; let out_addr = register_state.read(12).0; - // Read the 32-byte little-endian input from the replayed memory. + // 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; diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index c954fb5c1..b02b65e81 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -1234,9 +1234,9 @@ fn test_prove_hint_min_rust_guest() { "hint_min rust guest should verify" ); - // Committed output must equal the hinted value (field inverse of 3, 32-byte LE). + // Committed output must equal the hinted value (field inverse of 3, 32-byte BE). let mut input = [0u8; 32]; - input[0] = 3; + input[31] = 3; let expected = executor::vm::instruction::execution::compute_hint(0 /* HINT_FIELD_INV */, &input); assert_eq!(proof.public_output, expected.to_vec()); @@ -1265,11 +1265,11 @@ fn test_prove_hint_multi_rust_guest() { "hint_multi rust guest should verify" ); - // Expected = XOR of field-inverses of 3, 5, 7 (32-byte LE), matching the guest. + // Expected = XOR of field-inverses of 3, 5, 7 (32-byte BE), matching the guest. let mut expected = [0u8; 32]; for seed in [3u8, 5u8, 7u8] { let mut input = [0u8; 32]; - input[0] = seed; + input[31] = seed; let inv = executor::vm::instruction::execution::compute_hint(0 /* HINT_FIELD_INV */, &input); for i in 0..32 { diff --git a/syscalls/src/syscalls.rs b/syscalls/src/syscalls.rs index e7cc397a8..32176f791 100644 --- a/syscalls/src/syscalls.rs +++ b/syscalls/src/syscalls.rs @@ -199,8 +199,10 @@ pub fn ecsm_mul(_xr: &mut [u8; 32], _xg: &[u8; 32], _k: &[u8; 32]) { /// BENCH ONLY. 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 little-endian field/scalar -/// elements. The result is UNVERIFIED — the caller MUST check it in-guest +/// [`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 UNVERIFIED — the caller MUST check it in-guest /// (e.g. `x·inv == 1`), since this ecall adds no correctness constraint. #[cfg(target_arch = "riscv64")] pub fn hint(hint_id: usize, out: &mut [u8; 32], input: &[u8; 32]) { @@ -208,8 +210,8 @@ pub fn hint(hint_id: usize, out: &mut [u8; 32], input: &[u8; 32]) { asm!( "ecall", in("a0") hint_id, // x10 = hint selector - in("a1") input.as_ptr(), // x11 = input address (32-byte LE) - in("a2") out.as_mut_ptr(), // x12 = output address (32-byte LE) + 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, ) } From 9f839826f76457e9662c6d157506122ce60ecbd9 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Mon, 27 Jul 2026 18:57:37 -0300 Subject: [PATCH 06/28] Validate the Hint ecall operand addresses --- executor/src/tests/hint_tests.rs | 118 +++++++++++++++++++++++ executor/src/tests/mod.rs | 1 + executor/src/vm/instruction/execution.rs | 25 ++++- 3 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 executor/src/tests/hint_tests.rs diff --git a/executor/src/tests/hint_tests.rs b/executor/src/tests/hint_tests.rs new file mode 100644 index 000000000..3b0cb8aae --- /dev/null +++ b/executor/src/tests/hint_tests.rs @@ -0,0 +1,118 @@ +//! Tests for the non-constraining `Hint` syscall (BENCH ONLY). + +use crate::vm::instruction::decoding::Instruction; +use crate::vm::instruction::execution::{ + ExecutionError, HINT_FIELD_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"); +} + +/// An unknown `hint_id` is not an error — the ecall writes zeros and the guest's +/// verify is what rejects the value. Pins that contract so a future selector can't +/// silently start trapping instead. +#[test] +fn hint_syscall_writes_zeros_for_an_unknown_selector() { + let mut input = [0u8; 32]; + input[31] = 3; + let out = + run_hint_at(u64::MAX, 0x1000, 0x2000, &input).expect("unknown selector must not trap"); + assert_eq!(out, [0u8; 32]); +} diff --git a/executor/src/tests/mod.rs b/executor/src/tests/mod.rs index 456607433..244447b22 100644 --- a/executor/src/tests/mod.rs +++ b/executor/src/tests/mod.rs @@ -1,4 +1,5 @@ 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/instruction/execution.rs b/executor/src/vm/instruction/execution.rs index 0d7ae1ae0..87c636d64 100644 --- a/executor/src/vm/instruction/execution.rs +++ b/executor/src/vm/instruction/execution.rs @@ -152,8 +152,12 @@ pub fn compute_hint(hint_id: u64, in_be: &[u8; 32]) -> [u8; 32] { } } -/// Checks the ECSM address-alignment assumption: `(addr mod 2^32) + max_offset < 2^32`. -fn ecsm_addr_ok(addr: u64, max_offset: u64) -> bool { +/// Checks that a 32-byte operand does not overflow its lower 32-bit address limb: +/// `(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. +fn addr_limb_ok(addr: u64, max_offset: u64) -> bool { (addr % LOW_LIMB) + max_offset < LOW_LIMB } @@ -488,9 +492,9 @@ impl Instruction { let addr_xr = registers.read(10)?; let addr_xg = registers.read(11)?; let addr_k = registers.read(12)?; - if !ecsm_addr_ok(addr_xg, 31) - || !ecsm_addr_ok(addr_xr, 31) - || !ecsm_addr_ok(addr_k, 31) + if !addr_limb_ok(addr_xg, 31) + || !addr_limb_ok(addr_xr, 31) + || !addr_limb_ok(addr_k, 31) { return Err(ExecutionError::EcsmAddressOverflow); } @@ -522,6 +526,15 @@ impl Instruction { let hint_id = registers.read(10)?; let in_addr = registers.read(11)?; let out_addr = registers.read(12)?; + // The HINT table sends the output writes as `[out_addr_lo + 8i, + // out_addr_hi]`, so an `out_addr` whose 32-byte range crosses the + // limb boundary would unbalance the memory bus. `in_addr` is not on + // the bus (the input read is not modeled) but is bounded too, so the + // ecall's operand contract is uniform and `load_u256_le` cannot + // overflow its 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)?; @@ -708,6 +721,8 @@ 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("ECSM scalar multiplication error: {0}")] Ecsm(#[from] ecsm::EcsmError), } From eac970b68e908dc4362ea988c80de62ecd70faba Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Mon, 27 Jul 2026 19:01:40 -0300 Subject: [PATCH 07/28] Verify hints by difference instead of byte compare --- crypto/ethrex-crypto/src/lib.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/crypto/ethrex-crypto/src/lib.rs b/crypto/ethrex-crypto/src/lib.rs index 0177f598d..7e9b01ef7 100644 --- a/crypto/ethrex-crypto/src/lib.rs +++ b/crypto/ethrex-crypto/src/lib.rs @@ -133,7 +133,12 @@ fn decompress_r(r_bytes: &FieldBytes, y_is_odd: bool) -> Option { let y_be = get_hint(lambda_vm_syscalls::syscalls::HINT_FIELD_SQRT, &rhs_be); let mut y: FieldElement = Option::from(FieldElement::from_bytes(&y_be.into()))?; let y2: FieldElement = y.square(); - if y2.to_bytes() != rhs.to_bytes() { + // Verify the untrusted root: y² must equal x³+7. Negate `y2`, not `rhs`: + // `Neg` is `negate(1)` and only accepts magnitude 1, which `square()` always + // returns, whereas `rhs` is a sum and carries magnitude 2 — negating it would + // silently compute the wrong value in release, where the debug assert is gone. + // (`ct_eq` is unusable here for the same reason as in `field_inv`.) + if !bool::from((rhs + y2.negate(1)).normalizes_to_zero()) { return None; } // Select the root whose canonical LSB matches the requested parity. @@ -269,8 +274,7 @@ fn ecsm_oracle(x: &FieldElement, k: &Scalar) -> Option { /// /// Generic over the oracle so unit tests can substitute a software stand-in. /// Base-field inverse `x⁻¹ mod p`. On riscv64 the host supplies it via the -/// `hint` ecall and we verify `x·inv == 1` (canonical byte compare to sidestep -/// k256's lazy-normalized magnitudes); off-target it inverts in software. +/// `hint` ecall and we verify `x·inv == 1`; off-target it inverts in software. #[cfg(any(target_arch = "riscv64", test))] fn field_inv(x: &FieldElement) -> Option { #[cfg(target_arch = "riscv64")] @@ -278,7 +282,14 @@ fn field_inv(x: &FieldElement) -> Option { let x_be: [u8; 32] = x.to_bytes().into(); let inv_be = get_hint(lambda_vm_syscalls::syscalls::HINT_FIELD_INV, &x_be); let inv: FieldElement = Option::from(FieldElement::from_bytes(&inv_be.into()))?; - if (*x * inv).to_bytes() == FieldElement::ONE.to_bytes() { + // Verify the untrusted hint: x·inv must equal 1 (mod p). Compare by asking + // whether the difference normalizes to zero — a value-level test that skips + // the two full normalizations a `to_bytes()` compare pays. `ct_eq` is NOT a + // substitute: k256's FieldElement compares raw limbs *and* the magnitude and + // `normalized` tags, so a `mul` result (magnitude 1, unnormalized) never + // compares equal to the normalized `ONE` constant whatever its value. + // `Neg` is `negate(1)`, valid here because `mul` yields magnitude 1. + if bool::from((*x * inv - FieldElement::ONE).normalizes_to_zero()) { Some(inv) } else { None From 8fffbfb4d60432f94a3689a021fa62481c5ce43d Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 29 Jul 2026 16:00:03 -0300 Subject: [PATCH 08/28] Bind HINT writes to x12 and range-check bytes --- prover/src/tables/hint.rs | 85 +++++++++++++-- prover/src/tables/trace_builder.rs | 40 ++++++- prover/src/tests/prove_elfs_tests.rs | 149 +++++++++++++++++++++++++-- 3 files changed, 254 insertions(+), 20 deletions(-) diff --git a/prover/src/tables/hint.rs b/prover/src/tables/hint.rs index 55edc4fe0..25924bfc6 100644 --- a/prover/src/tables/hint.rs +++ b/prover/src/tables/hint.rs @@ -7,35 +7,51 @@ //! (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 two things, and constrains **nothing** about -//! the hinted value (that is the point — soundness lives in the guest's verify): +//! 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. **Sends** the four 8-byte MEMW writes of the output at `out_addr` +0/8/16/24 +//! 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. //! +//! The table has no algebraic constraints (`EmptyConstraints`), so `mu` is a free +//! column, but it needs no boolean constraint: every interaction is gated by it, and +//! the `Ecall` receiver's tuple contains the timestamp, which is unique per +//! instruction (`ts = 4i + 4`). The LogUp identity therefore matches each `(ts, +//! syscall)` tuple on its own, forcing `mu` to equal the CPU's send multiplicity for +//! that ecall — 1 where the CPU issued one, and 0 everywhere else, since a nonzero +//! `mu` on a tuple the CPU never sent leaves the bus unbalanced. Pinning `mu` this +//! way is what also pins the writes and range checks below to real calls. +//! //! ## Columns (37) //! - `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 +use executor::vm::instruction::execution::HINT_SYSCALL_NUMBER; use stark::lookup::{BusInteraction, BusValue, LinearTerm, Multiplicity, Packing}; use stark::trace::TraceTable; use super::types::{BusId, FE, GoldilocksExtension, GoldilocksField, VmTable}; -/// `hint` ecall syscall number — must match -/// `executor::vm::instruction::execution::HINT_SYSCALL_NUMBER`. -const HINT_SYSCALL_NUMBER: u64 = u64::MAX - 20; - pub mod cols { /// timestamp[0]: lower 32 bits of the ecall timestamp pub const TIMESTAMP_0: usize = 0; @@ -130,14 +146,45 @@ fn memw_write(value: [BusValue; 8], base_lo: BusValue, base_hi: BusValue) -> Vec 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. +/// +/// `a0` (the selector) and `a1` (the input address) are deliberately not bound: the +/// table constrains nothing about the value, and the input read is not modelled, so +/// neither reaches the memory argument. `a2` is the only operand that does. pub fn bus_interactions() -> Vec { let mu = || Multiplicity::Column(cols::MU); - let mut out = Vec::with_capacity(5); + let mut out = Vec::with_capacity(22); // ECALL receiver: [ts_lo, ts_hi, syscall_lo32, syscall_hi32]. out.push(BusInteraction::receiver( @@ -151,6 +198,13 @@ pub fn bus_interactions() -> Vec { ], )); + // 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), + )); + // write output: 4 doublewords at out_addr + 8i (timestamp T). for i in 0..4 { let base_lo = BusValue::linear(vec![ @@ -167,5 +221,20 @@ pub fn bus_interactions() -> Vec { )); } + // 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 } diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 76b08b68b..0fed386fa 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -982,6 +982,24 @@ fn collect_hint_ops( let in_addr = register_state.read(11).0; let out_addr = register_state.read(12).0; + let mut memw_ops = Vec::with_capacity(5); + + // Read x12 (out_addr) at ts. This is what ties the write addresses below to the + // ecall's a2: they are emitted from a HINT trace column, and only this memory- + // argument access pins that column to the register the CPU actually held. + // x10/x11 are not emitted on purpose — neither reaches the memory argument (the + // value is unconstrained by design and the input read is not modelled), so an + // access for them would be dead weight. See `tables::hint`. + { + let reg_value = pack_register_value(out_addr); + let (_old_val, old_ts) = register_state.read(12); + memw_ops.push( + MemwOperation::new(true, 2 * 12, reg_value, t, 2, true) + .with_old(reg_value, [old_ts, old_ts, 0, 0, 0, 0, 0, 0]), + ); + register_state.write(12, out_addr, 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() { @@ -992,7 +1010,6 @@ fn collect_hint_ops( 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. - let mut memw_ops = Vec::with_capacity(4); for i in 0..4 { let addr = out_addr.wrapping_add((8 * i) as u64); let mut value = [0u32; 8]; @@ -2316,6 +2333,23 @@ 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 // ============================================================================= @@ -3107,7 +3141,8 @@ 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. + // table owns). COMMIT sends AreBytes+IsHalfword; KECCAK_RND sends XOR/AND/ARE_BYTES/HWSL; + // HINT sends ARE_BYTES for its 32 output cells. // 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 @@ -3146,6 +3181,7 @@ 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 diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index b02b65e81..787b327db 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -1279,18 +1279,24 @@ fn test_prove_hint_multi_rust_guest() { assert_eq!(proof.public_output, expected.to_vec()); } -/// Soundness (BENCH ONLY): the verifier REJECTS a forged hint output. +/// Consistency (BENCH ONLY): 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. They are pinned instead by the memory argument: the HINT -/// table *sends* the output as MEMW writes, and the MEMW table *receives* the honest -/// values `collect_hint_ops` derived (recomputed from the input, written into -/// `memory_state`). Forge one output byte on the (single) real HINT row and the MEMW -/// write it sends no longer matches the received write → the Memw LogUp bus unbalances -/// → the proof must fail to verify. This is what makes the hint value load-bearing -/// even though the guest here does no in-circuit verify. Mirrors the ECSM analog. -#[test] -fn test_prove_hint_min_forged_result_rejected() { +/// 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; let _ = env_logger::builder().is_test(true).try_init(); @@ -1319,6 +1325,129 @@ fn test_prove_hint_min_forged_result_rejected() { ); } +/// 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(), + 1, + "HINT must send exactly one MEMW register read (out_addr → x12)" + ); + let v = &reads[0].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)" + ); + } + assert!( + matches!(reads[0].multiplicity, Multiplicity::Column(c) if c == hint_cols::MU), + "the register read must be gated by mu, like every other HINT interaction" + ); +} + +/// 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"); + + 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")); + } + } + + // 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" + ); +} + /// 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 f4173605ab76226929beb6f3d73d2a7dc9494608 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 29 Jul 2026 16:00:17 -0300 Subject: [PATCH 09/28] Fix hint doc placement and guest cargo config --- crypto/ethrex-crypto/src/lib.rs | 62 +++++++++---------- .../programs/rust/hint_min/.cargo/config.toml | 4 -- executor/programs/rust/hint_min/src/main.rs | 10 ++- .../rust/hint_multi/.cargo/config.toml | 4 -- 4 files changed, 38 insertions(+), 42 deletions(-) diff --git a/crypto/ethrex-crypto/src/lib.rs b/crypto/ethrex-crypto/src/lib.rs index 7e9b01ef7..8b520f384 100644 --- a/crypto/ethrex-crypto/src/lib.rs +++ b/crypto/ethrex-crypto/src/lib.rs @@ -63,23 +63,6 @@ impl Crypto for LambdaVmEcsmCrypto { // ── ECDSA secp256k1 recovery via the ECSM precompile ──────────────────────── -/// Recover the uncompressed public key bytes (X‖Y, 64 bytes) from a 64-byte -/// signature, recovery id, and 32-byte message hash. Used by the ECRECOVER -/// precompile (0x01). -/// -/// Returns the raw 64-byte key; the caller is responsible for hashing it. -/// Keeping keccak out of this function lets `secp256k1_ecrecover` route the -/// hash through `self.keccak256`, which uses the keccak_permute precompile on -/// riscv64 instead of always falling back to software. -/// -/// Mirrors the pure-Rust recovery in the `Crypto` trait default -/// (`pk = r⁻¹·(s·R − z·G)`), but evaluates the 2-term linear combination -/// `lincomb(G, u1, R, u2)` through the ECSM accelerator via [`ecsm_lincomb2`], -/// falling back to the software `ProjectivePoint::lincomb` whenever the -/// accelerated path declines (degenerate scalars/points, or non-riscv builds). -/// We compute the recovery directly rather than calling k256's -/// `recover_from_prehash`, which internally runs a *second* lincomb to -/// re-verify the key — doubling the ECSM ecalls for no gain here. /// 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 UNVERIFIED — every caller MUST check it @@ -157,6 +140,23 @@ fn decompress_r(r_bytes: &FieldBytes, y_is_odd: bool) -> Option { } } +/// Recover the uncompressed public key bytes (X‖Y, 64 bytes) from a 64-byte +/// signature, recovery id, and 32-byte message hash. Used by the ECRECOVER +/// precompile (0x01). +/// +/// Returns the raw 64-byte key; the caller is responsible for hashing it. +/// Keeping keccak out of this function lets `secp256k1_ecrecover` route the +/// hash through `self.keccak256`, which uses the keccak_permute precompile on +/// riscv64 instead of always falling back to software. +/// +/// Mirrors the pure-Rust recovery in the `Crypto` trait default +/// (`pk = r⁻¹·(s·R − z·G)`), but evaluates the 2-term linear combination +/// `lincomb(G, u1, R, u2)` through the ECSM accelerator via [`ecsm_lincomb2`], +/// falling back to the software `ProjectivePoint::lincomb` whenever the +/// accelerated path declines (degenerate scalars/points, or non-riscv builds). +/// We compute the recovery directly rather than calling k256's +/// `recover_from_prehash`, which internally runs a *second* lincomb to +/// re-verify the key — doubling the ECSM ecalls for no gain here. fn ecsm_ecrecover(sig: &[u8; 64], recid: u8, msg: &[u8; 32]) -> Result<[u8; 64], CryptoError> { let r_bytes = <&FieldBytes>::from(&sig[..32]); let s_bytes = <&FieldBytes>::from(&sig[32..]); @@ -259,20 +259,6 @@ fn ecsm_oracle(x: &FieldElement, k: &Scalar) -> Option { Option::from(FieldElement::from_bytes(&xr_le.into())) } -/// Computes `k1·P1 + k2·P2` from four x-only oracle queries, or `None` if any -/// degenerate-configuration guard trips. -/// -/// The lambda-vm ECSM precompile returns only `x(k·P)`. For `A = k1·P1` with -/// `P1 = (xp, yp)` fully known, query `xa = x(k1·P1)` and `xc = x((k1+1)·P1)`. -/// The chord-addition law gives `λ² = xc + xa + xp =: t` and `ya = yp + λ·dx` -/// with `dx = xa − xp`; substituting into `ya² = xa³ + b` makes λ *linear*: -/// `λ = (xa³ − xp³ − t·dx²) / (2·yp·dx)`. The wrong sign `−ya` would force -/// `x((k1−1)·P1) = xc`, i.e. `k1 ≡ 0` or `2·k1 ≡ 0 (mod n)`, excluded by the -/// scalar guards. x-only queries are parity-invariant (`x(k·P) = x(k·(−P))`), -/// so the precompile's canonical-y lift never matters. Same for `B = k2·P2`, -/// then `Q = A + B` is one affine addition. All three inversions are batched. -/// -/// Generic over the oracle so unit tests can substitute a software stand-in. /// Base-field inverse `x⁻¹ mod p`. On riscv64 the host supplies it via the /// `hint` ecall and we verify `x·inv == 1`; off-target it inverts in software. #[cfg(any(target_arch = "riscv64", test))] @@ -301,6 +287,20 @@ fn field_inv(x: &FieldElement) -> Option { } } +/// Computes `k1·P1 + k2·P2` from four x-only oracle queries, or `None` if any +/// degenerate-configuration guard trips. +/// +/// The lambda-vm ECSM precompile returns only `x(k·P)`. For `A = k1·P1` with +/// `P1 = (xp, yp)` fully known, query `xa = x(k1·P1)` and `xc = x((k1+1)·P1)`. +/// The chord-addition law gives `λ² = xc + xa + xp =: t` and `ya = yp + λ·dx` +/// with `dx = xa − xp`; substituting into `ya² = xa³ + b` makes λ *linear*: +/// `λ = (xa³ − xp³ − t·dx²) / (2·yp·dx)`. The wrong sign `−ya` would force +/// `x((k1−1)·P1) = xc`, i.e. `k1 ≡ 0` or `2·k1 ≡ 0 (mod n)`, excluded by the +/// scalar guards. x-only queries are parity-invariant (`x(k·P) = x(k·(−P))`), +/// so the precompile's canonical-y lift never matters. Same for `B = k2·P2`, +/// then `Q = A + B` is one affine addition. All three inversions are batched. +/// +/// Generic over the oracle so unit tests can substitute a software stand-in. #[cfg(any(target_arch = "riscv64", test))] fn lincomb2_with_oracle( a1: &AffinePoint, diff --git a/executor/programs/rust/hint_min/.cargo/config.toml b/executor/programs/rust/hint_min/.cargo/config.toml index 8ef8239bb..ca99a3f45 100644 --- a/executor/programs/rust/hint_min/.cargo/config.toml +++ b/executor/programs/rust/hint_min/.cargo/config.toml @@ -3,7 +3,3 @@ rustflags = [ "--cfg", "getrandom_backend=\"custom\"", "-C", "passes=lower-atomic" ] - -[env] -CC_riscv64im_lambda_vm_elf = "clang" -CFLAGS_riscv64im_lambda_vm_elf = "--target=riscv64 -march=rv64im -mabi=lp64 --sysroot=/opt/lambda-vm-sysroot" diff --git a/executor/programs/rust/hint_min/src/main.rs b/executor/programs/rust/hint_min/src/main.rs index f736a6706..2a93ff4eb 100644 --- a/executor/programs/rust/hint_min/src/main.rs +++ b/executor/programs/rust/hint_min/src/main.rs @@ -1,9 +1,13 @@ //! 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 + one 32-byte MEMW read + one 32-byte -//! MEMW write) so we can get prove→verify to balance before scaling to ethrex. +//! 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 MEMW accesses land in the aligned MEMW table. +//! Buffers are 8-byte aligned so the writes land in the aligned MEMW table, which is +//! a preference rather than a requirement — `classify_memw` routes unaligned accesses +//! to the general MEMW table, and the ethrex call site is in fact unaligned. use lambda_vm_syscalls as syscalls; diff --git a/executor/programs/rust/hint_multi/.cargo/config.toml b/executor/programs/rust/hint_multi/.cargo/config.toml index 8ef8239bb..ca99a3f45 100644 --- a/executor/programs/rust/hint_multi/.cargo/config.toml +++ b/executor/programs/rust/hint_multi/.cargo/config.toml @@ -3,7 +3,3 @@ rustflags = [ "--cfg", "getrandom_backend=\"custom\"", "-C", "passes=lower-atomic" ] - -[env] -CC_riscv64im_lambda_vm_elf = "clang" -CFLAGS_riscv64im_lambda_vm_elf = "--target=riscv64 -march=rv64im -mabi=lp64 --sysroot=/opt/lambda-vm-sysroot" From 2f30acf9bbd2914f682ba6b90a0b22b72dd4b27c Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 28 Jul 2026 17:48:24 -0300 Subject: [PATCH 10/28] Return the affine point (x, y) from ECSM --- crypto/ecsm/src/curve.rs | 10 +- crypto/ecsm/src/lib.rs | 9 ++ crypto/ethrex-crypto/src/lib.rs | 115 +++++++------------ crypto/ethrex-crypto/src/tests/ecsm_tests.rs | 70 +++++------ executor/src/vm/instruction/execution.rs | 9 +- prover/src/tables/ecsm.rs | 27 +++++ prover/src/tables/trace_builder.rs | 17 +++ syscalls/src/syscalls.rs | 23 ++++ 8 files changed, 165 insertions(+), 115 deletions(-) diff --git a/crypto/ecsm/src/curve.rs b/crypto/ecsm/src/curve.rs index c5c9f5714..6ed1a26f9 100644 --- a/crypto/ecsm/src/curve.rs +++ b/crypto/ecsm/src/curve.rs @@ -153,11 +153,19 @@ fn schedule(k: &BigUint) -> Vec<(u8, u8, u8)> { /// multiplication. Needs no step list or slopes, so it skips all witness work. /// `k` must be in `[1, N)` (guaranteed by `prepare`). pub fn scalar_mul_affine_x(k: &BigUint, g: &AffinePoint) -> BigUint { + scalar_mul_affine(k, g).x +} + +/// Executor fast path (affine PoC): the full affine point `k·g`. Same convention as +/// `scalar_mul_affine_x` / the witness — `g` is the even-`y` lift of its x-coordinate, +/// so `k·g`'s y matches the ECDAS-constrained `y_r`. Returns both coordinates so the +/// `ecsm_mul_affine` syscall can hand `y` back to the guest. +pub fn scalar_mul_affine(k: &BigUint, g: &AffinePoint) -> AffinePoint { let scalar = Option::::from(Scalar::from_repr(be32(k).into())) .expect("ECSM: scalar k must be < N"); let g_proj = ProjectivePoint::from(to_k256_affine(g)); let r = (g_proj * scalar).to_affine(); - from_k256_affine(&r).x + from_k256_affine(&r) } /// Jacobian doubling (dbl-2009-l) for `y² = x³ + 7`: on `(X:Y:Z)` with diff --git a/crypto/ecsm/src/lib.rs b/crypto/ecsm/src/lib.rs index e3a5e3a33..f03de9a54 100644 --- a/crypto/ecsm/src/lib.rs +++ b/crypto/ecsm/src/lib.rs @@ -126,3 +126,12 @@ pub fn scalar_mul_x(k_le: &[u8; 32], xg_le: &[u8; 32]) -> Result<[u8; 32], EcsmE let (k, g) = prepare(k_le, xg_le)?; Ok(to_le_32(&curve::scalar_mul_affine_x(&k, &g))) } + +/// Affine PoC entry point: both coordinates of `k·G` as little-endian 32-byte values, +/// with `y` on the even-`y` convention (matching the witness / ECDAS-constrained `y_r`). +/// The executor writes `xR` then `yR` (contiguous 64-byte output) back to guest memory. +pub fn scalar_mul_xy(k_le: &[u8; 32], xg_le: &[u8; 32]) -> Result<([u8; 32], [u8; 32]), EcsmError> { + let (k, g) = prepare(k_le, xg_le)?; + let r = curve::scalar_mul_affine(&k, &g); + Ok((to_le_32(&r.x), to_le_32(&r.y))) +} diff --git a/crypto/ethrex-crypto/src/lib.rs b/crypto/ethrex-crypto/src/lib.rs index 8b520f384..de54e702e 100644 --- a/crypto/ethrex-crypto/src/lib.rs +++ b/crypto/ethrex-crypto/src/lib.rs @@ -212,10 +212,10 @@ fn ecsm_ecrecover(sig: &[u8; 64], recid: u8, msg: &[u8; 32]) -> Result<[u8; 64], /// ECSM-accelerated 2-term linear combination `k1·P1 + k2·P2`. /// -/// On riscv64 this reconstructs the full affine result from four x-only ECSM -/// queries (see [`lincomb2_with_oracle`]); on other targets, and whenever a -/// guard trips (degenerate input or oracle inconsistency), it returns `None` -/// so the caller uses the pure-Rust `ProjectivePoint::lincomb`. +/// AFFINE PoC: on riscv64 this uses TWO affine ECSM queries (the precompile now +/// returns `(x, y)`, see [`lincomb2_with_oracle`]) instead of four x-only queries +/// plus chord-law y-reconstruction; on other targets, and whenever a guard trips, +/// it returns `None` so the caller uses the pure-Rust `ProjectivePoint::lincomb`. #[cfg(target_arch = "riscv64")] fn ecsm_lincomb2( a1: &AffinePoint, @@ -236,15 +236,15 @@ fn ecsm_lincomb2( None } -/// x-only scalar-mul oracle backed by the ECSM precompile: computes `x(k·P)` -/// for the curve point P whose x-coordinate is passed in. `x` must be the -/// x-coordinate of a curve point and `k` in `(0, N)` (N = curve order) — -/// guaranteed by the guards in [`lincomb2_with_oracle`]. Values cross the ABI -/// as 32-byte little-endian; `x_le` and `k_le` are distinct stack arrays so -/// the executor's `|addr_x_le − addr_k_le| ≥ 32` assumption holds by -/// construction. +/// AFFINE oracle backed by the ECSM precompile: computes the full point `k·P_even`, +/// where `P_even` is the EVEN-`y` lift of the passed x-coordinate (the precompile's +/// canonical convention). Returns `(x, y)` of `k·P_even` as normalized field elements. +/// The caller ([`lincomb2_with_oracle`]) flips `y` if the real input point's `y` is +/// odd. `x` must be a valid curve x-coordinate and `k` in `(0, N)`. Values cross the +/// ABI as 32-byte little-endian; `x_le`/`k_le` are distinct stack arrays (executor's +/// `|addr_x − addr_k| ≥ 32` assumption) and `out` is a 64-byte `[xR‖yR]` buffer. #[cfg(target_arch = "riscv64")] -fn ecsm_oracle(x: &FieldElement, k: &Scalar) -> Option { +fn ecsm_oracle(x: &FieldElement, k: &Scalar) -> Option<(FieldElement, FieldElement)> { let x_be = x.to_bytes(); let k_be = k.to_bytes(); let mut x_le = [0u8; 32]; @@ -253,10 +253,17 @@ fn ecsm_oracle(x: &FieldElement, k: &Scalar) -> Option { x_le[i] = x_be[31 - i]; k_le[i] = k_be[31 - i]; } - let mut xr_le = [0u8; 32]; - lambda_vm_syscalls::syscalls::ecsm_mul(&mut xr_le, &x_le, &k_le); - xr_le.reverse(); - Option::from(FieldElement::from_bytes(&xr_le.into())) + let mut out = [0u8; 64]; + lambda_vm_syscalls::syscalls::ecsm_mul_affine(&mut out, &x_le, &k_le); + let mut xr_be = [0u8; 32]; + let mut yr_be = [0u8; 32]; + for i in 0..32 { + xr_be[i] = out[31 - i]; + yr_be[i] = out[32 + 31 - i]; + } + let xr = Option::::from(FieldElement::from_bytes(&xr_be.into()))?; + let yr = Option::::from(FieldElement::from_bytes(&yr_be.into()))?; + Some((xr.normalize(), yr.normalize())) } /// Base-field inverse `x⁻¹ mod p`. On riscv64 the host supplies it via the @@ -310,45 +317,43 @@ fn lincomb2_with_oracle( oracle: O, ) -> Option where - O: Fn(&FieldElement, &Scalar) -> Option, + O: Fn(&FieldElement, &Scalar) -> Option<(FieldElement, FieldElement)>, { // Inputs are affine already (the ecrecover path lifts them from known Z=1 // points), so no projective→affine inversion is needed here. if bool::from(a1.is_identity()) || bool::from(a2.is_identity()) { return None; } - if scalar_near_edge(k1) || scalar_near_edge(k2) { + if bool::from(k1.is_zero()) || bool::from(k2.is_zero()) { return None; } let (x1, y1) = affine_xy(a1)?; let (x2, y2) = affine_xy(a2)?; - let xa = oracle(&x1, k1)?; - let xc1 = oracle(&x1, &(*k1 + Scalar::ONE))?; - let xb = oracle(&x2, k2)?; - let xc2 = oracle(&x2, &(*k2 + Scalar::ONE))?; + // The oracle returns k·(x, y_even) (even-y lift). The real input point may be the + // odd-y sibling, i.e. −(x, y_even); then k·(real) = −(k·(x, y_even)), so flip yR. + // y parity = LSB of the big-endian canonical coordinate (byte 31). + let (xa, ya_even) = oracle(&x1, k1)?; + let (xb, yb_even) = oracle(&x2, k2)?; + let ya = if y1.to_bytes()[31] & 1 == 1 { + ya_even.negate(1).normalize() + } else { + ya_even + }; + let yb = if y2.to_bytes()[31] & 1 == 1 { + yb_even.negate(1).normalize() + } else { + yb_even + }; - let dx1 = (xa - x1).normalize(); - let dx2 = (xb - x2).normalize(); + // Q = A + B via one chord addition (A ≠ ±B ⇒ dxq ≠ 0). One field inversion. let dxq = (xb - xa).normalize(); - if bool::from(dx1.is_zero()) || bool::from(dx2.is_zero()) || bool::from(dxq.is_zero()) { + if bool::from(dxq.is_zero()) { return None; } - - // One shared inversion for the two λ denominators and the final chord. - let den1 = y1.double() * dx1; - let den2 = y2.double() * dx2; - let inv = field_inv(&(den1 * den2 * dxq))?; - let inv_den1 = inv * den2 * dxq; - let inv_den2 = inv * den1 * dxq; - let inv_dxq = inv * den1 * den2; - - let ya = solve_y(&x1, &y1, &xa, &xc1, &dx1, &inv_den1)?; - let yb = solve_y(&x2, &y2, &xb, &xc2, &dx2, &inv_den2)?; - - // Q = A + B, with A ≠ ±B ensured by dxq ≠ 0. - let lq = (yb - ya) * inv_dxq; + let inv_dxq = field_inv(&dxq)?; + let lq = ((yb - ya) * inv_dxq).normalize(); let xq = (lq.square() - xa - xb).normalize(); let yq = (lq * (xa - xq) - ya).normalize(); @@ -359,38 +364,6 @@ where point_from_xy(&xq, &yq) } -/// Recovers `y(k·P)` from `xa = x(k·P)` and `xc = x((k+1)·P)`. -/// Returns `None` if `xc` is inconsistent with the computed `lambda` -/// (oracle misbehavior); degeneracy guards are in [`lincomb2_with_oracle`]. -#[cfg(any(target_arch = "riscv64", test))] -fn solve_y( - xp: &FieldElement, - yp: &FieldElement, - xa: &FieldElement, - xc: &FieldElement, - dx: &FieldElement, - inv_den: &FieldElement, -) -> Option { - let t = *xc + xa + xp; - let xa3 = xa.square() * xa; - let xp3 = xp.square() * xp; - let lambda = (xa3 - xp3 - t * dx.square()) * inv_den; - if lambda.square().normalize() != t.normalize() { - return None; - } - Some((*yp + lambda * dx).normalize()) -} - -/// `k ∈ {0, 1, n−1}`: fast early-exit before oracle calls. -/// k=0: invalid ecall scalar. k=1: dx=0. k=n-1: k+1 wraps to 0 mod n. -#[cfg(any(target_arch = "riscv64", test))] -fn scalar_near_edge(k: &Scalar) -> bool { - use k256::elliptic_curve::subtle::ConstantTimeEq; - bool::from(k.is_zero()) - || bool::from(k.ct_eq(&Scalar::ONE)) - || bool::from(k.ct_eq(&(-Scalar::ONE))) -} - /// Affine `(x, y)` of a non-identity point as field elements, via its SEC1 /// uncompressed encoding (k256 keeps `AffinePoint`'s coordinate fields private). #[cfg(any(target_arch = "riscv64", test))] diff --git a/crypto/ethrex-crypto/src/tests/ecsm_tests.rs b/crypto/ethrex-crypto/src/tests/ecsm_tests.rs index 89c911db7..45084eed9 100644 --- a/crypto/ethrex-crypto/src/tests/ecsm_tests.rs +++ b/crypto/ethrex-crypto/src/tests/ecsm_tests.rs @@ -11,15 +11,23 @@ fn curve_b() -> FieldElement { FieldElement::from_bytes(&bytes.into()).unwrap() } -/// Software stand-in for the ECSM precompile: lift `x` to a curve point and -/// return `x(k·P)` (parity-invariant, like the real ecall). -fn soft_oracle(x: &FieldElement, k: &Scalar) -> Option { +/// Software stand-in for the affine ECSM precompile: lift `x` to the EVEN-`y` curve +/// point `P_even` and return `(x, y)` of `k·P_even` (matching the real ecall's +/// even-`y` convention; the caller flips the sign for odd-`y` inputs). +fn soft_oracle(x: &FieldElement, k: &Scalar) -> Option<(FieldElement, FieldElement)> { let xn = x.normalize(); let y2 = (xn.square() * xn + curve_b()).normalize(); - let y = Option::::from(y2.sqrt())?; - let p = point_from_xy(&xn, &y.normalize())?; + let y = Option::::from(y2.sqrt())?.normalize(); + // Even-y lift: pick the root whose LSB (big-endian byte 31) is 0. + let y_even = if y.to_bytes()[31] & 1 == 0 { + y + } else { + y.negate(1).normalize() + }; + let p = point_from_xy(&xn, &y_even)?; let prod = (p * k).to_affine(); - Some(affine_xy(&prod)?.0) + let (xr, yr) = affine_xy(&prod)?; + Some((xr.normalize(), yr.normalize())) } fn g_times(n: u64) -> ProjectivePoint { @@ -57,13 +65,23 @@ fn matches_software_lincomb_on_recovery_shape() { #[test] fn edge_scalars_fall_back() { + // AFFINE PoC: only k=0 falls back now. The old x-only path also rejected k=1 + // and k=n−1 (the (k+1)·P query wrapped); the affine oracle makes no such query, + // so those scalars reconstruct normally. let p1 = g_times(3); let p2 = g_times(5); let ok = Scalar::from(12345u64); - for bad in [Scalar::ZERO, Scalar::ONE, -Scalar::ONE] { + for bad in [Scalar::ZERO] { assert!(lincomb2_with_oracle(&p1.to_affine(), &bad, &p2.to_affine(), &ok, soft_oracle).is_none()); assert!(lincomb2_with_oracle(&p1.to_affine(), &ok, &p2.to_affine(), &bad, soft_oracle).is_none()); } + // k=1 and k=n−1 now reconstruct correctly. + for good in [Scalar::ONE, -Scalar::ONE] { + let expected = ProjectivePoint::lincomb(&p1, &good, &p2, &ok); + let got = lincomb2_with_oracle(&p1.to_affine(), &good, &p2.to_affine(), &ok, soft_oracle) + .expect("k=1 / k=n−1 are valid for the affine oracle"); + assert_eq!(got, expected.to_affine()); + } } #[test] @@ -86,10 +104,8 @@ fn cancelling_and_doubling_terms_fall_back() { #[test] fn k_half_n_minus_1_reconstructs_correctly() { - // k = (n-1)/2 satisfies k·P = -(k+1)·P for any P, so the oracle returns - // the same x-coordinate for both the k and k+1 calls (xa = xc). The - // solve_y algebra still holds: lambda² = 2·xa + xp = t, so the check - // passes and the correct ya is recovered. + // k = (n-1)/2 was a special case for the old x-only path; with the affine + // oracle it is an ordinary scalar and must reconstruct correctly. let two_inv = Scalar::from(2u64) .invert_vartime() .expect("2 is invertible mod n"); @@ -123,38 +139,10 @@ fn cross_point_cancellation_falls_back() { ); } -#[test] -fn solve_y_rejects_inconsistent_oracle_xc() { - // Directly test that solve_y's lambda² == t check fires when xc is wrong. - // This is the oracle-misbehavior guard: it cannot easily be reached via - // lincomb2_with_oracle because the oracle is Fn (no mutable state to - // return xa correct and xc wrong in separate calls). - let (xp, yp) = affine_xy(&g_times(3).to_affine()).unwrap(); - let k = Scalar::from(12345u64); - - let xa = soft_oracle(&xp, &k).unwrap(); - let xc_correct = soft_oracle(&xp, &(k + Scalar::ONE)).unwrap(); - // xc from k+100 is inconsistent with xa from k — lambda²=t must reject it. - let xc_wrong = soft_oracle(&xp, &(k + Scalar::from(100u64))).unwrap(); - - let dx = (xa - xp).normalize(); - let inv_den = Option::::from((yp.double() * dx).invert()) - .expect("dx is nonzero for k=12345"); - - assert!( - solve_y(&xp, &yp, &xa, &xc_correct, &dx, &inv_den).is_some(), - "correct xc must pass the lambda² check" - ); - assert!( - solve_y(&xp, &yp, &xa, &xc_wrong, &dx, &inv_den).is_none(), - "inconsistent xc (oracle misbehavior) must be rejected by the lambda² check" - ); -} - #[test] fn odd_y_base_point_reconstructs_correctly() { - // Validates the solve_y sign-selection argument: when P1 has odd y the - // reconstruction must still match ProjectivePoint::lincomb. + // Validates the affine oracle's odd-y sign flip: when P1 has odd y the caller + // must negate the oracle's even-y result, matching ProjectivePoint::lincomb. let (p1, _k_gen) = (2u64..200) .find_map(|n| { let p = g_times(n); diff --git a/executor/src/vm/instruction/execution.rs b/executor/src/vm/instruction/execution.rs index 87c636d64..593de26df 100644 --- a/executor/src/vm/instruction/execution.rs +++ b/executor/src/vm/instruction/execution.rs @@ -492,8 +492,10 @@ impl Instruction { let addr_xr = registers.read(10)?; let addr_xg = registers.read(11)?; let addr_k = registers.read(12)?; + // AFFINE PoC: the output is a contiguous 64-byte buffer at + // addr_xr (xR at +0, yR at +32), so it spans offset 63. if !addr_limb_ok(addr_xg, 31) - || !addr_limb_ok(addr_xr, 31) + || !addr_limb_ok(addr_xr, 63) || !addr_limb_ok(addr_k, 31) { return Err(ExecutionError::EcsmAddressOverflow); @@ -510,8 +512,11 @@ impl Instruction { } let xg = load_u256_le(memory, addr_xg)?; let k = load_u256_le(memory, addr_k)?; - let xr = ecsm::scalar_mul_x(&k, &xg)?; + // AFFINE PoC: return both coordinates so the guest skips the + // x-only (k+1)·P y-reconstruction. xR at addr_xr, yR at +32. + let (xr, yr) = ecsm::scalar_mul_xy(&k, &xg)?; store_u256_le(memory, addr_xr, &xr)?; + store_u256_le(memory, addr_xr.wrapping_add(32), &yr)?; // Carry addr_xG/addr_k in the CPU log; addr_xR is recovered from x10 // by the ECSM register-read path in the trace builder. src2_val = addr_xg; diff --git a/prover/src/tables/ecsm.rs b/prover/src/tables/ecsm.rs index 5d0a9477f..dd466f119 100644 --- a/prover/src/tables/ecsm.rs +++ b/prover/src/tables/ecsm.rs @@ -443,6 +443,33 @@ pub fn bus_interactions() -> Vec { )); } + // AFFINE PoC: write yR as 4 doublewords at addr_xR + 32 + 8i (ts + 3). Reuses the + // ADDR_XR register (output buffer is the contiguous 64-byte [xR‖yR]); no new column + // or register read. yR (col YR) is the ECDAS-constrained y of k·(xG, even-yG). The + // guest fixes the sign from its known yG parity. ts + 3 is the free 4th sub-timestamp + // (instruction stride is 4; xG@T, k@T+1, xR@T+2 use the first three). + for i in 0..4 { + let base_lo = BusValue::linear(vec![ + LinearTerm::Column { + coefficient: 1, + column: cols::ADDR_XR_0, + }, + LinearTerm::Constant((32 + 8 * i) as i64), + ]); + out.push(BusInteraction::sender( + BusId::Memw, + mu(), + memw_write( + dword_bytes(cols::YR, i), + base_lo, + packed(cols::ADDR_XR_1), + ts_lo_plus(3), + ts_hi(), + 1, + ), + )); + } + // IS_BYTE range checks (single byte → AreBytes[x, 0]). let is_byte = |col: usize, len: usize, out: &mut Vec| { for i in 0..len { diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 0fed386fa..019b49f0f 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -942,6 +942,23 @@ fn collect_ecsm_ops( memory_state.write_bytes(addr, dword, 8, t + 2); } + // AFFINE PoC: yR writes at T + 3 (4 doublewords at addr_xR + 32 + 8i). Matches the + // ecsm.rs YR sender block; the executor wrote yR to addr_xR + 32. + for i in 0..4 { + let addr = addr_xr.wrapping_add((32 + 8 * i) as u64); + let mut value = [0u32; 8]; + let mut dword = 0u64; + for j in 0..8 { + value[j] = witness.y_r[8 * i + j] as u32; + dword |= (witness.y_r[8 * i + j] as u64) << (8 * j); + } + let (old_vals, old_ts) = memory_state.read_bytes(addr, 8); + memw_ops.push( + MemwOperation::new(false, addr, value, t + 3, 8, false).with_old(old_vals, old_ts), + ); + memory_state.write_bytes(addr, dword, 8, t + 3); + } + let ecdas_ops = witness .steps .iter() diff --git a/syscalls/src/syscalls.rs b/syscalls/src/syscalls.rs index 32176f791..4039b1819 100644 --- a/syscalls/src/syscalls.rs +++ b/syscalls/src/syscalls.rs @@ -191,6 +191,29 @@ pub fn ecsm_mul(xr: &mut [u8; 32], xg: &[u8; 32], k: &[u8; 32]) { } } +/// AFFINE PoC: compute `k·G` on secp256k1 and write BOTH coordinates into a contiguous +/// 64-byte buffer (`xR` at `out[0..32]`, `yR` at `out[32..64]`), all 32-byte little-endian. +/// `yR` follows the even-`yG` convention; the caller flips its sign if the real `yG` is odd. +/// Same ABI as [`ecsm_mul`] except `x10` points at a 64-byte output. Lets ECDSA recovery +/// avoid the second `(k+1)·P` query and the x-only y-reconstruction. +#[cfg(target_arch = "riscv64")] +pub fn ecsm_mul_affine(out: &mut [u8; 64], xg: &[u8; 32], k: &[u8; 32]) { + unsafe { + asm!( + "ecall", + in("a0") out.as_mut_ptr(), // x10 = address to write [xR‖yR] (64 bytes) + in("a1") xg.as_ptr(), // x11 = address of xG + in("a2") k.as_ptr(), // x12 = address of k + in("a7") ECSM_SYSCALL_NUMBER, + ) + } +} + +#[cfg(not(target_arch = "riscv64"))] +pub fn ecsm_mul_affine(_out: &mut [u8; 64], _xg: &[u8; 32], _k: &[u8; 32]) { + unimplemented!("syscalls are only implemented for riscv64 targets"); +} + #[cfg(not(target_arch = "riscv64"))] /// Compute `xR = (k·G)_x` on secp256k1 via the ECSM accelerator (32-byte little-endian values). pub fn ecsm_mul(_xr: &mut [u8; 32], _xg: &[u8; 32], _k: &[u8; 32]) { From ca018569f379896fa9ea8399ff26f482a9758392 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 28 Jul 2026 12:43:51 -0300 Subject: [PATCH 11/28] Make ECSM affine y-return sound --- crypto/ecsm/src/lib.rs | 45 ++++++++++++++++++- crypto/ecsm/src/witness.rs | 21 ++++++++- crypto/ethrex-crypto/src/lib.rs | 46 ++++++++------------ crypto/ethrex-crypto/src/tests/ecsm_tests.rs | 26 +++-------- executor/src/vm/instruction/execution.rs | 30 +++++++------ prover/src/tables/ecsm.rs | 27 ++++++++++++ prover/src/tables/trace_builder.rs | 27 ++++++++++-- syscalls/src/syscalls.rs | 22 +++++----- 8 files changed, 167 insertions(+), 77 deletions(-) diff --git a/crypto/ecsm/src/lib.rs b/crypto/ecsm/src/lib.rs index f03de9a54..47df49c43 100644 --- a/crypto/ecsm/src/lib.rs +++ b/crypto/ecsm/src/lib.rs @@ -24,7 +24,7 @@ mod tests; use num_bigint::BigUint; pub use curve::{AffinePoint, recover_y_canonical, replay_double_and_add}; -pub use witness::{EcdasStep, EcsmWitness, compute_witness}; +pub use witness::{EcdasStep, EcsmWitness, compute_witness, compute_witness_with_y}; /// secp256k1 curve coefficient `b`. pub const B: u64 = 7; @@ -119,6 +119,49 @@ pub(crate) fn prepare( Ok((k, AffinePoint { x: xg, y: yg })) } +/// Like [`prepare`] but takes an explicit `yG` (the caller's full input point) instead of +/// lifting `xG` to the canonical even root. Validates `0 < k < N`, `xG < p`, `yG < p`, and +/// that `(xG, yG)` is on the curve (`yG² ≡ xG³ + b mod p`). Used by the affine path so the +/// returned `yR` matches the caller's actual point (no parity convention / guest-side sign +/// flip). `yG`'s value is pinned in the prover by a memory read of the caller's input. +pub(crate) fn prepare_with_y( + k_le: &[u8; 32], + xg_le: &[u8; 32], + yg_le: &[u8; 32], +) -> Result<(BigUint, AffinePoint), EcsmError> { + let k = BigUint::from_bytes_le(k_le); + if k == BigUint::from(0u8) { + return Err(EcsmError::ScalarIsZero); + } + if k >= n() { + return Err(EcsmError::ScalarOutOfRange); + } + let xg = BigUint::from_bytes_le(xg_le); + let yg = BigUint::from_bytes_le(yg_le); + if xg >= p() || yg >= p() { + return Err(EcsmError::CoordinateOutOfRange); + } + // On-curve: yG² ≡ xG³ + b (mod p). + let lhs = (&yg * &yg) % p(); + let rhs = (&xg * &xg % p() * &xg + BigUint::from(B)) % p(); + if lhs != rhs { + return Err(EcsmError::NotOnCurve); + } + Ok((k, AffinePoint { x: xg, y: yg })) +} + +/// Affine entry point with an explicit input `yG`: both coordinates of `k·(xG, yG)` as +/// little-endian 32-byte values. The executor writes `xR` then `yR` back (64-byte output). +pub fn scalar_mul_xy_with_y( + k_le: &[u8; 32], + xg_le: &[u8; 32], + yg_le: &[u8; 32], +) -> Result<([u8; 32], [u8; 32]), EcsmError> { + let (k, g) = prepare_with_y(k_le, xg_le, yg_le)?; + let r = curve::scalar_mul_affine(&k, &g); + Ok((to_le_32(&r.x), to_le_32(&r.y))) +} + /// Computes the x-coordinate of `k·G` over secp256k1, given `k` and `xG` as little-endian /// 32-byte values. This is the executor's entry point — it writes the returned bytes back /// to guest memory at `addr_xR`. diff --git a/crypto/ecsm/src/witness.rs b/crypto/ecsm/src/witness.rs index 28b971383..6153e083d 100644 --- a/crypto/ecsm/src/witness.rs +++ b/crypto/ecsm/src/witness.rs @@ -23,7 +23,7 @@ use num_traits::{Signed, Zero}; use rayon::prelude::*; use crate::curve::{StepPts, replay_double_and_add}; -use crate::{B, EcsmError, P_BYTES, R_BYTES, n, p, prepare, to_le_32}; +use crate::{B, EcsmError, P_BYTES, R_BYTES, n, p, prepare, prepare_with_y, to_le_32}; /// Full ECSM-chip witness for one scalar multiplication (one ECSM row). #[derive(Debug, Clone)] @@ -280,7 +280,26 @@ fn shifted_quotient(relation: &str, numerator: &BigInt, p_big: &BigInt, r_big: & /// little-endian 32-byte values. This is the prover's entry point. pub fn compute_witness(k_le: &[u8; 32], xg_le: &[u8; 32]) -> Result { let (k, g) = prepare(k_le, xg_le)?; + compute_witness_inner(k_le, k, g) +} + +/// Like [`compute_witness`] but with an explicit input `yG` (the caller's full point), +/// validated on-curve by [`prepare_with_y`]. The affine path uses this so the witnessed +/// `yG`/`yR` match the caller's actual point rather than the canonical even lift. +pub fn compute_witness_with_y( + k_le: &[u8; 32], + xg_le: &[u8; 32], + yg_le: &[u8; 32], +) -> Result { + let (k, g) = prepare_with_y(k_le, xg_le, yg_le)?; + compute_witness_inner(k_le, k, g) +} +fn compute_witness_inner( + k_le: &[u8; 32], + k: BigUint, + g: crate::curve::AffinePoint, +) -> Result { let p_big = BigInt::from(p()); let r_big = BigInt::from(BigUint::from_bytes_le(&R_BYTES)); // r = 3p diff --git a/crypto/ethrex-crypto/src/lib.rs b/crypto/ethrex-crypto/src/lib.rs index de54e702e..728576620 100644 --- a/crypto/ethrex-crypto/src/lib.rs +++ b/crypto/ethrex-crypto/src/lib.rs @@ -236,25 +236,27 @@ fn ecsm_lincomb2( None } -/// AFFINE oracle backed by the ECSM precompile: computes the full point `k·P_even`, -/// where `P_even` is the EVEN-`y` lift of the passed x-coordinate (the precompile's -/// canonical convention). Returns `(x, y)` of `k·P_even` as normalized field elements. -/// The caller ([`lincomb2_with_oracle`]) flips `y` if the real input point's `y` is -/// odd. `x` must be a valid curve x-coordinate and `k` in `(0, N)`. Values cross the -/// ABI as 32-byte little-endian; `x_le`/`k_le` are distinct stack arrays (executor's -/// `|addr_x − addr_k| ≥ 32` assumption) and `out` is a 64-byte `[xR‖yR]` buffer. +/// AFFINE oracle backed by the ECSM precompile: computes the full point `k·(x, y)` for the +/// caller's actual input point `(x, y)`. Returns `(xR, yR)` as normalized field elements — +/// no parity convention or sign flip, because the precompile receives the real `y` and the +/// prover pins it by a memory read. `(x, y)` must be a curve point and `k` in `(0, N)`. +/// Values cross the ABI as 32-byte little-endian; `input` is a 64-byte `[xG‖yG]` buffer, +/// `out` a 64-byte `[xR‖yR]` buffer, `k_le` a distinct 32-byte array (executor's +/// `|addr_input − addr_k| ≥ 64` disjointness assumption). #[cfg(target_arch = "riscv64")] -fn ecsm_oracle(x: &FieldElement, k: &Scalar) -> Option<(FieldElement, FieldElement)> { +fn ecsm_oracle(x: &FieldElement, y: &FieldElement, k: &Scalar) -> Option<(FieldElement, FieldElement)> { let x_be = x.to_bytes(); + let y_be = y.to_bytes(); let k_be = k.to_bytes(); - let mut x_le = [0u8; 32]; + let mut input = [0u8; 64]; let mut k_le = [0u8; 32]; for i in 0..32 { - x_le[i] = x_be[31 - i]; + input[i] = x_be[31 - i]; + input[32 + i] = y_be[31 - i]; k_le[i] = k_be[31 - i]; } let mut out = [0u8; 64]; - lambda_vm_syscalls::syscalls::ecsm_mul_affine(&mut out, &x_le, &k_le); + lambda_vm_syscalls::syscalls::ecsm_mul_affine(&mut out, &input, &k_le); let mut xr_be = [0u8; 32]; let mut yr_be = [0u8; 32]; for i in 0..32 { @@ -317,7 +319,7 @@ fn lincomb2_with_oracle( oracle: O, ) -> Option where - O: Fn(&FieldElement, &Scalar) -> Option<(FieldElement, FieldElement)>, + O: Fn(&FieldElement, &FieldElement, &Scalar) -> Option<(FieldElement, FieldElement)>, { // Inputs are affine already (the ecrecover path lifts them from known Z=1 // points), so no projective→affine inversion is needed here. @@ -331,21 +333,11 @@ where let (x1, y1) = affine_xy(a1)?; let (x2, y2) = affine_xy(a2)?; - // The oracle returns k·(x, y_even) (even-y lift). The real input point may be the - // odd-y sibling, i.e. −(x, y_even); then k·(real) = −(k·(x, y_even)), so flip yR. - // y parity = LSB of the big-endian canonical coordinate (byte 31). - let (xa, ya_even) = oracle(&x1, k1)?; - let (xb, yb_even) = oracle(&x2, k2)?; - let ya = if y1.to_bytes()[31] & 1 == 1 { - ya_even.negate(1).normalize() - } else { - ya_even - }; - let yb = if y2.to_bytes()[31] & 1 == 1 { - yb_even.negate(1).normalize() - } else { - yb_even - }; + // The oracle receives the full point (x, y) and returns k·(x, y) directly — no parity + // convention or sign flip, since the precompile gets the real y (pinned in the prover + // by a memory read). + let (xa, ya) = oracle(&x1, &y1, k1)?; + let (xb, yb) = oracle(&x2, &y2, k2)?; // Q = A + B via one chord addition (A ≠ ±B ⇒ dxq ≠ 0). One field inversion. let dxq = (xb - xa).normalize(); diff --git a/crypto/ethrex-crypto/src/tests/ecsm_tests.rs b/crypto/ethrex-crypto/src/tests/ecsm_tests.rs index 45084eed9..15e728148 100644 --- a/crypto/ethrex-crypto/src/tests/ecsm_tests.rs +++ b/crypto/ethrex-crypto/src/tests/ecsm_tests.rs @@ -4,27 +4,11 @@ use crate::*; -/// secp256k1 curve constant `b = 7`. -fn curve_b() -> FieldElement { - let mut bytes = [0u8; 32]; - bytes[31] = 7; - FieldElement::from_bytes(&bytes.into()).unwrap() -} - -/// Software stand-in for the affine ECSM precompile: lift `x` to the EVEN-`y` curve -/// point `P_even` and return `(x, y)` of `k·P_even` (matching the real ecall's -/// even-`y` convention; the caller flips the sign for odd-`y` inputs). -fn soft_oracle(x: &FieldElement, k: &Scalar) -> Option<(FieldElement, FieldElement)> { - let xn = x.normalize(); - let y2 = (xn.square() * xn + curve_b()).normalize(); - let y = Option::::from(y2.sqrt())?.normalize(); - // Even-y lift: pick the root whose LSB (big-endian byte 31) is 0. - let y_even = if y.to_bytes()[31] & 1 == 0 { - y - } else { - y.negate(1).normalize() - }; - let p = point_from_xy(&xn, &y_even)?; +/// Software stand-in for the affine ECSM precompile: form the curve point `(x, y)` from +/// the caller's actual coordinates and return `(xR, yR)` of `k·(x, y)`. No parity +/// convention — the real ecall receives the full input point too. +fn soft_oracle(x: &FieldElement, y: &FieldElement, k: &Scalar) -> Option<(FieldElement, FieldElement)> { + let p = point_from_xy(&x.normalize(), &y.normalize())?; let prod = (p * k).to_affine(); let (xr, yr) = affine_xy(&prod)?; Some((xr.normalize(), yr.normalize())) diff --git a/executor/src/vm/instruction/execution.rs b/executor/src/vm/instruction/execution.rs index 593de26df..99619ef28 100644 --- a/executor/src/vm/instruction/execution.rs +++ b/executor/src/vm/instruction/execution.rs @@ -492,29 +492,31 @@ impl Instruction { let addr_xr = registers.read(10)?; let addr_xg = registers.read(11)?; let addr_k = registers.read(12)?; - // AFFINE PoC: the output is a contiguous 64-byte buffer at - // addr_xr (xR at +0, yR at +32), so it spans offset 63. - if !addr_limb_ok(addr_xg, 31) + // AFFINE: both the input (xG‖yG) and the output (xR‖yR) are + // contiguous 64-byte buffers, so each spans offset 63; k is 32B. + if !addr_limb_ok(addr_xg, 63) || !addr_limb_ok(addr_xr, 63) || !addr_limb_ok(addr_k, 31) { return Err(ExecutionError::EcsmAddressOverflow); } - // xG and k must occupy disjoint 32-byte regions. The trace builder - // reads each operand as unaligned doubleword MEMW accesses (xG at T, - // k at T+1); if the regions overlap, the same address is touched at - // both timestamps and the MEMW consistency argument can't prove the - // access chain. The loaded values would still be well-defined — this - // guard is about trace provability, not correctness of the multiply. - // xR may alias either: its accesses are at a later timestamp. - if addr_xg.abs_diff(addr_k) < 32 { + // AFFINE: the input is a contiguous 64-byte point (xG at +0, yG at + // +32) and k a 32-byte scalar. They must occupy disjoint regions — + // the trace builder reads xG/yG at T and k at T+1 as unaligned + // doubleword MEMW accesses; overlap touches the same address at both + // timestamps and the MEMW consistency argument can't prove the chain. + // The guard is about trace provability, not correctness. xR (output) + // may alias either: its accesses are at a later timestamp. + if addr_xg.abs_diff(addr_k) < 64 { return Err(ExecutionError::EcsmOperandOverlap); } let xg = load_u256_le(memory, addr_xg)?; + let yg = load_u256_le(memory, addr_xg.wrapping_add(32))?; let k = load_u256_le(memory, addr_k)?; - // AFFINE PoC: return both coordinates so the guest skips the - // x-only (k+1)·P y-reconstruction. xR at addr_xr, yR at +32. - let (xr, yr) = ecsm::scalar_mul_xy(&k, &xg)?; + // AFFINE: caller passes the full point (xG, yG); return both + // coordinates of k·(xG, yG) so the guest skips the x-only + // (k+1)·P y-reconstruction. xR at addr_xr, yR at +32. + let (xr, yr) = ecsm::scalar_mul_xy_with_y(&k, &xg, &yg)?; store_u256_le(memory, addr_xr, &xr)?; store_u256_le(memory, addr_xr.wrapping_add(32), &yr)?; // Carry addr_xG/addr_k in the CPU log; addr_xR is recovered from x10 diff --git a/prover/src/tables/ecsm.rs b/prover/src/tables/ecsm.rs index dd466f119..cfdd92257 100644 --- a/prover/src/tables/ecsm.rs +++ b/prover/src/tables/ecsm.rs @@ -354,6 +354,33 @@ pub fn bus_interactions() -> Vec { ), )); } + // AFFINE: read yG: 4 doublewords at addr_xG + 32 + 8i (ts). Pins the witnessed yG + // (col YG) to the caller's input point, so the returned yR corresponds to the + // caller's actual (xG, yG) — closes the parity soundness gap. Same low address limb + // (the +63 span is guarded to not cross the 2^32 limb boundary). + for i in 0..4 { + let base_lo = BusValue::linear(vec![ + LinearTerm::Column { + coefficient: 1, + column: cols::ADDR_XG_0, + }, + LinearTerm::Constant((32 + 8 * i) as i64), + ]); + out.push(BusInteraction::sender( + BusId::Memw, + mu(), + memw_read( + dword_bytes(cols::YG, i), + 0, + base_lo, + packed(cols::ADDR_XG_1), + ts_lo(), + ts_hi(), + 0, + 1, + ), + )); + } let ts_lo_plus = |d: i64| { BusValue::linear(vec![ diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 019b49f0f..8fdd85e22 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -851,16 +851,22 @@ fn collect_ecsm_ops( let addr_xg = register_state.read(11).0; let addr_k = register_state.read(12).0; - // Read the xG and k operands (32 little-endian bytes each) from memory. + // Read the operands from memory: xG‖yG (64 contiguous bytes) and k (32 bytes). + // AFFINE: yG is the caller's real input y (pinned below by a memory read), so the + // witnessed/returned point is the caller's actual point — no even-parity convention. let mut xg = [0u8; 32]; + let mut yg = [0u8; 32]; let mut k = [0u8; 32]; for i in 0..32 { xg[i] = memory_state.read_byte(addr_xg.wrapping_add(i as u64)).0; + yg[i] = memory_state + .read_byte(addr_xg.wrapping_add(32 + i as u64)) + .0; k[i] = memory_state.read_byte(addr_k.wrapping_add(i as u64)).0; } - let witness = ::ecsm::compute_witness(&k, &xg) - .expect("ECSM witness: executor validates 0 < k < N and xG on curve"); + let witness = ::ecsm::compute_witness_with_y(&k, &xg, &yg) + .expect("ECSM witness: executor validates 0 < k < N, xG/yG < p, (xG,yG) on curve"); let mut memw_ops = Vec::with_capacity(15); @@ -889,6 +895,21 @@ fn collect_ecsm_ops( memory_state.write_bytes(addr, dword, 8, t); } + // AFFINE: yG: 4 doubleword reads at T (addr_xG + 32 + 8i). Pins the witnessed yG to + // the caller's input, closing the parity soundness gap of the x-only-input version. + for i in 0..4 { + let addr = addr_xg.wrapping_add((32 + 8 * i) as u64); + let mut value = [0u32; 8]; + let mut dword = 0u64; + for j in 0..8 { + value[j] = witness.y_g[8 * i + j] as u32; + dword |= (witness.y_g[8 * i + j] as u64) << (8 * j); + } + let (_old, old_ts) = memory_state.read_bytes(addr, 8); + memw_ops.push(MemwOperation::new(false, addr, value, t, 8, true).with_old(value, old_ts)); + memory_state.write_bytes(addr, dword, 8, t); + } + // x12 -> addr_k (register read at T+1). { let (val, old_ts) = register_state.read(12); diff --git a/syscalls/src/syscalls.rs b/syscalls/src/syscalls.rs index 4039b1819..9a97c1ece 100644 --- a/syscalls/src/syscalls.rs +++ b/syscalls/src/syscalls.rs @@ -191,26 +191,28 @@ pub fn ecsm_mul(xr: &mut [u8; 32], xg: &[u8; 32], k: &[u8; 32]) { } } -/// AFFINE PoC: compute `k·G` on secp256k1 and write BOTH coordinates into a contiguous -/// 64-byte buffer (`xR` at `out[0..32]`, `yR` at `out[32..64]`), all 32-byte little-endian. -/// `yR` follows the even-`yG` convention; the caller flips its sign if the real `yG` is odd. -/// Same ABI as [`ecsm_mul`] except `x10` points at a 64-byte output. Lets ECDSA recovery -/// avoid the second `(k+1)·P` query and the x-only y-reconstruction. +/// AFFINE: compute `k·(xG, yG)` on secp256k1 and write BOTH result coordinates into a +/// contiguous 64-byte buffer (`xR` at `out[0..32]`, `yR` at `out[32..64]`). The input is +/// the full affine point as a contiguous 64-byte buffer (`xG` at `in[0..32]`, `yG` at +/// `in[32..64]`); `k` is 32 bytes. All values 32-byte little-endian. Passing the full point +/// (not just `xG`) means the returned `yR` is the y of the caller's actual point — no +/// parity convention or caller-side sign flip. Lets ECDSA recovery avoid the second +/// `(k+1)·P` query and the x-only y-reconstruction. #[cfg(target_arch = "riscv64")] -pub fn ecsm_mul_affine(out: &mut [u8; 64], xg: &[u8; 32], k: &[u8; 32]) { +pub fn ecsm_mul_affine(out: &mut [u8; 64], input: &[u8; 64], k: &[u8; 32]) { unsafe { asm!( "ecall", - in("a0") out.as_mut_ptr(), // x10 = address to write [xR‖yR] (64 bytes) - in("a1") xg.as_ptr(), // x11 = address of xG - in("a2") k.as_ptr(), // x12 = address of k + in("a0") out.as_mut_ptr(), // x10 = address to write [xR‖yR] (64 bytes) + in("a1") input.as_ptr(), // x11 = address of [xG‖yG] (64 bytes) + in("a2") k.as_ptr(), // x12 = address of k in("a7") ECSM_SYSCALL_NUMBER, ) } } #[cfg(not(target_arch = "riscv64"))] -pub fn ecsm_mul_affine(_out: &mut [u8; 64], _xg: &[u8; 32], _k: &[u8; 32]) { +pub fn ecsm_mul_affine(_out: &mut [u8; 64], _input: &[u8; 64], _k: &[u8; 32]) { unimplemented!("syscalls are only implemented for riscv64 targets"); } From cd603c275d7fbb8072ca9b7beb29b92e8ba44fc5 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 28 Jul 2026 15:13:19 -0300 Subject: [PATCH 12/28] Select ECSM x-only vs affine ecall via IS_AFFINE --- .../rust/ecsm_affine/.cargo/config.toml | 5 + executor/programs/rust/ecsm_affine/Cargo.lock | 359 ++++++++++++++++++ executor/programs/rust/ecsm_affine/Cargo.toml | 9 + .../programs/rust/ecsm_affine/src/main.rs | 32 ++ executor/src/tests/ecsm_tests.rs | 69 +++- executor/src/vm/instruction/execution.rs | 74 +++- prover/src/tables/cpu.rs | 17 +- prover/src/tables/ecsm.rs | 99 ++++- prover/src/tables/trace_builder.rs | 84 ++-- prover/src/tests/ecsm_tests.rs | 5 +- prover/src/tests/prove_elfs_tests.rs | 79 ++++ syscalls/src/syscalls.rs | 9 +- 12 files changed, 766 insertions(+), 75 deletions(-) create mode 100644 executor/programs/rust/ecsm_affine/.cargo/config.toml create mode 100644 executor/programs/rust/ecsm_affine/Cargo.lock create mode 100644 executor/programs/rust/ecsm_affine/Cargo.toml create mode 100644 executor/programs/rust/ecsm_affine/src/main.rs diff --git a/executor/programs/rust/ecsm_affine/.cargo/config.toml b/executor/programs/rust/ecsm_affine/.cargo/config.toml new file mode 100644 index 000000000..ca99a3f45 --- /dev/null +++ b/executor/programs/rust/ecsm_affine/.cargo/config.toml @@ -0,0 +1,5 @@ +[target.riscv64im-lambda-vm-elf] +rustflags = [ + "--cfg", "getrandom_backend=\"custom\"", + "-C", "passes=lower-atomic" +] diff --git a/executor/programs/rust/ecsm_affine/Cargo.lock b/executor/programs/rust/ecsm_affine/Cargo.lock new file mode 100644 index 000000000..cc4741e6b --- /dev/null +++ b/executor/programs/rust/ecsm_affine/Cargo.lock @@ -0,0 +1,359 @@ +# 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 = "dlmalloc" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad5208a115eaba24916f7456929832e310a81518c641f93fee4f89aa93aa3675" +dependencies = [ + "cfg-if", + "libc", + "windows-sys", +] + +[[package]] +name = "ecsm_affine" +version = "0.1.0" +dependencies = [ + "lambda-vm-syscalls", +] + +[[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 = "lambda-vm-syscalls" +version = "0.1.0" +dependencies = [ + "critical-section", + "dlmalloc", + "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.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[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.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +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.117", +] + +[[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.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[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.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +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.117", +] + +[[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.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[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.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5361301a1d9e5dd94c524eb99365fbaed5b237e831d7f45e2ddea11ffe8627" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "422033a2245cb4b6ff8def11b2dfaf184a2ab2573f5af28082a163a68889af0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] diff --git a/executor/programs/rust/ecsm_affine/Cargo.toml b/executor/programs/rust/ecsm_affine/Cargo.toml new file mode 100644 index 000000000..3bd8703a0 --- /dev/null +++ b/executor/programs/rust/ecsm_affine/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] + +[package] +name = "ecsm_affine" +version = "0.1.0" +edition = "2024" + +[dependencies] +lambda-vm-syscalls = { path = "../../../../syscalls" } diff --git a/executor/programs/rust/ecsm_affine/src/main.rs b/executor/programs/rust/ecsm_affine/src/main.rs new file mode 100644 index 000000000..46aa857a4 --- /dev/null +++ b/executor/programs/rust/ecsm_affine/src/main.rs @@ -0,0 +1,32 @@ +use lambda_vm_syscalls as syscalls; + +/// Computes 5·G on secp256k1 via the **affine** ECSM precompile (`ecsm_mul_affine`) and +/// commits the 64-byte result point `xR‖yR` as public output. Exercises the affine ecall +/// end-to-end (IS_AFFINE=1: yG read from memory, yR written back). +pub fn main() { + // secp256k1 generator (Gx, Gy), big-endian then reversed to little-endian. + let mut gx: [u8; 32] = [ + 0x79, 0xBE, 0x66, 0x7E, 0xF9, 0xDC, 0xBB, 0xAC, 0x55, 0xA0, 0x62, 0x95, 0xCE, 0x87, 0x0B, + 0x07, 0x02, 0x9B, 0xFC, 0xDB, 0x2D, 0xCE, 0x28, 0xD9, 0x59, 0xF2, 0x81, 0x5B, 0x16, 0xF8, + 0x17, 0x98, + ]; + gx.reverse(); + let mut gy: [u8; 32] = [ + 0x48, 0x3A, 0xDA, 0x77, 0x26, 0xA3, 0xC4, 0x65, 0x5D, 0xA4, 0xFB, 0xFC, 0x0E, 0x11, 0x08, + 0xA8, 0xFD, 0x17, 0xB4, 0x48, 0xA6, 0x85, 0x54, 0x19, 0x9C, 0x47, 0xD0, 0x8F, 0xFB, 0x10, + 0xD4, 0xB8, + ]; + gy.reverse(); + + // Full input point as a contiguous 64-byte buffer: xG at [0..32], yG at [32..64]. + let mut input = [0u8; 64]; + input[..32].copy_from_slice(&gx); + input[32..].copy_from_slice(&gy); + + let mut k = [0u8; 32]; + k[0] = 5; + + let mut out = [0u8; 64]; + syscalls::syscalls::ecsm_mul_affine(&mut out, &input, &k); + syscalls::syscalls::commit(&out); +} diff --git a/executor/src/tests/ecsm_tests.rs b/executor/src/tests/ecsm_tests.rs index 0fa240a8e..230b4e32d 100644 --- a/executor/src/tests/ecsm_tests.rs +++ b/executor/src/tests/ecsm_tests.rs @@ -1,7 +1,9 @@ //! Tests for the ECSM (elliptic-curve scalar multiplication) syscall. use crate::vm::instruction::decoding::Instruction; -use crate::vm::instruction::execution::{ECSM_SYSCALL_NUMBER, ExecutionError}; +use crate::vm::instruction::execution::{ + ECSM_AFFINE_SYSCALL_NUMBER, ECSM_SYSCALL_NUMBER, ExecutionError, +}; use crate::vm::memory::Memory; use crate::vm::registers::Registers; @@ -16,6 +18,17 @@ fn gx_le() -> [u8; 32] { be } +/// secp256k1 generator y-coordinate, little-endian. +fn gy_le() -> [u8; 32] { + let mut be = [ + 0x48, 0x3A, 0xDA, 0x77, 0x26, 0xA3, 0xC4, 0x65, 0x5D, 0xA4, 0xFB, 0xFC, 0x0E, 0x11, 0x08, + 0xA8, 0xFD, 0x17, 0xB4, 0x48, 0xA6, 0x85, 0x54, 0x19, 0x9C, 0x47, 0xD0, 0x8F, 0xFB, 0x10, + 0xD4, 0xB8, + ]; + be.reverse(); + be +} + fn write_u256_le(memory: &mut Memory, addr: u64, bytes: &[u8; 32]) { for i in 0..4 { let mut dw = [0u8; 8]; @@ -63,6 +76,60 @@ fn k_le(v: u64) -> [u8; 32] { k } +/// Runs the AFFINE ECSM syscall (full point in/out) with the given scalar, `xG` and `yG`, +/// returning the `(xR, yR)` written back to the contiguous 64-byte output buffer. +fn run_ecsm_affine( + k_le: &[u8; 32], + xg_le: &[u8; 32], + yg_le: &[u8; 32], +) -> Result<([u8; 32], [u8; 32]), ExecutionError> { + let mut pc = 0; + let mut registers = Registers::default(); + let mut memory = Memory::default(); + + let addr_xr = 0x1000u64; // output xR‖yR (64 bytes) + let addr_xg = 0x2000u64; // input xG‖yG (64 bytes) + let addr_k = 0x3000u64; + write_u256_le(&mut memory, addr_xg, xg_le); + write_u256_le(&mut memory, addr_xg + 32, yg_le); + write_u256_le(&mut memory, addr_k, k_le); + + registers.write(17, ECSM_AFFINE_SYSCALL_NUMBER).unwrap(); + registers.write(10, addr_xr).unwrap(); + registers.write(11, addr_xg).unwrap(); + registers.write(12, addr_k).unwrap(); + + Instruction::EcallEbreak.run(&mut pc, &mut registers, &mut memory)?; + Ok(( + read_u256_le(&memory, addr_xr), + read_u256_le(&memory, addr_xr + 32), + )) +} + +#[test] +fn ecsm_affine_syscall_writes_both_coords() { + let (xg, yg) = (gx_le(), gy_le()); + for v in [1u64, 2, 3, 5, 0xFFFF, 1_000_003] { + let (xr, yr) = run_ecsm_affine(&k_le(v), &xg, &yg).unwrap(); + let (exr, eyr) = ecsm::scalar_mul_xy_with_y(&k_le(v), &xg, &yg).unwrap(); + assert_eq!(xr, exr, "xR mismatch for k = {v}"); + assert_eq!(yr, eyr, "yR mismatch for k = {v}"); + } +} + +#[test] +fn ecsm_affine_syscall_rejects_point_not_on_curve() { + // A valid xG paired with the wrong yG (here yG = Gy of a different point) must be + // rejected on-curve, unlike the x-only variant which lifts its own canonical y. + let mut yg = gy_le(); + yg[0] ^= 1; // perturb so (xG, yG) is no longer on the curve + let err = run_ecsm_affine(&k_le(5), &gx_le(), &yg).unwrap_err(); + assert!(matches!( + err, + ExecutionError::Ecsm(ecsm::EcsmError::NotOnCurve) + )); +} + #[test] fn ecsm_syscall_writes_correct_result() { let xg = gx_le(); diff --git a/executor/src/vm/instruction/execution.rs b/executor/src/vm/instruction/execution.rs index 99619ef28..aeba41e8c 100644 --- a/executor/src/vm/instruction/execution.rs +++ b/executor/src/vm/instruction/execution.rs @@ -16,6 +16,8 @@ pub enum SyscallNumbers { Halt = 93, // Placeholder discriminant. The actual syscall value is ECSM_SYSCALL_NUMBER. Ecsm = 94, + // Placeholder discriminant. The actual syscall value is ECSM_AFFINE_SYSCALL_NUMBER. + EcsmAffine = 96, // Placeholder discriminant. The actual syscall value is HINT_SYSCALL_NUMBER. // BENCH ONLY: non-constraining hint (host computes modular inverse/sqrt, guest verifies). Hint = 95, @@ -32,8 +34,19 @@ const KECCAK_STATE_BYTES: u64 = 25 * 8; /// The spec uses ECALL number `-11`; interpreted as an unsigned 64-bit value that is /// `u64::MAX - 10 = 0xFFFF_FFFF_FFFF_FFF5`, which the ECSM core table puts on the `Ecall` /// bus as `[lo32, hi32] = [2^32 - 11, 2^32 - 1]`. +/// +/// This is the **x-only** variant: the guest passes only `xG` (32 bytes) and gets back only +/// the x-coordinate `xR` (32 bytes). See [`ECSM_AFFINE_SYSCALL_NUMBER`] for the affine variant. pub const ECSM_SYSCALL_NUMBER: u64 = u64::MAX - 10; +/// Syscall number for the **affine** ECSM accelerator variant. +/// +/// `u64::MAX - 11 = 0xFFFF_FFFF_FFFF_FFF4`. The guest passes the full point `xG‖yG` +/// (contiguous 64 bytes) and gets back both coordinates `xR‖yR` (contiguous 64 bytes), so +/// ECDSA recovery skips the x-only `(k+1)·P` y-reconstruction. The ECSM core table selects +/// the mode with an `IS_AFFINE` column pinned to this number via the `Ecall` bus. +pub const ECSM_AFFINE_SYSCALL_NUMBER: u64 = u64::MAX - 11; + /// Syscall number for the non-constraining `Hint` ecall (BENCH ONLY). /// /// The host computes a modular inverse or square root and writes it back to the @@ -61,6 +74,7 @@ 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 == ECSM_AFFINE_SYSCALL_NUMBER => Ok(SyscallNumbers::EcsmAffine), v if v == HINT_SYSCALL_NUMBER => Ok(SyscallNumbers::Hint), _ => Err(()), } @@ -81,7 +95,7 @@ impl SyscallNumbers { pub fn accelerator(self) -> Option { match self { SyscallNumbers::KeccakPermute => Some(Accelerator::Keccak), - SyscallNumbers::Ecsm => Some(Accelerator::Ecsm), + SyscallNumbers::Ecsm | SyscallNumbers::EcsmAffine => Some(Accelerator::Ecsm), SyscallNumbers::Print | SyscallNumbers::Panic | SyscallNumbers::Commit @@ -485,37 +499,69 @@ impl Instruction { src2_val = state_addr; } SyscallNumbers::Ecsm => { - // ECSM(-11): k×G on secp256k1. + // ECSM(-11), x-only: (k·G)_x on secp256k1. // x10 = addr to write xR, x11 = addr of xG, x12 = addr of k. // xG, k, xR are 32-byte little-endian values; xG and xR must be // canonical field elements and k must be in [1, N). let addr_xr = registers.read(10)?; let addr_xg = registers.read(11)?; let addr_k = registers.read(12)?; - // AFFINE: both the input (xG‖yG) and the output (xR‖yR) are - // contiguous 64-byte buffers, so each spans offset 63; k is 32B. + if !addr_limb_ok(addr_xg, 31) + || !addr_limb_ok(addr_xr, 31) + || !addr_limb_ok(addr_k, 31) + { + return Err(ExecutionError::EcsmAddressOverflow); + } + // xG and k must occupy disjoint 32-byte regions. The trace builder + // reads each operand as unaligned doubleword MEMW accesses (xG at T, + // k at T+1); if the regions overlap, the same address is touched at + // both timestamps and the MEMW consistency argument can't prove the + // access chain. The guard is about trace provability, not correctness. + // xR may alias either: its accesses are at a later timestamp. + if addr_xg.abs_diff(addr_k) < 32 { + return Err(ExecutionError::EcsmOperandOverlap); + } + let xg = load_u256_le(memory, addr_xg)?; + let k = load_u256_le(memory, addr_k)?; + let xr = ecsm::scalar_mul_x(&k, &xg)?; + store_u256_le(memory, addr_xr, &xr)?; + // Carry addr_xG/addr_k in the CPU log; addr_xR is recovered from x10 + // by the ECSM register-read path in the trace builder. + src2_val = addr_xg; + dst_val = addr_k; + } + SyscallNumbers::EcsmAffine => { + // ECSM affine: both coordinates of k·(xG, yG) on secp256k1. + // x10 = addr to write xR‖yR, x11 = addr of xG‖yG, x12 = addr of k. + // Input and output are contiguous 64-byte buffers; k is 32B. xG/yG/xR + // must be canonical field elements, (xG, yG) on curve, k in [1, N). + let addr_xr = registers.read(10)?; + let addr_xg = registers.read(11)?; + let addr_k = registers.read(12)?; + // Both the input (xG‖yG) and the output (xR‖yR) are contiguous + // 64-byte buffers, so each spans offset 63; k is 32B. if !addr_limb_ok(addr_xg, 63) || !addr_limb_ok(addr_xr, 63) || !addr_limb_ok(addr_k, 31) { return Err(ExecutionError::EcsmAddressOverflow); } - // AFFINE: the input is a contiguous 64-byte point (xG at +0, yG at - // +32) and k a 32-byte scalar. They must occupy disjoint regions — - // the trace builder reads xG/yG at T and k at T+1 as unaligned - // doubleword MEMW accesses; overlap touches the same address at both - // timestamps and the MEMW consistency argument can't prove the chain. - // The guard is about trace provability, not correctness. xR (output) - // may alias either: its accesses are at a later timestamp. + // The input is a contiguous 64-byte point (xG at +0, yG at +32) and + // k a 32-byte scalar. They must occupy disjoint regions — the trace + // builder reads xG/yG at T and k at T+1 as unaligned doubleword MEMW + // accesses; overlap touches the same address at both timestamps and + // the MEMW consistency argument can't prove the chain. The guard is + // about trace provability, not correctness. xR (output) may alias + // either: its accesses are at a later timestamp. if addr_xg.abs_diff(addr_k) < 64 { return Err(ExecutionError::EcsmOperandOverlap); } let xg = load_u256_le(memory, addr_xg)?; let yg = load_u256_le(memory, addr_xg.wrapping_add(32))?; let k = load_u256_le(memory, addr_k)?; - // AFFINE: caller passes the full point (xG, yG); return both - // coordinates of k·(xG, yG) so the guest skips the x-only - // (k+1)·P y-reconstruction. xR at addr_xr, yR at +32. + // Caller passes the full point (xG, yG); return both coordinates of + // k·(xG, yG) so the guest skips the x-only (k+1)·P y-reconstruction. + // xR at addr_xr, yR at +32. let (xr, yr) = ecsm::scalar_mul_xy_with_y(&k, &xg, &yg)?; store_u256_le(memory, addr_xr, &xr)?; store_u256_le(memory, addr_xr.wrapping_add(32), &yr)?; diff --git a/prover/src/tables/cpu.rs b/prover/src/tables/cpu.rs index 0bb29aaf1..986796307 100644 --- a/prover/src/tables/cpu.rs +++ b/prover/src/tables/cpu.rs @@ -187,8 +187,14 @@ pub struct CpuOperation { pub keccak_state_addr: u64, /// Whether this ECALL is an ECSM (elliptic-curve scalar multiply) syscall + /// (either the x-only or the affine variant). pub ecall_ecsm: bool, + /// Whether this ECSM ecall is the affine variant (full point in / out). Selects the + /// `IS_AFFINE` column of the ECSM table and, in the trace builder, the yG-read / yR-write + /// memory ops. `false` for the x-only variant and for non-ECSM rows. + pub ecsm_affine: bool, + /// Whether this ECALL is a non-constraining Hint syscall (BENCH ONLY). The /// hint operand addresses (x10/x11/x12) are recovered from the register state /// in the trace builder, exactly like ECSM. @@ -237,9 +243,13 @@ impl CpuOperation { f.ecall && log.src1_val == executor::vm::instruction::execution::KECCAK_SYSCALL_NUMBER; let keccak_state_addr = if ecall_keccak { log.src2_val } else { 0 }; // The ECSM operand addresses (x10/x11/x12) are recovered from the register state - // in the trace builder. - let ecall_ecsm = - f.ecall && log.src1_val == executor::vm::instruction::execution::ECSM_SYSCALL_NUMBER; + // in the trace builder. The x-only and affine variants share the ECSM table; the + // affine flag selects the yG-read / yR-write memory ops and the IS_AFFINE column. + let ecsm_affine = f.ecall + && log.src1_val == executor::vm::instruction::execution::ECSM_AFFINE_SYSCALL_NUMBER; + let ecall_ecsm = ecsm_affine + || (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; @@ -360,6 +370,7 @@ impl CpuOperation { ecall_keccak, keccak_state_addr, ecall_ecsm, + ecsm_affine, ecall_hint, } } diff --git a/prover/src/tables/ecsm.rs b/prover/src/tables/ecsm.rs index cfdd92257..cc98d701d 100644 --- a/prover/src/tables/ecsm.rs +++ b/prover/src/tables/ecsm.rs @@ -1,11 +1,21 @@ //! ECSM core chip — orchestrates one secp256k1 scalar multiplication `k·G`. //! -//! One row per `ECALL(-11)`. It reads `xG` and `k` from memory, witnesses `yG` and proves +//! One row per ECSM `ECALL`. It reads `xG` and `k` from memory, witnesses `yG` and proves //! `yG² ≡ xG³ + b mod p` (via two byte-limb convolution relations with quotients `q0,q1` //! and 64-entry carry arrays `c0,c1`), enforces `0 < k < N` and `xR < p`, writes `xR` back, //! serves the scalar bits directly via the `Bit` bus, and delegates the double-and-add to ECDAS //! over the `Ecdas`/`Bit` buses. //! +//! ## Two modes (`IS_AFFINE` selector) +//! The chip serves both ecall variants with one prover, selected by the `IS_AFFINE` column: +//! - **x-only** (`ECSM_SYSCALL_NUMBER`): input `xG` (32B), output `xR` (32B). `yG` is the +//! canonical even lift (not read from memory), `yR` is witnessed only for ECDAS. +//! - **affine** (`ECSM_AFFINE_SYSCALL_NUMBER`): input `xG‖yG` (64B), output `xR‖yR` (64B); +//! the yG-read and yR-write MEMW buses fire with `mult = IS_AFFINE`. +//! +//! `IS_AFFINE` is pinned to the actual ecall number by the `Ecall` receiver, so it can't be +//! forged (see `bus_interactions`). +//! //! See `spec/src/ecsm.toml`. All multi-limb arithmetic uses 8-bit limbs; the witness is built //! by `ecsm::compute_witness`, which reproduces these exact recurrences. //! @@ -15,7 +25,7 @@ //! relation has no standalone constant and also closes at all-zero. The range checks / //! virtual-carry checks remain µ-gated as before. -use executor::vm::instruction::execution::ECSM_SYSCALL_NUMBER; +use executor::vm::instruction::execution::{ECSM_AFFINE_SYSCALL_NUMBER, ECSM_SYSCALL_NUMBER}; use stark::lookup::{BusInteraction, BusValue, LinearTerm, Multiplicity, Packing}; use stark::trace::TraceTable; @@ -28,7 +38,7 @@ pub(crate) const CARRY_OFFSET_X2: i64 = 8160; pub(crate) const CARRY_OFFSET_YG: i64 = 16319; // ========================================================================= -// Column indices (667 columns; keep in sync with NUM_COLUMNS below) +// Column indices (668 columns; keep in sync with NUM_COLUMNS below) // ========================================================================= pub mod cols { @@ -56,8 +66,11 @@ pub mod cols { pub const K_SUB_N: usize = 634; // U256HL (16 halfwords) pub const XR_SUB_P: usize = 650; // U256HL (16 halfwords) pub const MU: usize = 666; + /// Mode selector: 1 = affine ecall (read yG, write yR), 0 = x-only / padding. + /// Pinned to the actual ecall number by the `Ecall` receiver (see `bus_interactions`). + pub const IS_AFFINE: usize = 667; - pub const NUM_COLUMNS: usize = 667; + pub const NUM_COLUMNS: usize = 668; #[inline] pub const fn xr(i: usize) -> usize { @@ -121,6 +134,9 @@ pub struct EcsmOperation { pub addr_xg: u64, pub addr_k: u64, pub addr_xr: u64, + /// Affine variant (full point in/out): drives the `IS_AFFINE` column and the + /// yG-read / yR-write memory ops. `false` for the x-only variant. + pub is_affine: bool, pub witness: EcsmWitness, } @@ -190,6 +206,9 @@ pub fn generate_ecsm_trace( } table.set_fe(row_idx, cols::MU, FE::one()); + if op.is_affine { + table.set_fe(row_idx, cols::IS_AFFINE, FE::one()); + } } trace @@ -299,19 +318,42 @@ fn k_dword_busvalues(dword_idx: usize) -> [BusValue; 8] { pub fn bus_interactions() -> Vec { let mu = || Multiplicity::Column(cols::MU); + // Affine-only multiplicity: fires only when IS_AFFINE = 1 (never on x-only or padding + // rows, where IS_AFFINE is constrained to 0). Used for the yG-read / yR-write buses. + let affine = || Multiplicity::Column(cols::IS_AFFINE); let ts_lo = || packed(cols::TIMESTAMP_0); let ts_hi = || packed(cols::TIMESTAMP_1); let mut out = Vec::new(); // ECALL receiver (mult = mu): [ts_lo, ts_hi, syscall_lo32, syscall_hi32]. + // + // The received syscall number is LINEAR in IS_AFFINE: + // syscall = xonly + IS_AFFINE·(affine − xonly). + // The CPU sends the *actual* a7 (rv1) on the `Ecall` bus, so this pins IS_AFFINE to the + // real ecall variant — a prover that flips IS_AFFINE without changing the guest's ecall + // number makes the bus fingerprint mismatch and the LogUp argument fail. This is the + // soundness anchor that lets the yG-read / yR-write buses key off IS_AFFINE below. + let xonly_lo = (ECSM_SYSCALL_NUMBER & 0xFFFF_FFFF) as i64; + let xonly_hi = (ECSM_SYSCALL_NUMBER >> 32) as i64; + let affine_lo = (ECSM_AFFINE_SYSCALL_NUMBER & 0xFFFF_FFFF) as i64; + let affine_hi = (ECSM_AFFINE_SYSCALL_NUMBER >> 32) as i64; + let syscall_word = |xonly: i64, affine: i64| { + BusValue::linear(vec![ + LinearTerm::Constant(xonly), + LinearTerm::Column { + coefficient: affine - xonly, + column: cols::IS_AFFINE, + }, + ]) + }; out.push(BusInteraction::receiver( BusId::Ecall, mu(), vec![ ts_lo(), ts_hi(), - BusValue::constant(ECSM_SYSCALL_NUMBER & 0xFFFF_FFFF), - BusValue::constant(ECSM_SYSCALL_NUMBER >> 32), + syscall_word(xonly_lo, affine_lo), + syscall_word(xonly_hi, affine_hi), ], )); @@ -354,10 +396,11 @@ pub fn bus_interactions() -> Vec { ), )); } - // AFFINE: read yG: 4 doublewords at addr_xG + 32 + 8i (ts). Pins the witnessed yG - // (col YG) to the caller's input point, so the returned yR corresponds to the - // caller's actual (xG, yG) — closes the parity soundness gap. Same low address limb - // (the +63 span is guarded to not cross the 2^32 limb boundary). + // AFFINE (mult = IS_AFFINE): read yG: 4 doublewords at addr_xG + 32 + 8i (ts). Pins the + // witnessed yG (col YG) to the caller's input point, so the returned yR corresponds to + // the caller's actual (xG, yG) — closes the parity soundness gap. Fires only on affine + // rows; x-only guests pass a 32-byte xG (no yG in memory), so these must not fire there. + // Same low address limb (the +63 span is guarded to not cross the 2^32 limb boundary). for i in 0..4 { let base_lo = BusValue::linear(vec![ LinearTerm::Column { @@ -368,7 +411,7 @@ pub fn bus_interactions() -> Vec { ]); out.push(BusInteraction::sender( BusId::Memw, - mu(), + affine(), memw_read( dword_bytes(cols::YG, i), 0, @@ -470,11 +513,11 @@ pub fn bus_interactions() -> Vec { )); } - // AFFINE PoC: write yR as 4 doublewords at addr_xR + 32 + 8i (ts + 3). Reuses the - // ADDR_XR register (output buffer is the contiguous 64-byte [xR‖yR]); no new column - // or register read. yR (col YR) is the ECDAS-constrained y of k·(xG, even-yG). The - // guest fixes the sign from its known yG parity. ts + 3 is the free 4th sub-timestamp - // (instruction stride is 4; xG@T, k@T+1, xR@T+2 use the first three). + // AFFINE (mult = IS_AFFINE): write yR as 4 doublewords at addr_xR + 32 + 8i (ts + 3). + // Reuses the ADDR_XR register (output buffer is the contiguous 64-byte [xR‖yR]); no new + // column or register read. yR (col YR) is the ECDAS-constrained y of k·(xG, yG). Fires + // only on affine rows; x-only guests get only xR written back. ts + 3 is the free 4th + // sub-timestamp (instruction stride is 4; xG@T, k@T+1, xR@T+2 use the first three). for i in 0..4 { let base_lo = BusValue::linear(vec![ LinearTerm::Column { @@ -485,7 +528,7 @@ pub fn bus_interactions() -> Vec { ]); out.push(BusInteraction::sender( BusId::Memw, - mu(), + affine(), memw_write( dword_bytes(cols::YR, i), base_lo, @@ -725,7 +768,7 @@ impl OverflowKind { // ========================================================================= // // One body against the generic `ConstraintBuilder` serves the compiled prover -// folder, the verifier folder and IR capture. Constraint indices 0..413: +// folder, the verifier folder and IR capture. Constraint indices 0..415: // 0 : IS_BIT(MU) // 1..257 : IS_BIT(k[i]) for the 256 scalar bits // 257 : KBitsZeroOnPadding — (Σ k_bit[i])·(1−µ) @@ -740,10 +783,12 @@ impl OverflowKind { // 404 : OverflowRequired(KLtN) // 405..412 : CarryBit(XrLtP, 0..7) // 412 : OverflowRequired(XrLtP) +// 413 : IS_BIT(IS_AFFINE) +// 414 : AffineZeroOnPadding — IS_AFFINE·(1−µ) use stark::constraints::builder::{ConstraintBuilder, ConstraintSet}; -/// ECSM transition constraints as a single-source [`ConstraintSet`] (413 +/// ECSM transition constraints as a single-source [`ConstraintSet`] (415 /// total). No column configuration needed (the layout is fixed via `cols`). pub struct EcsmConstraints; @@ -950,6 +995,20 @@ impl ConstraintSet for EcsmConstraints { idx += 1; } - debug_assert_eq!(idx, 413); + // idx 413: IS_BIT(IS_AFFINE): a·(1−a). The mode selector is a bit. (deg 2) + let is_affine = b.main(0, cols::IS_AFFINE); + let one = b.one(); + b.emit_base(idx, is_affine.clone() * (one - is_affine)); + idx += 1; + + // idx 414: AffineZeroOnPadding: IS_AFFINE·(1−µ). Forces IS_AFFINE = 0 on padding + // rows (µ=0), so the affine-gated yG-read / yR-write buses can't fire there. (deg 2) + let is_affine = b.main(0, cols::IS_AFFINE); + let mu = b.main(0, cols::MU); + let one = b.one(); + b.emit_base(idx, is_affine * (one - mu)); + idx += 1; + + debug_assert_eq!(idx, 415); } } diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 8fdd85e22..eecfb1efb 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -847,26 +847,34 @@ fn collect_ecsm_ops( Vec, ) { let t = op.timestamp; + let is_affine = op.ecsm_affine; let addr_xr = register_state.read(10).0; let addr_xg = register_state.read(11).0; let addr_k = register_state.read(12).0; - // Read the operands from memory: xG‖yG (64 contiguous bytes) and k (32 bytes). - // AFFINE: yG is the caller's real input y (pinned below by a memory read), so the - // witnessed/returned point is the caller's actual point — no even-parity convention. + // Read the operands from memory. x-only reads xG (32B) + k (32B); affine also reads yG + // (the caller's real input y, pinned below by a memory read at T) so the returned point + // is the caller's actual point — no even-parity convention. let mut xg = [0u8; 32]; let mut yg = [0u8; 32]; let mut k = [0u8; 32]; for i in 0..32 { xg[i] = memory_state.read_byte(addr_xg.wrapping_add(i as u64)).0; - yg[i] = memory_state - .read_byte(addr_xg.wrapping_add(32 + i as u64)) - .0; + if is_affine { + yg[i] = memory_state + .read_byte(addr_xg.wrapping_add(32 + i as u64)) + .0; + } k[i] = memory_state.read_byte(addr_k.wrapping_add(i as u64)).0; } - let witness = ::ecsm::compute_witness_with_y(&k, &xg, &yg) - .expect("ECSM witness: executor validates 0 < k < N, xG/yG < p, (xG,yG) on curve"); + let witness = if is_affine { + ::ecsm::compute_witness_with_y(&k, &xg, &yg) + .expect("ECSM witness: executor validates 0 < k < N, xG/yG < p, (xG,yG) on curve") + } else { + ::ecsm::compute_witness(&k, &xg) + .expect("ECSM witness: executor validates 0 < k < N and xG on curve") + }; let mut memw_ops = Vec::with_capacity(15); @@ -895,19 +903,23 @@ fn collect_ecsm_ops( memory_state.write_bytes(addr, dword, 8, t); } - // AFFINE: yG: 4 doubleword reads at T (addr_xG + 32 + 8i). Pins the witnessed yG to - // the caller's input, closing the parity soundness gap of the x-only-input version. - for i in 0..4 { - let addr = addr_xg.wrapping_add((32 + 8 * i) as u64); - let mut value = [0u32; 8]; - let mut dword = 0u64; - for j in 0..8 { - value[j] = witness.y_g[8 * i + j] as u32; - dword |= (witness.y_g[8 * i + j] as u64) << (8 * j); + // AFFINE only: yG: 4 doubleword reads at T (addr_xG + 32 + 8i). Pins the witnessed yG to + // the caller's input, closing the parity soundness gap. x-only guests pass only xG, so + // this block (and the ECSM table's yG-read bus, gated by IS_AFFINE) does not run. + if is_affine { + for i in 0..4 { + let addr = addr_xg.wrapping_add((32 + 8 * i) as u64); + let mut value = [0u32; 8]; + let mut dword = 0u64; + for j in 0..8 { + value[j] = witness.y_g[8 * i + j] as u32; + dword |= (witness.y_g[8 * i + j] as u64) << (8 * j); + } + let (_old, old_ts) = memory_state.read_bytes(addr, 8); + memw_ops + .push(MemwOperation::new(false, addr, value, t, 8, true).with_old(value, old_ts)); + memory_state.write_bytes(addr, dword, 8, t); } - let (_old, old_ts) = memory_state.read_bytes(addr, 8); - memw_ops.push(MemwOperation::new(false, addr, value, t, 8, true).with_old(value, old_ts)); - memory_state.write_bytes(addr, dword, 8, t); } // x12 -> addr_k (register read at T+1). @@ -963,21 +975,24 @@ fn collect_ecsm_ops( memory_state.write_bytes(addr, dword, 8, t + 2); } - // AFFINE PoC: yR writes at T + 3 (4 doublewords at addr_xR + 32 + 8i). Matches the - // ecsm.rs YR sender block; the executor wrote yR to addr_xR + 32. - for i in 0..4 { - let addr = addr_xr.wrapping_add((32 + 8 * i) as u64); - let mut value = [0u32; 8]; - let mut dword = 0u64; - for j in 0..8 { - value[j] = witness.y_r[8 * i + j] as u32; - dword |= (witness.y_r[8 * i + j] as u64) << (8 * j); + // AFFINE only: yR writes at T + 3 (4 doublewords at addr_xR + 32 + 8i). Matches the + // ecsm.rs YR sender block (gated by IS_AFFINE); the executor wrote yR to addr_xR + 32. + // x-only guests get only xR written back. + if is_affine { + for i in 0..4 { + let addr = addr_xr.wrapping_add((32 + 8 * i) as u64); + let mut value = [0u32; 8]; + let mut dword = 0u64; + for j in 0..8 { + value[j] = witness.y_r[8 * i + j] as u32; + dword |= (witness.y_r[8 * i + j] as u64) << (8 * j); + } + let (old_vals, old_ts) = memory_state.read_bytes(addr, 8); + memw_ops.push( + MemwOperation::new(false, addr, value, t + 3, 8, false).with_old(old_vals, old_ts), + ); + memory_state.write_bytes(addr, dword, 8, t + 3); } - let (old_vals, old_ts) = memory_state.read_bytes(addr, 8); - memw_ops.push( - MemwOperation::new(false, addr, value, t + 3, 8, false).with_old(old_vals, old_ts), - ); - memory_state.write_bytes(addr, dword, 8, t + 3); } let ecdas_ops = witness @@ -991,6 +1006,7 @@ fn collect_ecsm_ops( addr_xg, addr_k, addr_xr, + is_affine, witness, }; diff --git a/prover/src/tests/ecsm_tests.rs b/prover/src/tests/ecsm_tests.rs index 91956c5ad..9a302fce7 100644 --- a/prover/src/tests/ecsm_tests.rs +++ b/prover/src/tests/ecsm_tests.rs @@ -44,6 +44,7 @@ fn op_for(k: u64) -> EcsmOperation { addr_xg: 0x2000, addr_k: 0x3000, addr_xr: 0x1000, + is_affine: false, witness, } } @@ -94,7 +95,7 @@ fn constraints_hold_on_generated_trace() { #[test] fn constraint_set_count() { - assert_eq!(EcsmConstraints.meta().len(), 413); + assert_eq!(EcsmConstraints.meta().len(), 415); } /// The yG carry recurrence closes on all-zero padding because both the `µ·p²` offset and the @@ -231,6 +232,7 @@ fn q1_bit32_equals_one_path() { addr_xg: 0x2000, addr_k: 0x3000, addr_xr: 0x1000, + is_affine: false, witness, }; let trace = generate_ecsm_trace(&[op]); @@ -257,6 +259,7 @@ fn constraints_hold_for_k_eq_n_minus_one() { addr_xg: 0x2000, addr_k: 0x3000, addr_xr: 0x1000, + is_affine: false, witness, }; let trace = generate_ecsm_trace(&[op]); diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index 787b327db..cf64f40c6 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -1510,6 +1510,85 @@ fn test_prove_elfs_ecsm_forged_ecdas_mu_rejected() { ); } +/// secp256k1 generator (Gx, Gy) as little-endian 32-byte coordinates. +fn secp256k1_generator_le() -> ([u8; 32], [u8; 32]) { + let mut gx = [ + 0x79u8, 0xBE, 0x66, 0x7E, 0xF9, 0xDC, 0xBB, 0xAC, 0x55, 0xA0, 0x62, 0x95, 0xCE, 0x87, 0x0B, + 0x07, 0x02, 0x9B, 0xFC, 0xDB, 0x2D, 0xCE, 0x28, 0xD9, 0x59, 0xF2, 0x81, 0x5B, 0x16, 0xF8, + 0x17, 0x98, + ]; + gx.reverse(); + let mut gy = [ + 0x48u8, 0x3A, 0xDA, 0x77, 0x26, 0xA3, 0xC4, 0x65, 0x5D, 0xA4, 0xFB, 0xFC, 0x0E, 0x11, 0x08, + 0xA8, 0xFD, 0x17, 0xB4, 0x48, 0xA6, 0x85, 0x54, 0x19, 0x9C, 0x47, 0xD0, 0x8F, 0xFB, 0x10, + 0xD4, 0xB8, + ]; + gy.reverse(); + (gx, gy) +} + +/// Reads the compiled affine ECSM rust guest (`ecsm_mul_affine` → commit xR‖yR). +fn ecsm_affine_elf_bytes() -> Vec { + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + std::fs::read(workspace_root.join("executor/program_artifacts/rust/ecsm_affine.elf")) + .expect("ecsm_affine.elf not found — run `make compile-programs-rust`") +} + +/// End-to-end via the **affine** Rust-guest path: `ecsm_mul_affine` computes 5·G and commits +/// the full 64-byte point xR‖yR. Verifies the affine ecall proves (IS_AFFINE=1: yG read from +/// memory, yR written back) and that the committed point matches the native reference. +#[test] +fn test_prove_ecsm_affine_rust_guest() { + let _ = env_logger::builder().is_test(true).try_init(); + + let elf_bytes = ecsm_affine_elf_bytes(); + let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); + assert!( + verify_vm_minimal(&proof, &elf_bytes), + "ecsm_affine rust guest should verify" + ); + + // Committed output must equal the full point 5·G = (xR, yR). + let (gx, gy) = secp256k1_generator_le(); + let mut k = [0u8; 32]; + k[0] = 5; + let (xr, yr) = ecsm::scalar_mul_xy_with_y(&k, &gx, &gy).unwrap(); + let mut expected = xr.to_vec(); + expected.extend_from_slice(&yr); + assert_eq!(proof.public_output, expected); +} + +/// Soundness: forging the returned `yR` on an affine ECSM row must be rejected. `yR` is pinned +/// both by the affine yR-write MEMW bus (mult = IS_AFFINE) and by the ECDAS final-receiver +/// tuple, so tampering it unbalances those buses and the proof must fail to verify. +#[test] +fn test_prove_ecsm_forged_yr_rejected() { + use crate::tables::ecsm::cols as ecsm_cols; + + let _ = env_logger::builder().is_test(true).try_init(); + + let elf_bytes = ecsm_affine_elf_bytes(); + 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 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 yR on the (single) real affine ECSM row. + let orig = *traces.ecsm.main_table.get(0, ecsm_cols::YR); + let forged = orig + FieldElement::::one(); + traces.ecsm.main_table.set(0, ecsm_cols::YR, forged); + + assert!( + !prove_and_verify_vm_minimal(&elf, &mut traces), + "Verifier must reject a forged ECSM result yR" + ); +} + /// Verifier REJECTS a forged trace where an addr byte cell is set to a /// non-byte field element. /// diff --git a/syscalls/src/syscalls.rs b/syscalls/src/syscalls.rs index 9a97c1ece..4a23bb6d3 100644 --- a/syscalls/src/syscalls.rs +++ b/syscalls/src/syscalls.rs @@ -29,10 +29,15 @@ pub enum SyscallNumbers { #[cfg(target_arch = "riscv64")] const KECCAK_SYSCALL_NUMBER: usize = usize::MAX - 1; -/// Syscall number for the ECSM secp256k1 scalar-multiply accelerator (-11 as usize). +/// Syscall number for the x-only ECSM secp256k1 scalar-multiply accelerator (-11 as usize). #[cfg(target_arch = "riscv64")] const ECSM_SYSCALL_NUMBER: usize = usize::MAX - 10; +/// Syscall number for the affine ECSM variant (full point in/out). +/// Must match `executor::...::execution::ECSM_AFFINE_SYSCALL_NUMBER` (u64::MAX - 11). +#[cfg(target_arch = "riscv64")] +const ECSM_AFFINE_SYSCALL_NUMBER: usize = usize::MAX - 11; + /// Syscall number for the non-constraining Hint ecall (BENCH ONLY). /// Must match `executor::...::execution::HINT_SYSCALL_NUMBER` (u64::MAX - 20). #[cfg(target_arch = "riscv64")] @@ -206,7 +211,7 @@ pub fn ecsm_mul_affine(out: &mut [u8; 64], input: &[u8; 64], k: &[u8; 32]) { in("a0") out.as_mut_ptr(), // x10 = address to write [xR‖yR] (64 bytes) in("a1") input.as_ptr(), // x11 = address of [xG‖yG] (64 bytes) in("a2") k.as_ptr(), // x12 = address of k - in("a7") ECSM_SYSCALL_NUMBER, + in("a7") ECSM_AFFINE_SYSCALL_NUMBER, ) } } From 287b2f5bbb22640bdbd577d33b6285436d4d563c Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 30 Jul 2026 11:38:10 -0300 Subject: [PATCH 13/28] Force yR < p in the ECSM chip --- crypto/ecsm/src/witness.rs | 7 +++++ prover/src/tables/ecsm.rs | 43 +++++++++++++++++++++++------- prover/src/tables/trace_builder.rs | 5 +++- prover/src/tests/ecsm_tests.rs | 2 +- 4 files changed, 46 insertions(+), 11 deletions(-) diff --git a/crypto/ecsm/src/witness.rs b/crypto/ecsm/src/witness.rs index 6153e083d..d412ab8aa 100644 --- a/crypto/ecsm/src/witness.rs +++ b/crypto/ecsm/src/witness.rs @@ -47,6 +47,11 @@ pub struct EcsmWitness { pub k_sub_n: [u8; 32], /// `(xR - p) mod 2^256` pub x_r_sub_p: [u8; 32], + /// `(yR - p) mod 2^256`. Forces `yR < p`: without it the byte range checks only + /// bound `yR < 2^256`, and the quotient columns absorb a multiple of `p`, so a + /// witness could publish `yR + p` for any `yR < 2^256 - p` (~2^32) — points with + /// such a tiny `y` are constructible, since `3 | p-1` makes cubing 3-to-1. + pub y_r_sub_p: [u8; 32], /// position of the most significant set bit of `k` pub len_k: u8, pub x_r: [u8; 32], @@ -347,6 +352,7 @@ fn compute_witness_inner( let x_r = to_le_32(&result.x); let y_r = to_le_32(&result.y); let x_r_sub_p = to_le_32(&((&two_256 + &result.x) - p())); + let y_r_sub_p = to_le_32(&((&two_256 + &result.y) - p())); // Steps are independent witnesses (each builds its own λ/quotient/carry data // from one StepPts), so they parallelize freely when rayon is available. @@ -373,6 +379,7 @@ fn compute_witness_inner( x_g_sub_p, k_sub_n, x_r_sub_p, + y_r_sub_p, len_k, x_r, y_r, diff --git a/prover/src/tables/ecsm.rs b/prover/src/tables/ecsm.rs index cc98d701d..7f4f625f6 100644 --- a/prover/src/tables/ecsm.rs +++ b/prover/src/tables/ecsm.rs @@ -69,8 +69,10 @@ pub mod cols { /// Mode selector: 1 = affine ecall (read yG, write yR), 0 = x-only / padding. /// Pinned to the actual ecall number by the `Ecall` receiver (see `bus_interactions`). pub const IS_AFFINE: usize = 667; + /// `(yR - p) mod 2^256`, the addend that forces `yR < p` (see `OverflowKind::YrLtP`). + pub const YR_SUB_P: usize = 668; // U256HL (16 halfwords) - pub const NUM_COLUMNS: usize = 668; + pub const NUM_COLUMNS: usize = 684; #[inline] pub const fn xr(i: usize) -> usize { @@ -121,6 +123,10 @@ pub mod cols { pub const fn xr_sub_p(i: usize) -> usize { XR_SUB_P + i } + #[inline] + pub const fn yr_sub_p(i: usize) -> usize { + YR_SUB_P + i + } } // ========================================================================= @@ -197,6 +203,7 @@ pub fn generate_ecsm_trace( write_halfwords(table, row_idx, cols::XG_SUB_P, &w.x_g_sub_p); write_halfwords(table, row_idx, cols::K_SUB_N, &w.k_sub_n); write_halfwords(table, row_idx, cols::XR_SUB_P, &w.x_r_sub_p); + write_halfwords(table, row_idx, cols::YR_SUB_P, &w.y_r_sub_p); for i in 0..64 { debug_assert!((0..1 << 16).contains(&(w.c0[i] + CARRY_OFFSET_X2))); @@ -601,6 +608,13 @@ pub fn bus_interactions() -> Vec { vec![packed(cols::xr_sub_p(i))], )); } + for i in 0..16 { + out.push(BusInteraction::sender( + BusId::IsHalfword, + mu(), + vec![packed(cols::yr_sub_p(i))], + )); + } // ZERO bus: assert k != 0 (sum of byte_k[0..31] is nonzero). // byte_k[i] = Σ_{j=0}^{7} 2^j · k[8i+j], so Σ byte_k = Σ_{b=0}^{255} 2^(b%8) · k[b]. @@ -725,6 +739,7 @@ pub enum OverflowKind { XgLtP, KLtN, XrLtP, + YrLtP, } impl OverflowKind { @@ -734,6 +749,7 @@ impl OverflowKind { OverflowKind::XgLtP => &P_BYTES, OverflowKind::KLtN => &N_BYTES, OverflowKind::XrLtP => &P_BYTES, + OverflowKind::YrLtP => &P_BYTES, }; let mut w = 0u64; for b in 0..4 { @@ -747,6 +763,7 @@ impl OverflowKind { OverflowKind::XgLtP => cols::XG_SUB_P, OverflowKind::KLtN => cols::K_SUB_N, OverflowKind::XrLtP => cols::XR_SUB_P, + OverflowKind::YrLtP => cols::YR_SUB_P, } } /// Column base of the sum. @@ -755,6 +772,7 @@ impl OverflowKind { OverflowKind::XgLtP => cols::XG, OverflowKind::KLtN => cols::K, OverflowKind::XrLtP => cols::XR, + OverflowKind::YrLtP => cols::YR, } } /// Whether the sum is stored as individual bits (k) rather than bytes (xG/xR). @@ -768,7 +786,7 @@ impl OverflowKind { // ========================================================================= // // One body against the generic `ConstraintBuilder` serves the compiled prover -// folder, the verifier folder and IR capture. Constraint indices 0..415: +// folder, the verifier folder and IR capture. Constraint indices 0..423: // 0 : IS_BIT(MU) // 1..257 : IS_BIT(k[i]) for the 256 scalar bits // 257 : KBitsZeroOnPadding — (Σ k_bit[i])·(1−µ) @@ -783,12 +801,14 @@ impl OverflowKind { // 404 : OverflowRequired(KLtN) // 405..412 : CarryBit(XrLtP, 0..7) // 412 : OverflowRequired(XrLtP) -// 413 : IS_BIT(IS_AFFINE) -// 414 : AffineZeroOnPadding — IS_AFFINE·(1−µ) +// 413..420 : CarryBit(YrLtP, 0..7) +// 420 : OverflowRequired(YrLtP) +// 421 : IS_BIT(IS_AFFINE) +// 422 : AffineZeroOnPadding — IS_AFFINE·(1−µ) use stark::constraints::builder::{ConstraintBuilder, ConstraintSet}; -/// ECSM transition constraints as a single-source [`ConstraintSet`] (415 +/// ECSM transition constraints as a single-source [`ConstraintSet`] (423 /// total). No column configuration needed (the layout is fixed via `cols`). pub struct EcsmConstraints; @@ -979,7 +999,12 @@ impl ConstraintSet for EcsmConstraints { idx += 1; // xG < p, k < N and xR < p: 7 carry bits (deg 3) + overflow-required (deg 2) each. - for kind in [OverflowKind::XgLtP, OverflowKind::KLtN, OverflowKind::XrLtP] { + for kind in [ + OverflowKind::XgLtP, + OverflowKind::KLtN, + OverflowKind::XrLtP, + OverflowKind::YrLtP, + ] { let c = Self::carry_chain(b, kind); for ci in c.iter().take(7) { // µ · c_i · (1 − c_i) @@ -995,13 +1020,13 @@ impl ConstraintSet for EcsmConstraints { idx += 1; } - // idx 413: IS_BIT(IS_AFFINE): a·(1−a). The mode selector is a bit. (deg 2) + // idx 421: IS_BIT(IS_AFFINE): a·(1−a). The mode selector is a bit. (deg 2) let is_affine = b.main(0, cols::IS_AFFINE); let one = b.one(); b.emit_base(idx, is_affine.clone() * (one - is_affine)); idx += 1; - // idx 414: AffineZeroOnPadding: IS_AFFINE·(1−µ). Forces IS_AFFINE = 0 on padding + // idx 422: AffineZeroOnPadding: IS_AFFINE·(1−µ). Forces IS_AFFINE = 0 on padding // rows (µ=0), so the affine-gated yG-read / yR-write buses can't fire there. (deg 2) let is_affine = b.main(0, cols::IS_AFFINE); let mu = b.main(0, cols::MU); @@ -1009,6 +1034,6 @@ impl ConstraintSet for EcsmConstraints { b.emit_base(idx, is_affine * (one - mu)); idx += 1; - debug_assert_eq!(idx, 415); + debug_assert_eq!(idx, 423); } } diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index eecfb1efb..65838de42 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -2445,7 +2445,7 @@ pub(crate) fn collect_bitwise_from_ecsm(ops: &[ecsm::EcsmOperation]) -> Vec Vec Date: Thu, 30 Jul 2026 11:38:19 -0300 Subject: [PATCH 14/28] Drop stale affine docs and dead entry point --- crypto/ecsm/src/lib.rs | 9 -------- crypto/ethrex-crypto/src/lib.rs | 24 +++++++++++--------- crypto/ethrex-crypto/src/tests/ecsm_tests.rs | 5 ++-- 3 files changed, 16 insertions(+), 22 deletions(-) diff --git a/crypto/ecsm/src/lib.rs b/crypto/ecsm/src/lib.rs index 47df49c43..4e8c09ac2 100644 --- a/crypto/ecsm/src/lib.rs +++ b/crypto/ecsm/src/lib.rs @@ -169,12 +169,3 @@ pub fn scalar_mul_x(k_le: &[u8; 32], xg_le: &[u8; 32]) -> Result<[u8; 32], EcsmE let (k, g) = prepare(k_le, xg_le)?; Ok(to_le_32(&curve::scalar_mul_affine_x(&k, &g))) } - -/// Affine PoC entry point: both coordinates of `k·G` as little-endian 32-byte values, -/// with `y` on the even-`y` convention (matching the witness / ECDAS-constrained `y_r`). -/// The executor writes `xR` then `yR` (contiguous 64-byte output) back to guest memory. -pub fn scalar_mul_xy(k_le: &[u8; 32], xg_le: &[u8; 32]) -> Result<([u8; 32], [u8; 32]), EcsmError> { - let (k, g) = prepare(k_le, xg_le)?; - let r = curve::scalar_mul_affine(&k, &g); - Ok((to_le_32(&r.x), to_le_32(&r.y))) -} diff --git a/crypto/ethrex-crypto/src/lib.rs b/crypto/ethrex-crypto/src/lib.rs index 728576620..8beaf79bb 100644 --- a/crypto/ethrex-crypto/src/lib.rs +++ b/crypto/ethrex-crypto/src/lib.rs @@ -296,18 +296,20 @@ fn field_inv(x: &FieldElement) -> Option { } } -/// Computes `k1·P1 + k2·P2` from four x-only oracle queries, or `None` if any -/// degenerate-configuration guard trips. +/// Computes `k1·P1 + k2·P2` from two affine oracle queries, or `None` if a +/// degenerate configuration trips a guard. /// -/// The lambda-vm ECSM precompile returns only `x(k·P)`. For `A = k1·P1` with -/// `P1 = (xp, yp)` fully known, query `xa = x(k1·P1)` and `xc = x((k1+1)·P1)`. -/// The chord-addition law gives `λ² = xc + xa + xp =: t` and `ya = yp + λ·dx` -/// with `dx = xa − xp`; substituting into `ya² = xa³ + b` makes λ *linear*: -/// `λ = (xa³ − xp³ − t·dx²) / (2·yp·dx)`. The wrong sign `−ya` would force -/// `x((k1−1)·P1) = xc`, i.e. `k1 ≡ 0` or `2·k1 ≡ 0 (mod n)`, excluded by the -/// scalar guards. x-only queries are parity-invariant (`x(k·P) = x(k·(−P))`), -/// so the precompile's canonical-y lift never matters. Same for `B = k2·P2`, -/// then `Q = A + B` is one affine addition. All three inversions are batched. +/// The affine ECSM ecall returns the full point, so `A = k1·P1` and `B = k2·P2` +/// each cost one query and `Q = A + B` is a single chord addition — one field +/// inversion, for `1/(xb − xa)`. `dx = 0` covers both degenerate cases at once +/// (two curve points share an x only when they are equal or negatives), so the +/// caller falls back to the software `lincomb` there. +/// +/// The x-only predecessor needed a second query `x((k+1)·P)` per point to solve +/// for `y` through the chord-addition law, which is what made `k1 = 1` and +/// `k1 = N−1` degenerate; with `y` supplied by the chip those scalars are +/// ordinary. secp256k1 has cofactor 1 and prime `N`, so `k·P ≠ O` for every +/// `k ∈ (0, N)` and no further scalar guard is needed. /// /// Generic over the oracle so unit tests can substitute a software stand-in. #[cfg(any(target_arch = "riscv64", test))] diff --git a/crypto/ethrex-crypto/src/tests/ecsm_tests.rs b/crypto/ethrex-crypto/src/tests/ecsm_tests.rs index 15e728148..04e3a28ce 100644 --- a/crypto/ethrex-crypto/src/tests/ecsm_tests.rs +++ b/crypto/ethrex-crypto/src/tests/ecsm_tests.rs @@ -125,8 +125,9 @@ fn cross_point_cancellation_falls_back() { #[test] fn odd_y_base_point_reconstructs_correctly() { - // Validates the affine oracle's odd-y sign flip: when P1 has odd y the caller - // must negate the oracle's even-y result, matching ProjectivePoint::lincomb. + // A base point with odd y needs no special handling: the affine oracle receives the + // caller's actual y and returns the actual k·P, so there is no even-y convention to + // undo. Pins that, by checking the result against ProjectivePoint::lincomb. let (p1, _k_gen) = (2u64..200) .find_map(|n| { let p = g_times(n); From 5743caa4854e0cd5c8da120f0af41416dcb09090 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 30 Jul 2026 12:27:48 -0300 Subject: [PATCH 15/28] Verify hints with a mandatory software fallback --- crypto/ethrex-crypto/src/lib.rs | 179 ++++++++++++------ .../src/tests/ecrecover_tests.rs | 48 ++--- crypto/ethrex-crypto/src/tests/ecsm_tests.rs | 10 +- crypto/ethrex-crypto/src/tests/hint_tests.rs | 145 ++++++++++++++ .../ethrex-crypto/src/tests/keccak_tests.rs | 7 +- crypto/ethrex-crypto/src/tests/mod.rs | 2 + executor/src/tests/hint_tests.rs | 67 ++++++- executor/src/vm/instruction/execution.rs | 51 +++-- 8 files changed, 397 insertions(+), 112 deletions(-) create mode 100644 crypto/ethrex-crypto/src/tests/hint_tests.rs diff --git a/crypto/ethrex-crypto/src/lib.rs b/crypto/ethrex-crypto/src/lib.rs index 8b520f384..3736179a9 100644 --- a/crypto/ethrex-crypto/src/lib.rs +++ b/crypto/ethrex-crypto/src/lib.rs @@ -20,9 +20,10 @@ use ethrex_crypto::keccak::keccak_hash; use ethrex_crypto::{Crypto, CryptoError}; use k256::elliptic_curve::group::prime::PrimeCurveAffine; use k256::elliptic_curve::ops::{LinearCombination, Reduce}; -// `Invert` (software `x.invert()`) is only used by the host fallback; on the -// riscv64 guest all inversions go through the `hint` ecall. -#[cfg(not(target_arch = "riscv64"))] +// `Invert` provides the software `x.invert()/invert_vartime()`. It is used by the +// host path AND, on the riscv64 guest, by the mandatory software fallback that +// runs whenever a hinted inverse fails to verify (a lying host). It is therefore +// needed in every build, not only off-target. use k256::elliptic_curve::ops::Invert; use k256::elliptic_curve::sec1::ToEncodedPoint; use k256::elliptic_curve::PrimeField; @@ -65,32 +66,42 @@ impl Crypto for LambdaVmEcsmCrypto { /// 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 UNVERIFIED — every caller MUST check it -/// in-guest (`x·inv == 1`, `y² == x³+7`), since the ecall adds no correctness -/// constraint. BENCH scaffolding. +/// 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. #[cfg(target_arch = "riscv64")] fn get_hint(hint_id: usize, x_be: &[u8; 32]) -> [u8; 32] { - let mut out = [0u8; 32]; - lambda_vm_syscalls::syscalls::hint(hint_id, &mut out, x_be); - out + // 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 } -/// Scalar-field inverse `x⁻¹ mod n`. On riscv64 (guest) the inverse comes from the -/// `hint` ecall and we verify `x·inv == 1`; off-target (host tests) it computes the -/// inverse in software. BENCH scaffolding. +/// 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. fn scalar_inv(x: &Scalar) -> Option { #[cfg(target_arch = "riscv64")] { - use k256::elliptic_curve::subtle::ConstantTimeEq; - let x_be: [u8; 32] = x.to_bytes().into(); - let inv_be = get_hint(lambda_vm_syscalls::syscalls::HINT_SCALAR_INV, &x_be); - let inv: Scalar = Option::from(Scalar::from_repr(inv_be.into()))?; - // Verify the untrusted hint: x·inv must equal 1 (mod n). - if bool::from((*x * inv).ct_eq(&Scalar::ONE)) { - Some(inv) - } else { - None - } + scalar_inv_with_oracle(x, |x_be| { + get_hint(lambda_vm_syscalls::syscalls::HINT_SCALAR_INV, x_be) + }) } #[cfg(not(target_arch = "riscv64"))] { @@ -98,40 +109,46 @@ fn scalar_inv(x: &Scalar) -> Option { } } -/// Decompress R from its x-coordinate + parity. On riscv64 the `y = sqrt(x³+7)` -/// is an `hint`-ecall value verified in-guest (`y² == x³+7`), with parity -/// selection; off-target it uses k256's software `decompress`. BENCH scaffolding. +/// Core of [`scalar_inv`], generic over the hint source so host tests can inject an +/// honest or a lying oracle and assert the software fallback keeps the result +/// correct either way. See [`scalar_inv`] for the verify-then-fallback rationale. +#[cfg(any(target_arch = "riscv64", test))] +fn scalar_inv_with_oracle(x: &Scalar, hint: O) -> Option +where + O: FnOnce(&[u8; 32]) -> [u8; 32], +{ + use k256::elliptic_curve::subtle::ConstantTimeEq; + let x_be: [u8; 32] = x.to_bytes().into(); + let inv_be = hint(&x_be); + // Fast path: a canonical hint that verifies (x·inv == 1 mod n) is used as-is. + if let Some(inv) = Option::::from(Scalar::from_repr(inv_be.into())) { + if bool::from((*x * inv).ct_eq(&Scalar::ONE)) { + return Some(inv); + } + } + // Hint absent / malformed / wrong: recompute authoritatively. `x⁻¹` exists for + // every input the callers pass (`r ≠ 0`), so this is `Some` on the honest path. + x.invert_vartime().into() +} + +/// 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. fn decompress_r(r_bytes: &FieldBytes, y_is_odd: bool) -> Option { #[cfg(target_arch = "riscv64")] { - let x: FieldElement = Option::from(FieldElement::from_bytes(r_bytes))?; - // secp256k1: y² = x³ + 7. - let mut seven_bytes = [0u8; 32]; - seven_bytes[31] = 7; - let seven: FieldElement = Option::from(FieldElement::from_bytes(&seven_bytes.into()))?; - let x3: FieldElement = x.square() * x; - let rhs: FieldElement = x3 + seven; - // Hinted sqrt (BE in/out), then verify y² == rhs canonically. - let rhs_be: [u8; 32] = rhs.to_bytes().into(); - let y_be = get_hint(lambda_vm_syscalls::syscalls::HINT_FIELD_SQRT, &rhs_be); - let mut y: FieldElement = Option::from(FieldElement::from_bytes(&y_be.into()))?; - let y2: FieldElement = y.square(); - // Verify the untrusted root: y² must equal x³+7. Negate `y2`, not `rhs`: - // `Neg` is `negate(1)` and only accepts magnitude 1, which `square()` always - // returns, whereas `rhs` is a sum and carries magnitude 2 — negating it would - // silently compute the wrong value in release, where the debug assert is gone. - // (`ct_eq` is unusable here for the same reason as in `field_inv`.) - if !bool::from((rhs + y2.negate(1)).normalizes_to_zero()) { - return None; - } - // Select the root whose canonical LSB matches the requested parity. - let y_odd = (y.to_bytes()[31] & 1) == 1; - if y_odd != y_is_odd { - y = -y; - } - // Build the affine point; `from_encoded_point` re-checks it's on-curve. - let ep = EncodedPoint::from_affine_coordinates(&x.to_bytes(), &y.to_bytes(), false); - Option::from(AffinePoint::from_encoded_point(&ep)) + decompress_r_with_oracle(r_bytes, y_is_odd, |rhs_be| { + get_hint(lambda_vm_syscalls::syscalls::HINT_FIELD_SQRT, rhs_be) + }) } #[cfg(not(target_arch = "riscv64"))] { @@ -140,6 +157,62 @@ fn decompress_r(r_bytes: &FieldBytes, y_is_odd: bool) -> Option { } } +/// Core of [`decompress_r`], generic over the hint source for host tests: try the +/// hinted sqrt, then fall back to the authoritative software decompress on any +/// failure. See [`decompress_r`] for the rationale. +#[cfg(any(target_arch = "riscv64", test))] +fn decompress_r_with_oracle(r_bytes: &FieldBytes, y_is_odd: bool, hint: O) -> Option +where + O: FnOnce(&[u8; 32]) -> [u8; 32], +{ + if let Some(p) = decompress_r_hinted(r_bytes, y_is_odd, hint) { + return Some(p); + } + // Hinted root absent / malformed / wrong, OR a genuine non-residue: the software + // decompress is authoritative — `Some` for a residue, `None` for a non-residue. + use k256::elliptic_curve::point::DecompressPoint; + AffinePoint::decompress(r_bytes, u8::from(y_is_odd).into()).into() +} + +/// The hint-accelerated decompress attempt: returns the point only if the hinted +/// root verifies (`y² == x³+7`); `None` on any failure, so the caller falls back to +/// the software decompress. Never the last word — a `None` here is not a decision +/// that R is invalid, only that the fast path did not produce a verified root. +#[cfg(any(target_arch = "riscv64", test))] +fn decompress_r_hinted(r_bytes: &FieldBytes, y_is_odd: bool, hint: O) -> Option +where + O: FnOnce(&[u8; 32]) -> [u8; 32], +{ + let x: FieldElement = Option::from(FieldElement::from_bytes(r_bytes))?; + // secp256k1: y² = x³ + 7. + let mut seven_bytes = [0u8; 32]; + seven_bytes[31] = 7; + let seven: FieldElement = Option::from(FieldElement::from_bytes(&seven_bytes.into()))?; + let x3: FieldElement = x.square() * x; + let rhs: FieldElement = x3 + seven; + // Hinted sqrt (BE in/out), then verify y² == rhs canonically. + let rhs_be: [u8; 32] = rhs.to_bytes().into(); + let y_be = hint(&rhs_be); + let mut y: FieldElement = Option::from(FieldElement::from_bytes(&y_be.into()))?; + let y2: FieldElement = y.square(); + // Verify the untrusted root: y² must equal x³+7. Negate `y2`, not `rhs`: + // `Neg` is `negate(1)` and only accepts magnitude 1, which `square()` always + // returns, whereas `rhs` is a sum and carries magnitude 2 — negating it would + // silently compute the wrong value in release, where the debug assert is gone. + // (`ct_eq` is unusable here for the same reason as in `field_inv`.) + if !bool::from((rhs + y2.negate(1)).normalizes_to_zero()) { + return None; + } + // Select the root whose canonical LSB matches the requested parity. + let y_odd = (y.to_bytes()[31] & 1) == 1; + if y_odd != y_is_odd { + y = -y; + } + // Build the affine point; `from_encoded_point` re-checks it's on-curve. + let ep = EncodedPoint::from_affine_coordinates(&x.to_bytes(), &y.to_bytes(), false); + Option::from(AffinePoint::from_encoded_point(&ep)) +} + /// Recover the uncompressed public key bytes (X‖Y, 64 bytes) from a 64-byte /// signature, recovery id, and 32-byte message hash. Used by the ECRECOVER /// precompile (0x01). diff --git a/crypto/ethrex-crypto/src/tests/ecrecover_tests.rs b/crypto/ethrex-crypto/src/tests/ecrecover_tests.rs index f9c1d9242..af2ab1f1d 100644 --- a/crypto/ethrex-crypto/src/tests/ecrecover_tests.rs +++ b/crypto/ethrex-crypto/src/tests/ecrecover_tests.rs @@ -57,36 +57,24 @@ fn make_ecdsa_fixture(d: Scalar, kk: Scalar, msg: [u8; 32]) -> ([u8; 64], u8, [u fn ecrecover_known_answer_three_tuples() { // Three distinct (d, kk, msg) tuples — deterministic, no RNG. let tuples: &[(u64, u64, [u8; 32])] = &[ - ( - 0x0000_0000_0000_0001u64, - 0x0000_0000_dead_beefu64, - { - let mut m = [0u8; 32]; - m[31] = 0x42; - m - }, - ), - ( - 0x00c0_ffee_dead_beef_u64, - 0x0123_4567_89ab_cdef_u64, - { - let mut m = [0u8; 32]; - m[0] = 0xff; - m[31] = 0x01; - m - }, - ), - ( - 0x0bad_f00d_1337_cafe, - 0xfeed_face_0000_0001, - { - let mut m = [0u8; 32]; - for (i, b) in m.iter_mut().enumerate() { - *b = i as u8; - } - m - }, - ), + (0x0000_0000_0000_0001u64, 0x0000_0000_dead_beefu64, { + let mut m = [0u8; 32]; + m[31] = 0x42; + m + }), + (0x00c0_ffee_dead_beef_u64, 0x0123_4567_89ab_cdef_u64, { + let mut m = [0u8; 32]; + m[0] = 0xff; + m[31] = 0x01; + m + }), + (0x0bad_f00d_1337_cafe, 0xfeed_face_0000_0001, { + let mut m = [0u8; 32]; + for (i, b) in m.iter_mut().enumerate() { + *b = i as u8; + } + m + }), ]; for &(d_u64, kk_u64, msg) in tuples { diff --git a/crypto/ethrex-crypto/src/tests/ecsm_tests.rs b/crypto/ethrex-crypto/src/tests/ecsm_tests.rs index 89c911db7..42e80224b 100644 --- a/crypto/ethrex-crypto/src/tests/ecsm_tests.rs +++ b/crypto/ethrex-crypto/src/tests/ecsm_tests.rs @@ -61,8 +61,14 @@ fn edge_scalars_fall_back() { let p2 = g_times(5); let ok = Scalar::from(12345u64); for bad in [Scalar::ZERO, Scalar::ONE, -Scalar::ONE] { - assert!(lincomb2_with_oracle(&p1.to_affine(), &bad, &p2.to_affine(), &ok, soft_oracle).is_none()); - assert!(lincomb2_with_oracle(&p1.to_affine(), &ok, &p2.to_affine(), &bad, soft_oracle).is_none()); + assert!( + lincomb2_with_oracle(&p1.to_affine(), &bad, &p2.to_affine(), &ok, soft_oracle) + .is_none() + ); + assert!( + lincomb2_with_oracle(&p1.to_affine(), &ok, &p2.to_affine(), &bad, soft_oracle) + .is_none() + ); } } diff --git a/crypto/ethrex-crypto/src/tests/hint_tests.rs b/crypto/ethrex-crypto/src/tests/hint_tests.rs new file mode 100644 index 000000000..023130e35 --- /dev/null +++ b/crypto/ethrex-crypto/src/tests/hint_tests.rs @@ -0,0 +1,145 @@ +//! Host tests for the untrusted-hint verify-then-fallback paths (`scalar_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 C1 turns on: a bad hint can only 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::*; + +/// A `[u8; 32]` big-endian field element from a small integer. +fn fe_from_u64(k: u64) -> FieldElement { + let mut be = [0u8; 32]; + be[24..32].copy_from_slice(&k.to_be_bytes()); + Option::::from(FieldElement::from_bytes(&be.into())).expect("k < p") +} + +/// Honest scalar-inverse oracle (BE in/out, mod n) — mirrors the executor's +/// `compute_hint(HINT_SCALAR_INV, ..)`: the inverse if it exists, else zeros. +fn honest_scalar_inv(x_be: &[u8; 32]) -> [u8; 32] { + let x = Option::::from(Scalar::from_repr((*x_be).into())).expect("canonical input"); + match Option::::from(x.invert()) { + Some(inv) => inv.to_bytes().into(), + None => [0u8; 32], + } +} + +/// Honest base-field sqrt oracle (BE in/out, mod p) — mirrors +/// `compute_hint(HINT_FIELD_SQRT, ..)`: a root if one exists, else zeros. +fn honest_field_sqrt(rhs_be: &[u8; 32]) -> [u8; 32] { + let rhs = Option::::from(FieldElement::from_bytes(&(*rhs_be).into())) + .expect("canonical"); + match Option::::from(rhs.sqrt()) { + Some(y) => y.to_bytes().into(), + None => [0u8; 32], + } +} + +fn sec1(p: &AffinePoint) -> Vec { + p.to_encoded_point(false).as_bytes().to_vec() +} + +#[test] +fn scalar_inv_honest_hint_matches_software() { + for k in [1u64, 2, 3, 7, 1000, 12345, u64::MAX] { + let x = Scalar::from(k); + let sw = x.invert_vartime().expect("k != 0 is invertible"); + let got = scalar_inv_with_oracle(&x, honest_scalar_inv).expect("inverse exists"); + assert_eq!( + got, sw, + "honest hint must equal the software inverse (k={k})" + ); + } +} + +#[test] +fn scalar_inv_lying_hint_falls_back_to_software() { + // The prover-chosen hint returns garbage; the result must be unchanged. `x⁻¹` + // exists (the caller guarantees `r != 0`), so the software fallback is + // authoritative — a lie cannot turn a recoverable signature into a failure. + for lie in [[0u8; 32], [0xFFu8; 32]] { + for k in [1u64, 2, 12345, u64::MAX] { + let x = Scalar::from(k); + let sw = x.invert_vartime().unwrap(); + let got = scalar_inv_with_oracle(&x, |_| lie).expect("fallback recomputes"); + assert_eq!( + got, sw, + "lying hint must fall back to the software inverse (k={k})" + ); + } + } +} + +#[test] +fn decompress_r_honest_hint_matches_software() { + // x-coordinates of real points are guaranteed residues. + for k in [1u64, 2, 5, 12345] { + let p = (ProjectivePoint::GENERATOR * Scalar::from(k)).to_affine(); + let (x, y) = affine_xy(&p).unwrap(); + let rb = x.to_bytes(); + let y_is_odd = (y.normalize().to_bytes()[31] & 1) == 1; + let got = decompress_r_with_oracle(&rb, y_is_odd, honest_field_sqrt) + .expect("valid residue decompresses"); + assert_eq!( + sec1(&got), + sec1(&p), + "honest hint must recover the point (k={k})" + ); + } +} + +#[test] +fn decompress_r_lying_hint_falls_back_to_software() { + // A residue x with a garbage sqrt hint must still decompress to the true point. + for lie in [[0u8; 32], [0xFFu8; 32]] { + for k in [1u64, 5, 12345] { + let p = (ProjectivePoint::GENERATOR * Scalar::from(k)).to_affine(); + let (x, y) = affine_xy(&p).unwrap(); + let rb = x.to_bytes(); + let y_is_odd = (y.normalize().to_bytes()[31] & 1) == 1; + let got = decompress_r_with_oracle(&rb, y_is_odd, |_| lie) + .expect("software fallback decompresses a residue"); + assert_eq!( + sec1(&got), + sec1(&p), + "lying hint must fall back to software (k={k})" + ); + } + } +} + +#[test] +fn decompress_r_non_residue_is_none_regardless_of_hint() { + // Find a small x whose x³+7 has no square root: R is genuinely undecompressable + // and must be `None`. A lying hint must NOT be able to force a `Some`, and the + // honest path must NOT spuriously fail — both stem from the same software + // fallback being the sole authority on rejection. + let mut seven = [0u8; 32]; + seven[31] = 7; + let seven = Option::::from(FieldElement::from_bytes(&seven.into())).unwrap(); + + let x = (1u64..10_000) + .map(fe_from_u64) + .find(|x| { + let rhs = (x.square() * *x + seven).normalize(); + Option::::from(rhs.sqrt()).is_none() + }) + .expect("some small x has a non-residue x³+7"); + let rb = x.to_bytes(); + + assert!( + decompress_r_with_oracle(&rb, false, honest_field_sqrt).is_none(), + "a genuine non-residue must decompress to None (honest hint)" + ); + for lie in [[0u8; 32], [0xFFu8; 32]] { + assert!( + decompress_r_with_oracle(&rb, false, |_| lie).is_none(), + "a lying hint must not force a non-residue to decompress" + ); + } +} diff --git a/crypto/ethrex-crypto/src/tests/keccak_tests.rs b/crypto/ethrex-crypto/src/tests/keccak_tests.rs index cde649fcb..14d497520 100644 --- a/crypto/ethrex-crypto/src/tests/keccak_tests.rs +++ b/crypto/ethrex-crypto/src/tests/keccak_tests.rs @@ -8,7 +8,12 @@ use crate::*; fn check_keccak(input: &[u8]) { let got = keccak256_with_permute(input, keccak::f1600); let want = keccak_hash(input); - assert_eq!(got, want, "keccak256 mismatch for {}-byte input", input.len()); + assert_eq!( + got, + want, + "keccak256 mismatch for {}-byte input", + input.len() + ); } /// Cross-check our sponge against a hardcoded vector from the Ethereum spec. diff --git a/crypto/ethrex-crypto/src/tests/mod.rs b/crypto/ethrex-crypto/src/tests/mod.rs index f050a8e48..37fc9b3a0 100644 --- a/crypto/ethrex-crypto/src/tests/mod.rs +++ b/crypto/ethrex-crypto/src/tests/mod.rs @@ -3,4 +3,6 @@ pub mod ecrecover_tests; #[cfg(test)] pub mod ecsm_tests; #[cfg(test)] +pub mod hint_tests; +#[cfg(test)] pub mod keccak_tests; diff --git a/executor/src/tests/hint_tests.rs b/executor/src/tests/hint_tests.rs index 3b0cb8aae..f32f25aea 100644 --- a/executor/src/tests/hint_tests.rs +++ b/executor/src/tests/hint_tests.rs @@ -1,8 +1,9 @@ -//! Tests for the non-constraining `Hint` syscall (BENCH ONLY). +//! Tests for the non-constraining `Hint` syscall. use crate::vm::instruction::decoding::Instruction; use crate::vm::instruction::execution::{ - ExecutionError, HINT_FIELD_INV, HINT_SYSCALL_NUMBER, compute_hint, + 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; @@ -105,14 +106,62 @@ fn hint_syscall_accepts_operand_ending_at_the_limb_boundary() { .expect("operand ending at the limb boundary must run"); } -/// An unknown `hint_id` is not an error — the ecall writes zeros and the guest's -/// verify is what rejects the value. Pins that contract so a future selector can't -/// silently start trapping instead. +/// 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_zeros_for_an_unknown_selector() { +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; - let out = - run_hint_at(u64::MAX, 0x1000, 0x2000, &input).expect("unknown selector must not trap"); - assert_eq!(out, [0u8; 32]); + 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:?}" + ); + } } diff --git a/executor/src/vm/instruction/execution.rs b/executor/src/vm/instruction/execution.rs index 87c636d64..4a47d453d 100644 --- a/executor/src/vm/instruction/execution.rs +++ b/executor/src/vm/instruction/execution.rs @@ -17,7 +17,7 @@ pub enum SyscallNumbers { // Placeholder discriminant. The actual syscall value is ECSM_SYSCALL_NUMBER. Ecsm = 94, // Placeholder discriminant. The actual syscall value is HINT_SYSCALL_NUMBER. - // BENCH ONLY: non-constraining hint (host computes modular inverse/sqrt, guest verifies). + // Non-constraining hint (host computes modular inverse/sqrt, guest verifies). Hint = 95, } @@ -34,12 +34,13 @@ 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 (BENCH ONLY). +/// 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`). This adds no in-circuit -/// correctness constraint of its own — it exists to measure the cost of the -/// hint-then-verify pattern versus computing the operation in the guest. +/// 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 - 20; /// Hint operation selector passed in `a0`. @@ -111,13 +112,19 @@ fn store_u256_le(memory: &mut Memory, addr: u64, bytes: &[u8; 32]) -> Result<(), Ok(()) } -/// BENCH ONLY. 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. On any failure (non-canonical input, no inverse/sqrt) returns zeros; -/// the guest's verify then fails loudly. +/// 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. +/// +/// 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). @@ -518,14 +525,22 @@ impl Instruction { dst_val = addr_k; } SyscallNumbers::Hint => { - // BENCH ONLY. Non-constraining hint: host computes a modular - // inverse/sqrt and writes it to the guest, which verifies it. - // 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. + // 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 !matches!(hint_id, HINT_FIELD_INV | HINT_SCALAR_INV | HINT_FIELD_SQRT) { + return Err(ExecutionError::HintUnknownSelector(hint_id)); + } // The HINT table sends the output writes as `[out_addr_lo + 8i, // out_addr_hi]`, so an `out_addr` whose 32-byte range crosses the // limb boundary would unbalance the memory bus. `in_addr` is not on @@ -723,6 +738,8 @@ pub enum ExecutionError { 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), } From ad21c37a123b22b88a011baebe8ec4c4b02b2416 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 30 Jul 2026 12:28:05 -0300 Subject: [PATCH 16/28] Constrain the HINT multiplicity column as boolean --- prover/src/tables/hint.rs | 45 +++++++++++++++---- prover/src/test_utils.rs | 13 +++--- .../tests/constraint_program_device_tests.rs | 1 + prover/src/tests/constraint_program_tests.rs | 1 + prover/src/tests/constraint_set_tests_b.rs | 16 +++++++ prover/src/tests/ood_window_ir_tests.rs | 1 + prover/src/tests/prove_elfs_tests.rs | 22 +++++++-- 7 files changed, 81 insertions(+), 18 deletions(-) diff --git a/prover/src/tables/hint.rs b/prover/src/tables/hint.rs index 25924bfc6..c82f7edc0 100644 --- a/prover/src/tables/hint.rs +++ b/prover/src/tables/hint.rs @@ -1,4 +1,4 @@ -//! HINT table — receiver for the non-constraining `hint` ecall (BENCH ONLY). +//! HINT table — receiver for the non-constraining `hint` ecall. //! //! The `hint` ecall (syscall `u64::MAX - 20`) lets the executor hand the guest a //! value that is expensive to compute but cheap to verify (modular inverse, sqrt, @@ -31,14 +31,16 @@ //! 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. //! -//! The table has no algebraic constraints (`EmptyConstraints`), so `mu` is a free -//! column, but it needs no boolean constraint: every interaction is gated by it, and -//! the `Ecall` receiver's tuple contains the timestamp, which is unique per -//! instruction (`ts = 4i + 4`). The LogUp identity therefore matches each `(ts, -//! syscall)` tuple on its own, forcing `mu` to equal the CPU's send multiplicity for -//! that ecall — 1 where the CPU issued one, and 0 everywhere else, since a nonzero -//! `mu` on a tuple the CPU never sent leaves the bus unbalanced. Pinning `mu` this -//! way is what also pins the writes and range checks below to real calls. +//! The table's only algebraic constraint is `mu·(1−mu) = 0` (`HintConstraints`): +//! `mu` is the multiplicity of every bus interaction, so it must be boolean. The +//! LogUp argument already pins its *value* — the `Ecall` receiver's tuple contains +//! the timestamp, which is unique per instruction (`ts = 4i + 4`), so the identity +//! matches each `(ts, syscall)` tuple on its own and forces `mu` to the CPU's send +//! multiplicity for that ecall (1 where the CPU issued one, 0 elsewhere; a nonzero +//! `mu` on a tuple the CPU never sent leaves the bus unbalanced). The boolean +//! constraint is nonetheless carried explicitly, matching every other +//! multiplicity-column table (ECSM/ECDAS/COMMIT/STORE/MEMW_R): a multiplicity column +//! is bit-constrained in-circuit rather than left to rest on a bus-balance argument. //! //! ## Columns (37) //! - `timestamp[0..1]` (DWordWL): the ecall timestamp `T` @@ -47,9 +49,12 @@ //! - `mu`: multiplicity flag (1 = real hint call, 0 = padding) — gates every bus 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}; pub mod cols { @@ -238,3 +243,25 @@ pub fn bus_interactions() -> Vec { 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 `x12` register read, 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. The LogUp argument already fixes `mu`'s value +/// via the timestamp-unique `Ecall` tuple, but every other multiplicity-column table +/// bit-constrains its column in-circuit; HINT does the same rather than being the +/// lone exception that relies solely on bus balance. +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/test_utils.rs b/prover/src/test_utils.rs index 005a22abb..948af4163 100644 --- a/prover/src/test_utils.rs +++ b/prover/src/test_utils.rs @@ -65,7 +65,9 @@ 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::{bus_interactions as hint_bus_interactions, cols as hint_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, }; @@ -841,15 +843,16 @@ pub fn create_halt_air(proof_options: &ProofOptions) -> ConcreteVmAir ConcreteVmAir { +/// Create HINT AIR: a receiver for the `hint` ecall (Ecall receive, x12 register +/// read, four output MEMW writes, output byte range-checks) with a single boolean +/// constraint on the multiplicity column `mu`. +pub fn create_hint_air(proof_options: &ProofOptions) -> ConcreteVmAir { build_air( hint_cols::NUM_COLUMNS, hint_bus_interactions(), proof_options, 1, - EmptyConstraints, + HintConstraints, "HINT", ) } diff --git a/prover/src/tests/constraint_program_device_tests.rs b/prover/src/tests/constraint_program_device_tests.rs index a2863b2f0..a29a7cb49 100644 --- a/prover/src/tests/constraint_program_device_tests.rs +++ b/prover/src/tests/constraint_program_device_tests.rs @@ -181,4 +181,5 @@ 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 3ae46494d..e227da53d 100644 --- a/prover/src/tests/constraint_program_tests.rs +++ b/prover/src/tests/constraint_program_tests.rs @@ -179,4 +179,5 @@ 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 0348c2b70..a7f68ecfd 100644 --- a/prover/src/tests/constraint_set_tests_b.rs +++ b/prover/src/tests/constraint_set_tests_b.rs @@ -299,3 +299,19 @@ 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/ood_window_ir_tests.rs b/prover/src/tests/ood_window_ir_tests.rs index b4ff5766c..29d224627 100644 --- a/prover/src/tests/ood_window_ir_tests.rs +++ b/prover/src/tests/ood_window_ir_tests.rs @@ -114,4 +114,5 @@ 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/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index 787b327db..5b9b62fed 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -1210,8 +1210,8 @@ fn test_prove_ecsm_rust_guest() { ); } -/// P0 for the non-constraining `Hint` ecall (BENCH ONLY): the minimal Rust guest -/// does one `hint` call (secp256k1 base-field inverse of 3) and commits the result. +/// 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 exactly the HINT table's bus surface (Ecall receive + the four /// 8-byte output MEMW writes) end-to-end through prove→verify, de-risking the bus /// balance before scaling to real consumers. The committed output must equal the @@ -1242,7 +1242,7 @@ fn test_prove_hint_min_rust_guest() { assert_eq!(proof.public_output, expected.to_vec()); } -/// Multi-hint P0/P2 (BENCH ONLY): three `hint` ecalls, each result read back with +/// Multi-hint: three `hint` ecalls, 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) and **read-back via normal LOAD** (MEMW reads chaining @@ -1279,7 +1279,7 @@ fn test_prove_hint_multi_rust_guest() { assert_eq!(proof.public_output, expected.to_vec()); } -/// Consistency (BENCH ONLY): the verifier REJECTS a HINT row that disagrees with the +/// 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 @@ -1399,6 +1399,20 @@ fn test_hint_binds_out_addr_to_x12() { "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!(reads[0].multiplicity, Multiplicity::Column(c) if c == hint_cols::MU), "the register read must be gated by mu, like every other HINT interaction" From 515a921d3fb04594345e78569b4a071f4dcde27e Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 30 Jul 2026 12:28:25 -0300 Subject: [PATCH 17/28] Drop BENCH-ONLY labels from the hint ecall --- prover/src/tables/cpu.rs | 6 +++--- prover/src/tables/trace_builder.rs | 10 +++++----- syscalls/src/syscalls.rs | 9 +++++---- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/prover/src/tables/cpu.rs b/prover/src/tables/cpu.rs index 0bb29aaf1..fc4c2f976 100644 --- a/prover/src/tables/cpu.rs +++ b/prover/src/tables/cpu.rs @@ -189,9 +189,9 @@ 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 (BENCH ONLY). The - /// hint operand addresses (x10/x11/x12) are recovered from the register state - /// in the trace builder, exactly like ECSM. + /// 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, } diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 0fed386fa..2856a9d4c 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -657,7 +657,7 @@ fn collect_ops_from_cpu( ecdas_ops.extend(ecdas_rows); } - // Collect Hint ecall operations (the 32-byte output write). BENCH ONLY. + // 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); @@ -959,7 +959,7 @@ fn collect_ecsm_ops( (memw_ops, ecsm_op, ecdas_ops) } -/// Collects the memory operations for a `Hint` ecall (BENCH ONLY). +/// 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 @@ -2816,7 +2816,7 @@ pub struct Traces { /// ECDAS double/add table (variable rows per ecall) pub ecdas: TraceTable, - /// HINT table (one row per non-constraining hint ecall). BENCH ONLY. + /// 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) @@ -2861,7 +2861,7 @@ struct CollectedOps { // EC scalar-multiplication accelerator chips. ecsm_ops: Vec, ecdas_ops: Vec, - // Non-constraining hint ecall (BENCH ONLY). + // Non-constraining hint ecall. hint_ops: Vec, } @@ -3468,7 +3468,7 @@ 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). BENCH ONLY. + // 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) = diff --git a/syscalls/src/syscalls.rs b/syscalls/src/syscalls.rs index 32176f791..e70294d80 100644 --- a/syscalls/src/syscalls.rs +++ b/syscalls/src/syscalls.rs @@ -33,7 +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 (BENCH ONLY). +/// Syscall number for the non-constraining Hint ecall. /// Must match `executor::...::execution::HINT_SYSCALL_NUMBER` (u64::MAX - 20). #[cfg(target_arch = "riscv64")] const HINT_SYSCALL_NUMBER: usize = usize::MAX - 20; @@ -197,13 +197,14 @@ pub fn ecsm_mul(_xr: &mut [u8; 32], _xg: &[u8; 32], _k: &[u8; 32]) { unimplemented!("syscalls are only implemented for riscv64 targets"); } -/// BENCH ONLY. Ask the host for a non-constraining hint (modular inverse/sqrt). +/// 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 UNVERIFIED — the caller MUST check it in-guest -/// (e.g. `x·inv == 1`), since this ecall adds no correctness constraint. +/// 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 { From da14fc8c7515ce0bb8866a39dd972d96c9534aa9 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 30 Jul 2026 12:49:35 -0300 Subject: [PATCH 18/28] Test that IS_BIT rejects a non-boolean HINT mu --- prover/src/tables/hint.rs | 20 ++++---- prover/src/tests/hint_tests.rs | 90 ++++++++++++++++++++++++++++++++++ prover/src/tests/mod.rs | 2 + 3 files changed, 102 insertions(+), 10 deletions(-) create mode 100644 prover/src/tests/hint_tests.rs diff --git a/prover/src/tables/hint.rs b/prover/src/tables/hint.rs index c82f7edc0..5712c109e 100644 --- a/prover/src/tables/hint.rs +++ b/prover/src/tables/hint.rs @@ -31,16 +31,16 @@ //! 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. //! -//! The table's only algebraic constraint is `mu·(1−mu) = 0` (`HintConstraints`): -//! `mu` is the multiplicity of every bus interaction, so it must be boolean. The -//! LogUp argument already pins its *value* — the `Ecall` receiver's tuple contains -//! the timestamp, which is unique per instruction (`ts = 4i + 4`), so the identity -//! matches each `(ts, syscall)` tuple on its own and forces `mu` to the CPU's send -//! multiplicity for that ecall (1 where the CPU issued one, 0 elsewhere; a nonzero -//! `mu` on a tuple the CPU never sent leaves the bus unbalanced). The boolean -//! constraint is nonetheless carried explicitly, matching every other -//! multiplicity-column table (ECSM/ECDAS/COMMIT/STORE/MEMW_R): a multiplicity column -//! is bit-constrained in-circuit rather than left to rest on a bus-balance argument. +//! `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 +//! two rows splitting `mu = 1/2 + 1/2` while each keeps its own `out_addr` (and +//! `out_addr` is the base the four output writes take). Such a witness is in fact +//! caught today, but only downstream and non-locally: MEMW boolean-constrains its own +//! multiplicities, so a half-weighted write finds no receiver. Constraining `mu` here +//! makes the argument local, independent of how MEMW happens to handle multiplicities. //! //! ## Columns (37) //! - `timestamp[0..1]` (DWordWL): the ecall timestamp `T` diff --git a/prover/src/tests/hint_tests.rs b/prover/src/tests/hint_tests.rs new file mode 100644 index 000000000..b2b17cbf5 --- /dev/null +++ b/prover/src/tests/hint_tests.rs @@ -0,0 +1,90 @@ +//! HINT constraint tests. + +use crate::tables::hint::{HintConstraints, HintOperation, cols, generate_hint_trace}; +use crate::tables::types::{FE, GoldilocksExtension, GoldilocksField}; +use math::field::element::FieldElement; +use stark::constraints::builder::{ConstraintSet, ProverEvalFolder}; +use stark::frame::Frame; +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), + } +} + +#[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 splitting `mu = 1/2 + 1/2` across two rows +/// that each keep their own `out_addr`. Such a witness is caught downstream too (MEMW +/// boolean-constrains its own multiplicities, so a half-weighted write finds no +/// receiver), but that protection lives in another table; this constraint makes it +/// local. +#[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" + ); +} diff --git a/prover/src/tests/mod.rs b/prover/src/tests/mod.rs index 2d66692a9..72090bc50 100644 --- a/prover/src/tests/mod.rs +++ b/prover/src/tests/mod.rs @@ -47,6 +47,8 @@ pub mod ecsm_tests; #[cfg(test)] pub mod eq_tests; #[cfg(test)] +pub mod hint_tests; +#[cfg(test)] pub mod keccak_rnd_tests; #[cfg(test)] pub mod load_tests; From c75121a680c338e402ee28a8a91ad56d7a316cc4 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Fri, 31 Jul 2026 15:40:12 -0300 Subject: [PATCH 19/28] Run ethrex-crypto host tests in CI --- .github/workflows/pr_main.yaml | 3 +++ Makefile | 9 +++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr_main.yaml b/.github/workflows/pr_main.yaml index a4554fda2..ad46ce289 100644 --- a/.github/workflows/pr_main.yaml +++ b/.github/workflows/pr_main.yaml @@ -134,6 +134,9 @@ jobs: - name: Run syscalls host tests (keccak differential vs sha3) run: make test-syscalls + - name: Run ethrex-crypto host tests (hint verify-then-fallback + ecrecover) + run: make test-ethrex-crypto + # "Test" is a required check — keep this name to avoid branch protection changes. # This gate job passes only when CLI, executor, disk-spill, and prover tests succeed. test: diff --git a/Makefile b/Makefile index 4abed4a90..b648157f3 100644 --- a/Makefile +++ b/Makefile @@ -90,7 +90,7 @@ ASM_LDFLAGS ?= -fuse-ld=lld -nostdlib -Wl,-e,main # Custom RV64IM target spec location RV64_TARGET_SPEC=$(CURDIR)/executor/programs/riscv64im-lambda-vm-elf.json -.PHONY: test prepare-sysroot +.PHONY: test test-syscalls test-ethrex-crypto prepare-sysroot # The guard checks for include/stdlib.h (not just the include/ dir) so that a PARTIAL # sysroot — directories present but missing the C standard library headers — is detected @@ -332,7 +332,12 @@ check-ethrex-fixture-checksums: test-syscalls: cd syscalls && cargo test -test: compile-programs test-syscalls +# ethrex-crypto is a detached workspace (excluded from the root members), so a +# root `cargo test` never runs it. Run it explicitly, like test-syscalls. +test-ethrex-crypto: + cd crypto/ethrex-crypto && cargo test + +test: compile-programs test-syscalls test-ethrex-crypto cargo test # === Quick test shortcuts === From 3b1a012fdf3a41318e3da745d2240244094fb8eb Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Fri, 31 Jul 2026 15:40:52 -0300 Subject: [PATCH 20/28] Add software fallback and test seam to field_inv --- crypto/ethrex-crypto/src/lib.rs | 55 ++++++++++++++------ crypto/ethrex-crypto/src/tests/hint_tests.rs | 46 +++++++++++++++- 2 files changed, 83 insertions(+), 18 deletions(-) diff --git a/crypto/ethrex-crypto/src/lib.rs b/crypto/ethrex-crypto/src/lib.rs index 3736179a9..702416ea6 100644 --- a/crypto/ethrex-crypto/src/lib.rs +++ b/crypto/ethrex-crypto/src/lib.rs @@ -332,27 +332,21 @@ fn ecsm_oracle(x: &FieldElement, k: &Scalar) -> Option { Option::from(FieldElement::from_bytes(&xr_le.into())) } -/// Base-field inverse `x⁻¹ mod p`. On riscv64 the host supplies it via the -/// `hint` ecall and we verify `x·inv == 1`; off-target it inverts in software. +/// 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. #[cfg(any(target_arch = "riscv64", test))] fn field_inv(x: &FieldElement) -> Option { #[cfg(target_arch = "riscv64")] { - let x_be: [u8; 32] = x.to_bytes().into(); - let inv_be = get_hint(lambda_vm_syscalls::syscalls::HINT_FIELD_INV, &x_be); - let inv: FieldElement = Option::from(FieldElement::from_bytes(&inv_be.into()))?; - // Verify the untrusted hint: x·inv must equal 1 (mod p). Compare by asking - // whether the difference normalizes to zero — a value-level test that skips - // the two full normalizations a `to_bytes()` compare pays. `ct_eq` is NOT a - // substitute: k256's FieldElement compares raw limbs *and* the magnitude and - // `normalized` tags, so a `mul` result (magnitude 1, unnormalized) never - // compares equal to the normalized `ONE` constant whatever its value. - // `Neg` is `negate(1)`, valid here because `mul` yields magnitude 1. - if bool::from((*x * inv - FieldElement::ONE).normalizes_to_zero()) { - Some(inv) - } else { - None - } + field_inv_with_oracle(x, |x_be| { + get_hint(lambda_vm_syscalls::syscalls::HINT_FIELD_INV, x_be) + }) } #[cfg(not(target_arch = "riscv64"))] { @@ -360,6 +354,33 @@ fn field_inv(x: &FieldElement) -> Option { } } +/// Core of [`field_inv`], generic over the hint source so host tests can inject an +/// honest or a lying oracle and assert the software fallback keeps the result +/// correct either way. See [`scalar_inv`] for the verify-then-fallback rationale. +#[cfg(any(target_arch = "riscv64", test))] +fn field_inv_with_oracle(x: &FieldElement, hint: O) -> Option +where + O: FnOnce(&[u8; 32]) -> [u8; 32], +{ + let x_be: [u8; 32] = x.to_bytes().into(); + let inv_be = hint(&x_be); + // Fast path: a canonical hint that verifies (x·inv == 1 mod p) is used as-is. + // Verify by asking whether the difference normalizes to zero — a value-level test + // that skips the two full normalizations a `to_bytes()` compare pays. `ct_eq` is + // NOT a substitute: k256's FieldElement compares raw limbs *and* the magnitude and + // `normalized` tags, so a `mul` result (magnitude 1, unnormalized) never compares + // equal to the normalized `ONE` constant whatever its value. + // `Neg` is `negate(1)`, valid here because `mul` yields magnitude 1. + if let Some(inv) = Option::::from(FieldElement::from_bytes(&inv_be.into())) { + if bool::from((*x * inv - FieldElement::ONE).normalizes_to_zero()) { + return Some(inv); + } + } + // Hint absent / malformed / wrong: recompute authoritatively. `None` only for a + // genuine `x = 0`, excluded by the callers' guards. + Option::from(x.invert()) +} + /// Computes `k1·P1 + k2·P2` from four x-only oracle queries, or `None` if any /// degenerate-configuration guard trips. /// diff --git a/crypto/ethrex-crypto/src/tests/hint_tests.rs b/crypto/ethrex-crypto/src/tests/hint_tests.rs index 023130e35..a7e992577 100644 --- a/crypto/ethrex-crypto/src/tests/hint_tests.rs +++ b/crypto/ethrex-crypto/src/tests/hint_tests.rs @@ -1,5 +1,5 @@ //! Host tests for the untrusted-hint verify-then-fallback paths (`scalar_inv`, -//! `decompress_r`). +//! `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 @@ -143,3 +143,47 @@ fn decompress_r_non_residue_is_none_regardless_of_hint() { ); } } + +/// Honest base-field inverse oracle (BE in/out, mod p) — mirrors the executor's +/// `compute_hint(HINT_FIELD_INV, ..)`: the inverse if it exists, else zeros. +fn honest_field_inv(x_be: &[u8; 32]) -> [u8; 32] { + let x = Option::::from(FieldElement::from_bytes(&(*x_be).into())) + .expect("canonical input"); + match Option::::from(x.invert()) { + Some(inv) => inv.to_bytes().into(), + None => [0u8; 32], + } +} + +#[test] +fn field_inv_honest_hint_matches_software() { + for k in [1u64, 2, 3, 7, 1000, 12345] { + let x = fe_from_u64(k); + let sw = Option::::from(x.invert()).expect("k != 0 is invertible"); + let got = field_inv_with_oracle(&x, honest_field_inv).expect("inverse exists"); + assert_eq!( + got.normalize().to_bytes(), + sw.normalize().to_bytes(), + "honest hint must equal the software inverse (k={k})" + ); + } +} + +#[test] +fn field_inv_lying_hint_falls_back_to_software() { + // A prover-chosen garbage inverse must not change the result: `x⁻¹` exists for + // every input the callers pass (guarded non-zero denominators), so the software + // fallback is authoritative — a lie can only cost work, never steer the outcome. + for lie in [[0u8; 32], [0xFFu8; 32]] { + for k in [1u64, 2, 12345] { + let x = fe_from_u64(k); + let sw = Option::::from(x.invert()).unwrap(); + let got = field_inv_with_oracle(&x, |_| lie).expect("fallback recomputes"); + assert_eq!( + got.normalize().to_bytes(), + sw.normalize().to_bytes(), + "lying hint must fall back to the software inverse (k={k})" + ); + } + } +} From 32f9cd143c83fc922d1c941517306b9b05aebb11 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Fri, 31 Jul 2026 15:41:04 -0300 Subject: [PATCH 21/28] GPU parity-check the HINT table --- prover/tests/gpu_constraint_interp_real.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/prover/tests/gpu_constraint_interp_real.rs b/prover/tests/gpu_constraint_interp_real.rs index 2cea4be1b..4446fb446 100644 --- a/prover/tests/gpu_constraint_interp_real.rs +++ b/prover/tests/gpu_constraint_interp_real.rs @@ -271,4 +271,5 @@ 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"); } From be9066bfdb426bf36d7f0afc1bea5d730e244c64 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Fri, 31 Jul 2026 15:41:46 -0300 Subject: [PATCH 22/28] Move HINT syscall off the FEXT_FMA numberD --- executor/src/vm/instruction/execution.rs | 2 +- prover/src/tables/hint.rs | 14 ++++++++------ syscalls/src/syscalls.rs | 4 ++-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/executor/src/vm/instruction/execution.rs b/executor/src/vm/instruction/execution.rs index 4a47d453d..647dd0079 100644 --- a/executor/src/vm/instruction/execution.rs +++ b/executor/src/vm/instruction/execution.rs @@ -41,7 +41,7 @@ pub const ECSM_SYSCALL_NUMBER: u64 = u64::MAX - 10; /// 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 - 20; +pub const HINT_SYSCALL_NUMBER: u64 = u64::MAX - 30; /// Hint operation selector passed in `a0`. pub const HINT_FIELD_INV: u64 = 0; // secp256k1 base-field inverse (mod p) diff --git a/prover/src/tables/hint.rs b/prover/src/tables/hint.rs index a56cddd62..c2dc7d8a2 100644 --- a/prover/src/tables/hint.rs +++ b/prover/src/tables/hint.rs @@ -1,6 +1,6 @@ //! HINT table — receiver for the non-constraining `hint` ecall. //! -//! The `hint` ecall (syscall `u64::MAX - 20`) lets the executor hand the guest a +//! 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* @@ -36,11 +36,13 @@ //! 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 -//! two rows splitting `mu = 1/2 + 1/2` while each keeps its own `out_addr` (and -//! `out_addr` is the base the four output writes take). Such a witness is in fact -//! caught today, but only downstream and non-locally: MEMW boolean-constrains its own -//! multiplicities, so a half-weighted write finds no receiver. Constraining `mu` here -//! makes the argument local, independent of how MEMW happens to handle multiplicities. +//! 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 (37) //! - `timestamp[0..1]` (DWordWL): the ecall timestamp `T` diff --git a/syscalls/src/syscalls.rs b/syscalls/src/syscalls.rs index e70294d80..5228455ea 100644 --- a/syscalls/src/syscalls.rs +++ b/syscalls/src/syscalls.rs @@ -34,9 +34,9 @@ const KECCAK_SYSCALL_NUMBER: usize = usize::MAX - 1; 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 - 20). +/// Must match `executor::...::execution::HINT_SYSCALL_NUMBER` (u64::MAX - 30). #[cfg(target_arch = "riscv64")] -const HINT_SYSCALL_NUMBER: usize = usize::MAX - 20; +const HINT_SYSCALL_NUMBER: usize = usize::MAX - 30; /// Hint selectors passed in `a0` (must match the executor's `HINT_*`). pub const HINT_FIELD_INV: usize = 0; From 7d0e1ba1e554ae4f960ef844c8a4bd5a08416526 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Fri, 31 Jul 2026 16:04:55 -0300 Subject: [PATCH 23/28] Test the affine ECSM operand ranges instead of their distance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The affine ecall rejected any k within 64 bytes of the input point, but the point is 64 bytes and the scalar 32, so a scalar sitting directly below the point is disjoint at a distance of 32 and was rejected anyway. Both callers build the two buffers as separate stack arrays, so whether the ecall worked at all depended on which one the guest compiler laid out first: a rustc or LTO change could turn ecrecover into a hard EcsmOperandOverlap, which has no software fallback. The guard now tests the two byte ranges, which is what trace provability actually requires — xG‖yG is read at T and k at T+1, so they only need to not touch. The doc comment claiming the distance assumption held by construction was true only for the x-only path, where both operands are 32 bytes. Adds executor coverage for the boundary case that used to fail, for a non-canonical yG and for the 64-byte address-limb span. --- crypto/ethrex-crypto/src/lib.rs | 11 +++- executor/src/tests/ecsm_tests.rs | 77 ++++++++++++++++++++++++ executor/src/vm/instruction/execution.rs | 8 ++- 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/crypto/ethrex-crypto/src/lib.rs b/crypto/ethrex-crypto/src/lib.rs index cf80312cb..14781650a 100644 --- a/crypto/ethrex-crypto/src/lib.rs +++ b/crypto/ethrex-crypto/src/lib.rs @@ -314,10 +314,15 @@ fn ecsm_lincomb2( /// no parity convention or sign flip, because the precompile receives the real `y` and the /// prover pins it by a memory read. `(x, y)` must be a curve point and `k` in `(0, N)`. /// Values cross the ABI as 32-byte little-endian; `input` is a 64-byte `[xG‖yG]` buffer, -/// `out` a 64-byte `[xR‖yR]` buffer, `k_le` a distinct 32-byte array (executor's -/// `|addr_input − addr_k| ≥ 64` disjointness assumption). +/// `out` a 64-byte `[xR‖yR]` buffer and `k_le` a distinct 32-byte array, so the two +/// operand ranges are disjoint as the executor requires (it tests the ranges, not their +/// distance, so either stack layout is fine). #[cfg(target_arch = "riscv64")] -fn ecsm_oracle(x: &FieldElement, y: &FieldElement, k: &Scalar) -> Option<(FieldElement, FieldElement)> { +fn ecsm_oracle( + x: &FieldElement, + y: &FieldElement, + k: &Scalar, +) -> Option<(FieldElement, FieldElement)> { let x_be = x.to_bytes(); let y_be = y.to_bytes(); let k_be = k.to_bytes(); diff --git a/executor/src/tests/ecsm_tests.rs b/executor/src/tests/ecsm_tests.rs index 230b4e32d..3a80abc08 100644 --- a/executor/src/tests/ecsm_tests.rs +++ b/executor/src/tests/ecsm_tests.rs @@ -189,6 +189,83 @@ fn ecsm_syscall_rejects_xg_not_on_curve() { )); } +#[test] +fn ecsm_affine_syscall_rejects_non_canonical_yg() { + // yG = p is a non-canonical zero. The prover reads yG straight out of the caller's + // buffer, so nothing downstream would reduce it — the executor must reject it. + let err = run_ecsm_affine(&k_le(5), &gx_le(), &ecsm::P_BYTES).unwrap_err(); + assert!(matches!( + err, + ExecutionError::Ecsm(ecsm::EcsmError::CoordinateOutOfRange) + )); +} + +#[test] +fn ecsm_affine_syscall_rejects_zero_scalar() { + let err = run_ecsm_affine(&k_le(0), &gx_le(), &gy_le()).unwrap_err(); + assert!(matches!( + err, + ExecutionError::Ecsm(ecsm::EcsmError::ScalarIsZero) + )); +} + +/// Runs the affine ECSM syscall with caller-chosen operand addresses, input point `G` +/// and `k = 5`. +fn run_ecsm_affine_at(addr_xr: u64, addr_xg: u64, addr_k: u64) -> Result<(), ExecutionError> { + let mut pc = 0; + let mut registers = Registers::default(); + let mut memory = Memory::default(); + write_u256_le(&mut memory, addr_xg, &gx_le()); + write_u256_le(&mut memory, addr_xg.wrapping_add(32), &gy_le()); + write_u256_le(&mut memory, addr_k, &k_le(5)); + registers.write(17, ECSM_AFFINE_SYSCALL_NUMBER).unwrap(); + registers.write(10, addr_xr).unwrap(); + registers.write(11, addr_xg).unwrap(); + registers.write(12, addr_k).unwrap(); + Instruction::EcallEbreak.run(&mut pc, &mut registers, &mut memory)?; + Ok(()) +} + +#[test] +fn ecsm_affine_syscall_rejects_overlapping_point_k() { + // The input point spans 64 bytes, so any k landing inside [xG, xG + 64) is read at + // both T and T+1 and makes the trace unprovable. + for addr_k in [0x2000u64, 0x2008, 0x2020, 0x2038] { + let err = run_ecsm_affine_at(0x1000, 0x2000, addr_k).unwrap_err(); + assert!( + matches!(err, ExecutionError::EcsmOperandOverlap), + "addr_k = {addr_k:#x} overlaps the 64-byte input point and must be rejected" + ); + } + // Disjoint is disjoint regardless of distance: k directly below the point is 32 bytes + // away, which a `|diff| >= 64` bound would have rejected. Guest stack layouts produce + // exactly this case, so it must run. + run_ecsm_affine_at(0x1000, 0x2000, 0x1FE0).expect("k immediately below the point must run"); + run_ecsm_affine_at(0x1000, 0x2000, 0x2040).expect("k immediately above the point must run"); + // The output may alias either operand: its accesses are at later timestamps. + run_ecsm_affine_at(0x2000, 0x2000, 0x3000).expect("xR aliasing the input point is allowed"); + run_ecsm_affine_at(0x3000, 0x2000, 0x3000).expect("xR aliasing k is allowed"); +} + +#[test] +fn ecsm_affine_syscall_rejects_address_overflow() { + // Point and output span offset 63 (not 31), so their last accessed byte must stay in + // the limb: 0xFFFF_FFE8 fits a 32-byte operand but not a 64-byte one. + for (addr_xr, addr_xg, addr_k) in [ + (0xFFFF_FFE8, 0x2000, 0x3000), + (0x1000, 0xFFFF_FFE8, 0x3000), + (0xFFFF_FFC8, 0x2000, 0x3000), + (0x1000, 0xFFFF_FFC8, 0x3000), + (0x1000, 0x2000, 0xFFFF_FFF0), + ] { + let err = run_ecsm_affine_at(addr_xr, addr_xg, addr_k).unwrap_err(); + assert!( + matches!(err, ExecutionError::EcsmAddressOverflow), + "expected address overflow for xR={addr_xr:#x}, point={addr_xg:#x}, k={addr_k:#x}" + ); + } +} + /// Runs the ECSM syscall with caller-chosen operand addresses, `xG = Gx` and `k = 5`. fn run_ecsm_at(addr_xr: u64, addr_xg: u64, addr_k: u64) -> Result<(), ExecutionError> { let mut pc = 0; diff --git a/executor/src/vm/instruction/execution.rs b/executor/src/vm/instruction/execution.rs index 82fb7dc76..6642e58d1 100644 --- a/executor/src/vm/instruction/execution.rs +++ b/executor/src/vm/instruction/execution.rs @@ -560,7 +560,13 @@ impl Instruction { // the MEMW consistency argument can't prove the chain. The guard is // about trace provability, not correctness. xR (output) may alias // either: its accesses are at a later timestamp. - if addr_xg.abs_diff(addr_k) < 64 { + // + // Exact range test rather than a distance bound: the two operands have + // different sizes, so a k placed just below the point (`addr_k + 32 == + // addr_xg`) is disjoint at a distance of 32. A `< 64` bound would reject + // it, making the ecall depend on which operand the guest's compiler + // happens to lay out first. + if addr_k < addr_xg.wrapping_add(64) && addr_xg < addr_k.wrapping_add(32) { return Err(ExecutionError::EcsmOperandOverlap); } let xg = load_u256_le(memory, addr_xg)?; From 58ad49f1a59c14dbe8fe685fcc68a96344abb58d Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Fri, 31 Jul 2026 16:05:04 -0300 Subject: [PATCH 24/28] Fix stale ECSM affine docs, counts and the new guest's lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The column-count comment in the ECSM chip still said 668 after yR_sub_p landed (684), and scalar_mul_affine's doc claimed its input is the even-y lift of xG, which is only true on the x-only path — the affine path passes the caller's own point, which is the whole point of the function. Drops the "PoC" labels left in production paths and puts the new syscall variant in discriminant order. The new guest's Cargo.lock was generated on a tree carrying the dlmalloc allocator, so it listed dlmalloc and critical-section as lambda-vm-syscalls dependencies that the crate does not declare; the guest build passes no --locked, so it silently rewrote the file on every run and dirtied the tree after every make compile-programs. Also formats crypto/ethrex-crypto, which no CI job checks because it is not a workspace member, and sizes the ECSM memw op vector for affine rows, which push eight more ops. --- crypto/ecsm/src/curve.rs | 8 ++--- crypto/ethrex-crypto/src/lib.rs | 8 ++--- crypto/ethrex-crypto/src/tests/ecsm_tests.rs | 29 ++++++++++++------- executor/programs/rust/ecsm_affine/Cargo.lock | 28 ------------------ executor/src/vm/instruction/execution.rs | 4 +-- prover/src/tables/ecsm.rs | 2 +- prover/src/tables/trace_builder.rs | 3 +- 7 files changed, 31 insertions(+), 51 deletions(-) diff --git a/crypto/ecsm/src/curve.rs b/crypto/ecsm/src/curve.rs index 6ed1a26f9..1ef5954c2 100644 --- a/crypto/ecsm/src/curve.rs +++ b/crypto/ecsm/src/curve.rs @@ -156,10 +156,10 @@ pub fn scalar_mul_affine_x(k: &BigUint, g: &AffinePoint) -> BigUint { scalar_mul_affine(k, g).x } -/// Executor fast path (affine PoC): the full affine point `k·g`. Same convention as -/// `scalar_mul_affine_x` / the witness — `g` is the even-`y` lift of its x-coordinate, -/// so `k·g`'s y matches the ECDAS-constrained `y_r`. Returns both coordinates so the -/// `ecsm_mul_affine` syscall can hand `y` back to the guest. +/// Executor fast path: the full affine point `k·g`, so the `ecsm_mul_affine` syscall can +/// hand `y` back to the guest. `g` is whatever point the caller prepared — the even-`y` lift +/// of `xG` on the x-only path, the caller's own input point on the affine one — and `k·g`'s +/// y matches the ECDAS-constrained `y_r` either way. pub fn scalar_mul_affine(k: &BigUint, g: &AffinePoint) -> AffinePoint { let scalar = Option::::from(Scalar::from_repr(be32(k).into())) .expect("ECSM: scalar k must be < N"); diff --git a/crypto/ethrex-crypto/src/lib.rs b/crypto/ethrex-crypto/src/lib.rs index 14781650a..3b578ba7f 100644 --- a/crypto/ethrex-crypto/src/lib.rs +++ b/crypto/ethrex-crypto/src/lib.rs @@ -285,10 +285,10 @@ fn ecsm_ecrecover(sig: &[u8; 64], recid: u8, msg: &[u8; 32]) -> Result<[u8; 64], /// ECSM-accelerated 2-term linear combination `k1·P1 + k2·P2`. /// -/// AFFINE PoC: on riscv64 this uses TWO affine ECSM queries (the precompile now -/// returns `(x, y)`, see [`lincomb2_with_oracle`]) instead of four x-only queries -/// plus chord-law y-reconstruction; on other targets, and whenever a guard trips, -/// it returns `None` so the caller uses the pure-Rust `ProjectivePoint::lincomb`. +/// On riscv64 this uses two affine ECSM queries (the precompile returns `(x, y)`, +/// see [`lincomb2_with_oracle`]) instead of four x-only queries plus chord-law +/// y-reconstruction; on other targets, and whenever a guard trips, it returns +/// `None` so the caller uses the pure-Rust `ProjectivePoint::lincomb`. #[cfg(target_arch = "riscv64")] fn ecsm_lincomb2( a1: &AffinePoint, diff --git a/crypto/ethrex-crypto/src/tests/ecsm_tests.rs b/crypto/ethrex-crypto/src/tests/ecsm_tests.rs index 04e3a28ce..f10259f50 100644 --- a/crypto/ethrex-crypto/src/tests/ecsm_tests.rs +++ b/crypto/ethrex-crypto/src/tests/ecsm_tests.rs @@ -1,13 +1,17 @@ -//! Tests for the x-only ECSM linear-combination reconstruction -//! (`lincomb2_with_oracle`) against the software `ProjectivePoint::lincomb`, -//! plus the degenerate-configuration fallback guards. +//! Tests for the ECSM linear combination (`lincomb2_with_oracle`) against the +//! software `ProjectivePoint::lincomb`, plus the degenerate-configuration +//! fallback guards. use crate::*; /// Software stand-in for the affine ECSM precompile: form the curve point `(x, y)` from /// the caller's actual coordinates and return `(xR, yR)` of `k·(x, y)`. No parity /// convention — the real ecall receives the full input point too. -fn soft_oracle(x: &FieldElement, y: &FieldElement, k: &Scalar) -> Option<(FieldElement, FieldElement)> { +fn soft_oracle( + x: &FieldElement, + y: &FieldElement, + k: &Scalar, +) -> Option<(FieldElement, FieldElement)> { let p = point_from_xy(&x.normalize(), &y.normalize())?; let prod = (p * k).to_affine(); let (xr, yr) = affine_xy(&prod)?; @@ -49,16 +53,19 @@ fn matches_software_lincomb_on_recovery_shape() { #[test] fn edge_scalars_fall_back() { - // AFFINE PoC: only k=0 falls back now. The old x-only path also rejected k=1 - // and k=n−1 (the (k+1)·P query wrapped); the affine oracle makes no such query, - // so those scalars reconstruct normally. + // Only k=0 falls back. The old x-only path also rejected k=1 and k=n−1 (the + // (k+1)·P query wrapped); the affine oracle makes no such query, so those + // scalars reconstruct normally. let p1 = g_times(3); let p2 = g_times(5); let ok = Scalar::from(12345u64); - for bad in [Scalar::ZERO] { - assert!(lincomb2_with_oracle(&p1.to_affine(), &bad, &p2.to_affine(), &ok, soft_oracle).is_none()); - assert!(lincomb2_with_oracle(&p1.to_affine(), &ok, &p2.to_affine(), &bad, soft_oracle).is_none()); - } + let bad = Scalar::ZERO; + assert!( + lincomb2_with_oracle(&p1.to_affine(), &bad, &p2.to_affine(), &ok, soft_oracle).is_none() + ); + assert!( + lincomb2_with_oracle(&p1.to_affine(), &ok, &p2.to_affine(), &bad, soft_oracle).is_none() + ); // k=1 and k=n−1 now reconstruct correctly. for good in [Scalar::ONE, -Scalar::ONE] { let expected = ProjectivePoint::lincomb(&p1, &good, &p2, &ok); diff --git a/executor/programs/rust/ecsm_affine/Cargo.lock b/executor/programs/rust/ecsm_affine/Cargo.lock index cc4741e6b..dc4399287 100644 --- a/executor/programs/rust/ecsm_affine/Cargo.lock +++ b/executor/programs/rust/ecsm_affine/Cargo.lock @@ -26,17 +26,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" -[[package]] -name = "dlmalloc" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad5208a115eaba24916f7456929832e310a81518c641f93fee4f89aa93aa3675" -dependencies = [ - "cfg-if", - "libc", - "windows-sys", -] - [[package]] name = "ecsm_affine" version = "0.1.0" @@ -89,8 +78,6 @@ dependencies = [ name = "lambda-vm-syscalls" version = "0.1.0" dependencies = [ - "critical-section", - "dlmalloc", "embedded-alloc", "getrandom 0.2.17", "getrandom 0.3.4", @@ -317,21 +304,6 @@ dependencies = [ "wit-bindgen", ] -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/executor/src/vm/instruction/execution.rs b/executor/src/vm/instruction/execution.rs index 6642e58d1..6253f6bcd 100644 --- a/executor/src/vm/instruction/execution.rs +++ b/executor/src/vm/instruction/execution.rs @@ -16,11 +16,11 @@ pub enum SyscallNumbers { Halt = 93, // Placeholder discriminant. The actual syscall value is ECSM_SYSCALL_NUMBER. Ecsm = 94, - // Placeholder discriminant. The actual syscall value is ECSM_AFFINE_SYSCALL_NUMBER. - EcsmAffine = 96, // Placeholder discriminant. The actual syscall value is HINT_SYSCALL_NUMBER. // Non-constraining hint (host computes modular inverse/sqrt, guest verifies). Hint = 95, + // Placeholder discriminant. The actual syscall value is ECSM_AFFINE_SYSCALL_NUMBER. + EcsmAffine = 96, } /// Syscall number for KeccakPermute (u64::MAX - 1 = 0xFFFF_FFFF_FFFF_FFFE). diff --git a/prover/src/tables/ecsm.rs b/prover/src/tables/ecsm.rs index fb6819783..7e73c540d 100644 --- a/prover/src/tables/ecsm.rs +++ b/prover/src/tables/ecsm.rs @@ -38,7 +38,7 @@ pub(crate) const CARRY_OFFSET_X2: i64 = 8160; pub(crate) const CARRY_OFFSET_YG: i64 = 16319; // ========================================================================= -// Column indices (668 columns; keep in sync with NUM_COLUMNS below) +// Column indices (684 columns; keep in sync with NUM_COLUMNS below) // ========================================================================= pub mod cols { diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 5080131d6..c9b1ed733 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -876,7 +876,8 @@ fn collect_ecsm_ops( .expect("ECSM witness: executor validates 0 < k < N and xG on curve") }; - let mut memw_ops = Vec::with_capacity(15); + // 15 ops on the x-only path; the affine path adds 4 yG reads and 4 yR writes. + let mut memw_ops = Vec::with_capacity(if is_affine { 23 } else { 15 }); // x11 -> addr_xG (register read at T), x12 -> addr_k (register read at T+1). { From 5aa7d0c2bc3d269fe749f86211c9d07cefe27a6f Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Fri, 31 Jul 2026 16:05:19 -0300 Subject: [PATCH 25/28] Cover the IS_AFFINE selector and the yR < p check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The affine selector and yR canonicality had no direct tests: op_for hardcoded is_affine = false, so IS_BIT(IS_AFFINE) and AffineZeroOnPadding were only reached through the end-to-end ELF test, and nothing exercised OverflowRequired(YrLtP) at all, even though it is what keeps the published yR canonical. Adds a mixed affine / x-only trace, the isolated selector checks, a yR = p case mirroring the existing xG one, and an end-to-end test that clears IS_AFFINE on the affine row. That last one confirms the Ecall-bus anchor empirically rather than by argument: the receiver's syscall word is xonly + IS_AFFINE·(affine − xonly) and the CPU sends the guest's real a7, so a row claiming the wrong variant leaves that bus and the two it gates unbalanced. --- prover/src/tests/ecsm_tests.rs | 124 ++++++++++++++++++++++++++- prover/src/tests/prove_elfs_tests.rs | 35 ++++++++ 2 files changed, 155 insertions(+), 4 deletions(-) diff --git a/prover/src/tests/ecsm_tests.rs b/prover/src/tests/ecsm_tests.rs index 1fe214455..823144cba 100644 --- a/prover/src/tests/ecsm_tests.rs +++ b/prover/src/tests/ecsm_tests.rs @@ -1,10 +1,11 @@ -//! Tests for the ECSM core table — constraint satisfaction on generated traces, -//! the single-source constraint count, and isolated negative checks for the -//! padding closure, the scalar-bit padding guard and the `xG < p` overflow. +//! Tests for the ECSM core table — constraint satisfaction on generated traces (x-only and +//! affine rows), the single-source constraint count, and isolated negative checks for the +//! padding closure, the scalar-bit padding guard, the `xG < p` / `yR < p` overflows and the +//! `IS_AFFINE` mode selector. use crate::tables::ecsm::{EcsmConstraints, EcsmOperation, cols, generate_ecsm_trace}; use crate::tables::types::{FE, GoldilocksExtension, GoldilocksField}; -use ecsm::{N_BYTES, P_BYTES, compute_witness}; +use ecsm::{N_BYTES, P_BYTES, compute_witness, compute_witness_with_y}; use math::field::element::FieldElement; use stark::constraints::builder::{ConstraintSet, ProverEvalFolder}; use stark::frame::Frame; @@ -19,6 +20,10 @@ const IDX_YG_CONV0: usize = 323; // ConvCarry(Yg, 0) const IDX_Q1_BIT32: usize = 388; // IS_BIT(q1[32]) const IDX_XG_CARRY0: usize = 389; // CarryBit(XgLtP, 0) const IDX_XG_OVERFLOW: usize = 396; // OverflowRequired(XgLtP) +const IDX_YR_CARRY0: usize = 413; // CarryBit(YrLtP, 0) +const IDX_YR_OVERFLOW: usize = 420; // OverflowRequired(YrLtP) +const IDX_IS_AFFINE_BIT: usize = 421; // IS_BIT(IS_AFFINE) +const IDX_AFFINE_PADDING: usize = 422; // AffineZeroOnPadding fn gx_le() -> [u8; 32] { // secp256k1 Gx, little-endian. @@ -37,6 +42,17 @@ fn k_le(v: u64) -> [u8; 32] { k } +fn gy_le() -> [u8; 32] { + // secp256k1 Gy, little-endian. + let mut be = [ + 0x48, 0x3A, 0xDA, 0x77, 0x26, 0xA3, 0xC4, 0x65, 0x5D, 0xA4, 0xFB, 0xFC, 0x0E, 0x11, 0x08, + 0xA8, 0xFD, 0x17, 0xB4, 0x48, 0xA6, 0x85, 0x54, 0x19, 0x9C, 0x47, 0xD0, 0x8F, 0xFB, 0x10, + 0xD4, 0xB8, + ]; + be.reverse(); + be +} + fn op_for(k: u64) -> EcsmOperation { let witness = compute_witness(&k_le(k), &gx_le()).unwrap(); EcsmOperation { @@ -49,6 +65,20 @@ fn op_for(k: u64) -> EcsmOperation { } } +/// Affine-variant row: `yG` comes from the caller's buffer instead of the even lift, and +/// `IS_AFFINE = 1`. +fn affine_op_for(k: u64) -> EcsmOperation { + let witness = compute_witness_with_y(&k_le(k), &gx_le(), &gy_le()).unwrap(); + EcsmOperation { + timestamp: 448, + addr_xg: 0x2000, + addr_k: 0x3000, + addr_xr: 0x1000, + is_affine: true, + witness, + } +} + /// Evaluate the ECSM [`ConstraintSet`] on a single main-trace row (the compiled /// prover folder path), returning every base-field constraint value. fn eval_main_row(main: Vec) -> Vec { @@ -98,6 +128,92 @@ fn constraint_set_count() { assert_eq!(EcsmConstraints.meta().len(), 423); } +/// One prover serves both ecall variants, so affine rows (`IS_AFFINE = 1`, `yG` read from +/// the caller's buffer) and x-only rows must satisfy the same constraint set in one trace. +/// Covers `IS_BIT(IS_AFFINE)` and `AffineZeroOnPadding` with the selector actually set. +#[test] +fn constraints_hold_on_mixed_affine_and_xonly_trace() { + let ops = vec![ + affine_op_for(1), + op_for(2), + affine_op_for(0xFFFF), + op_for(1_000_003), + ]; + let trace = generate_ecsm_trace(&ops); + assert_eq!( + *trace.main_table.get(0, cols::IS_AFFINE), + FE::one(), + "row 0 is an affine row" + ); + assert_eq!( + *trace.main_table.get(1, cols::IS_AFFINE), + FE::zero(), + "row 1 is an x-only row" + ); + + for row in 0..trace.num_rows() { + for (i, v) in eval_row(&trace, row).iter().enumerate() { + assert_eq!(*v, FE::zero(), "constraint {i} must hold at row {row}"); + } + } +} + +/// `IS_AFFINE` must be a bit, and zero on padding — otherwise a witness could fire the +/// affine-gated yG-read / yR-write MEMW buses on rows that never ran an affine ecall. +#[test] +fn affine_selector_must_be_a_bit_and_zero_on_padding() { + let row_with = |mu: u64, is_affine: u64| { + let mut main = vec![FE::zero(); cols::NUM_COLUMNS]; + main[cols::MU] = FE::from(mu); + main[cols::IS_AFFINE] = FE::from(is_affine); + eval_main_row(main) + }; + + assert_ne!( + row_with(1, 2)[IDX_IS_AFFINE_BIT], + FE::zero(), + "IS_BIT must fire for a non-boolean IS_AFFINE" + ); + assert_ne!( + row_with(0, 1)[IDX_AFFINE_PADDING], + FE::zero(), + "AffineZeroOnPadding must fire for IS_AFFINE = 1 on a padding row" + ); + for is_affine in [0, 1] { + let row = row_with(1, is_affine); + assert_eq!(row[IDX_IS_AFFINE_BIT], FE::zero()); + assert_eq!(row[IDX_AFFINE_PADDING], FE::zero()); + } +} + +/// OverflowRequired for YrLtP fires when yR = p, the check that keeps the published `yR` +/// canonical (the byte range checks alone only bound it below 2^256, and the quotient +/// columns would absorb the extra multiple of p). Mirrors the xG case: all CarryBit +/// constraints still hold, but the chain never reaches c_7 = 1. +#[test] +fn yr_ge_p_overflow_required_fires() { + let mut main = vec![FE::zero(); cols::NUM_COLUMNS]; + main[cols::MU] = FE::one(); + // yR = p, yr_sub_p = 0 (invalid subtraction witness — fine for this isolation test). + for (i, &b) in P_BYTES.iter().enumerate() { + main[cols::YR + i] = FE::from(b as u64); + } + let row = eval_main_row(main); + + for i in 0..7 { + assert_eq!( + row[IDX_YR_CARRY0 + i], + FE::zero(), + "carry bit {i}: c_i=0 is a valid bit" + ); + } + assert_ne!( + row[IDX_YR_OVERFLOW], + FE::zero(), + "OverflowRequired must fire when yR = p" + ); +} + /// The yG carry recurrence closes on all-zero padding because both the `µ·p²` offset and the /// curve constant `µ·b` are multiplied by `µ`, so they vanish when `µ = 0`. This checks the /// closing argument (Yg limb-0 ConvCarry = constraint `IDX_YG_CONV0`) and its two ingredients. diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index ebeceadaf..95f34448b 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -1603,6 +1603,41 @@ fn test_prove_ecsm_forged_yr_rejected() { ); } +/// Soundness: `IS_AFFINE` cannot be forged. It is what gates the yG-read and yR-write MEMW +/// buses, and it is pinned by the `Ecall` receiver, whose syscall word is +/// `xonly + IS_AFFINE·(affine − xonly)` — the CPU sends the guest's real `a7`, so clearing +/// the selector on a row that ran the affine ecall leaves that bus (and the two it gates) +/// unbalanced. +#[test] +fn test_prove_ecsm_forged_is_affine_rejected() { + use crate::tables::ecsm::cols as ecsm_cols; + + let _ = env_logger::builder().is_test(true).try_init(); + + let elf_bytes = ecsm_affine_elf_bytes(); + 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 result = executor.run().expect("Failed to run program"); + let mut traces = + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); + + assert_eq!( + *traces.ecsm.main_table.get(0, ecsm_cols::IS_AFFINE), + FieldElement::::one(), + "sanity: the ecsm_affine guest produces an affine row" + ); + traces + .ecsm + .main_table + .set(0, ecsm_cols::IS_AFFINE, FieldElement::zero()); + + assert!( + !prove_and_verify_vm_minimal(&elf, &mut traces), + "Verifier must reject an ECSM row that claims the wrong ecall variant" + ); +} + /// Verifier REJECTS a forged trace where an addr byte cell is set to a /// non-byte field element. /// From 9ce5c1b6fd901da35e16514178efd727b1394a46 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Fri, 31 Jul 2026 16:29:19 -0300 Subject: [PATCH 26/28] Widen the ECSM error wording to the affine path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NotOnCurve and CoordinateOutOfRange both predate the affine ecall and named only xG: "ECSM xG is not a valid curve x-coordinate" and "ECSM xG must be < p". The affine path returns the same two variants for a caller-supplied yG that fails yG² ≡ xG³ + b or is >= p, so the messages pointed at the wrong operand. Rewords them and the variant docs to cover both entry points. Also hoists the modulus in prepare_with_y: it was rebuilt from P_BYTES five times per call (two range checks plus three reductions in the on-curve test). One BigUint either way against the scalar multiplication that follows, so this is for the reader, not the clock. --- crypto/ecsm/src/lib.rs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/crypto/ecsm/src/lib.rs b/crypto/ecsm/src/lib.rs index 4e8c09ac2..d69340e5c 100644 --- a/crypto/ecsm/src/lib.rs +++ b/crypto/ecsm/src/lib.rs @@ -65,11 +65,14 @@ pub enum EcsmError { ScalarIsZero, /// `k >= N`: outside the valid scalar range `[1, N)`. ScalarOutOfRange, - /// `x^3 + b` is not a quadratic residue, so `xG` is not a valid x-coordinate. + /// The input point is not on the curve: on the x-only path `x³ + b` is not a quadratic + /// residue, so `xG` is not a valid x-coordinate; on the affine path the caller's own + /// `yG` fails `yG² ≡ xG³ + b`. NotOnCurve, - /// `xG >= p`: not a canonical field element. Reducing it silently would - /// diverge from the prover, whose `xR < p` range check makes a non-canonical - /// input unprovable (with `k = 1` the input is echoed back as `xR`). + /// A coordinate is `>= p`, so it is not a canonical field element — `xG` on either path, + /// `yG` on the affine one. Reducing it silently would diverge from the prover, whose + /// `xR < p` / `yR < p` range checks make a non-canonical input unprovable (with `k = 1` + /// the x-only input is echoed back as `xR`). CoordinateOutOfRange, } @@ -78,8 +81,8 @@ impl core::fmt::Display for EcsmError { match self { EcsmError::ScalarIsZero => write!(f, "ECSM scalar k must be non-zero"), EcsmError::ScalarOutOfRange => write!(f, "ECSM scalar k must be < N"), - EcsmError::NotOnCurve => write!(f, "ECSM xG is not a valid curve x-coordinate"), - EcsmError::CoordinateOutOfRange => write!(f, "ECSM xG must be < p"), + EcsmError::NotOnCurve => write!(f, "ECSM input point is not on the curve"), + EcsmError::CoordinateOutOfRange => write!(f, "ECSM coordinates must be < p"), } } } @@ -136,14 +139,15 @@ pub(crate) fn prepare_with_y( if k >= n() { return Err(EcsmError::ScalarOutOfRange); } + let p = p(); let xg = BigUint::from_bytes_le(xg_le); let yg = BigUint::from_bytes_le(yg_le); - if xg >= p() || yg >= p() { + if xg >= p || yg >= p { return Err(EcsmError::CoordinateOutOfRange); } // On-curve: yG² ≡ xG³ + b (mod p). - let lhs = (&yg * &yg) % p(); - let rhs = (&xg * &xg % p() * &xg + BigUint::from(B)) % p(); + let lhs = (&yg * &yg) % &p; + let rhs = (&xg * &xg % &p * &xg + BigUint::from(B)) % &p; if lhs != rhs { return Err(EcsmError::NotOnCurve); } From c2283bfb02af1a948262ba0240350f82dd264a66 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Fri, 31 Jul 2026 16:54:44 -0300 Subject: [PATCH 27/28] Bind and range-check the HINT ecall operands --- prover/src/tables/hint.rs | 100 ++++++++++++++++-- prover/src/tables/trace_builder.rs | 58 +++++++--- .../tests/count_table_lengths_drift_tests.rs | 47 ++++++-- prover/src/tests/hint_tests.rs | 12 ++- prover/src/tests/prove_elfs_tests.rs | 73 ++++++++++++- 5 files changed, 255 insertions(+), 35 deletions(-) diff --git a/prover/src/tables/hint.rs b/prover/src/tables/hint.rs index c2dc7d8a2..8b8877d05 100644 --- a/prover/src/tables/hint.rs +++ b/prover/src/tables/hint.rs @@ -44,11 +44,14 @@ //! `IS_BIT` on `mu` here is therefore load-bearing -- not a redundant restatement of //! a check some other table performs. //! -//! ## Columns (37) +//! ## 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 use executor::vm::instruction::execution::HINT_SYSCALL_NUMBER; use stark::constraints::builder::{ConstraintBuilder, ConstraintSet}; @@ -57,7 +60,17 @@ use stark::trace::TraceTable; use crate::constraints::templates::emit_is_bit; -use super::types::{BusId, FE, GoldilocksExtension, GoldilocksField, VmTable}; +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). The executor rejects anything else up front, so the +/// AIR range-checks `selector < 3` to accept exactly the same set. +pub const HINT_SELECTOR_BOUND: u64 = 3; + +/// Bound the low 32-bit limb of `in_addr` must stay under so the ecall's 32-byte +/// input range (`+0..+31`) cannot straddle the 2^32 limb boundary. Mirrors the +/// executor's `addr_limb_ok(in_addr, 31)`: `(in_addr % 2^32) + 31 < 2^32`. +pub const HINT_IN_ADDR_LIMB_BOUND: u64 = (1 << 32) - 31; pub mod cols { /// timestamp[0]: lower 32 bits of the ecall timestamp @@ -72,8 +85,16 @@ pub mod cols { 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 = 37; + pub const NUM_COLUMNS: usize = 41; /// Column of output byte `i` (0..32). #[inline] @@ -89,6 +110,10 @@ 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), @@ -114,6 +139,8 @@ pub fn generate_hint_trace( 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()); } @@ -186,12 +213,17 @@ fn memw_register_read(reg: u64, lo_col: usize, hi_col: usize) -> Vec { /// 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. /// -/// `a0` (the selector) and `a1` (the input address) are deliberately not bound: the -/// table constrains nothing about the value, and the input read is not modelled, so -/// neither reaches the memory argument. `a2` is the only operand that does. +/// - **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`, ×2): assert `selector < 3` and that `in_addr`'s +/// low limb `< 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(22); + let mut out = Vec::with_capacity(26); // ECALL receiver: [ts_lo, ts_hi, syscall_lo32, syscall_hi32]. out.push(BusInteraction::receiver( @@ -212,6 +244,60 @@ pub fn bus_interactions() -> Vec { 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 low limb < 2^32 - 31, matching addr_limb_ok(in_addr, 31). The lhs high + // word is a literal 0, so only ADDR_IN_0 (the low limb) is compared — exactly the + // executor's check, which ignores the high limb. + out.push(BusInteraction::sender( + BusId::Alu, + mu(), + vec![ + packed(cols::ADDR_IN_0), + BusValue::constant(0), + BusValue::constant(HINT_IN_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![ diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index a3d5808ff..ef6ade6d7 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -982,22 +982,21 @@ fn collect_hint_ops( let in_addr = register_state.read(11).0; let out_addr = register_state.read(12).0; - let mut memw_ops = Vec::with_capacity(5); - - // Read x12 (out_addr) at ts. This is what ties the write addresses below to the - // ecall's a2: they are emitted from a HINT trace column, and only this memory- - // argument access pins that column to the register the CPU actually held. - // x10/x11 are not emitted on purpose — neither reaches the memory argument (the - // value is unconstrained by design and the input read is not modelled), so an - // access for them would be dead weight. See `tables::hint`. - { - let reg_value = pack_register_value(out_addr); - let (_old_val, old_ts) = register_state.read(12); + 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 * 12, reg_value, t, 2, true) + 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(12, out_addr, t); + register_state.write(reg, value, t); } // Read the 32-byte big-endian input from the replayed memory. @@ -1029,6 +1028,8 @@ fn collect_hint_ops( timestamp: t, out_addr, out_bytes, + hint_id, + in_addr, }; (memw_ops, hint_op) } @@ -3174,6 +3175,19 @@ 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 in_addr's low limb < 2^32 - 31 (matching the + // executor's HintUnknownSelector / HintAddressOverflow rejections). Two 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_IN_ADDR_LIMB_BOUND, + false, + ), + ] + })); // ===================================================================== // PHASE 4: All → Bitwise lookups @@ -3891,6 +3905,24 @@ 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 two LT + // range-checks (selector < 3, in_addr low limb). 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 += 2; + } + // CPU-side per-instruction-kind counters (non-word; word → CPU32, B5b) let f = &cpu_op.decode.fields; if !f.word_instr && f.is_lt() { diff --git a/prover/src/tests/count_table_lengths_drift_tests.rs b/prover/src/tests/count_table_lengths_drift_tests.rs index 6855fcb5b..7337f0790 100644 --- a/prover/src/tests/count_table_lengths_drift_tests.rs +++ b/prover/src/tests/count_table_lengths_drift_tests.rs @@ -3,16 +3,17 @@ 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; -#[test] -fn count_table_lengths_matches_traces() { - let (elf, logs, _) = run_asm_elf("fib_iterative_372k"); +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"); - let traces = Traces::from_elf_and_logs_minimal(&elf, &logs, &max_rows, &[]) - .expect("trace build 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"); let sum_heights = |tables: &[stark::trace::TraceTable<_, _>]| -> u64 { tables.iter().map(|t| t.main_table.height as u64).sum() @@ -91,3 +92,37 @@ fn count_table_lengths_matches_traces() { // Mirrors hardcoded `halt_rows = 1` in `auto_storage::table_specs`. assert_eq!(traces.halt.main_table.height, 1, "halt_rows"); } + +#[test] +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 index b2b17cbf5..02f7b3761 100644 --- a/prover/src/tests/hint_tests.rs +++ b/prover/src/tests/hint_tests.rs @@ -31,6 +31,8 @@ fn op(timestamp: u64, out_addr: u64) -> HintOperation { timestamp, out_addr, out_bytes: std::array::from_fn(|i| i as u8), + hint_id: 0, + in_addr: 0x3000, } } @@ -58,11 +60,11 @@ fn constraints_hold_on_generated_trace() { /// /// 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 splitting `mu = 1/2 + 1/2` across two rows -/// that each keep their own `out_addr`. Such a witness is caught downstream too (MEMW -/// boolean-constrains its own multiplicities, so a half-weighted write finds no -/// receiver), but that protection lives in another table; this constraint makes it -/// local. +/// 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)]); diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index 5b9b62fed..c559014a3 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -1325,6 +1325,65 @@ fn test_prove_hint_min_inconsistent_output_rejected() { ); } +/// 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)); + 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 { @@ -1368,10 +1427,16 @@ fn test_hint_binds_out_addr_to_x12() { .collect(); assert_eq!( reads.len(), - 1, - "HINT must send exactly one MEMW register read (out_addr → x12)" + 3, + "HINT must send three MEMW register reads (a0 → x10, a1 → x11, a2 → x12)" ); - let v = &reads[0].values; + // 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. @@ -1414,7 +1479,7 @@ fn test_hint_binds_out_addr_to_x12() { "ts_hi must be the ecall timestamp (the read must occur at T)" ); assert!( - matches!(reads[0].multiplicity, Multiplicity::Column(c) if c == hint_cols::MU), + 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" ); } From 64a22ff9d97738044ab3fc62776712bcee15559a Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Fri, 31 Jul 2026 16:56:16 -0300 Subject: [PATCH 28/28] lint --- prover/src/tests/prove_elfs_tests.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index c559014a3..c120650f5 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -1355,10 +1355,11 @@ fn hint_min_traces() -> (Elf, Traces) { 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)); + traces.hint.main_table.set( + 0, + hint_cols::SEL_0, + FieldElement::::from(3u64), + ); assert!( !prove_and_verify_vm_minimal(&elf, &mut traces), "Verifier must reject a hint with an out-of-range selector"