From 2f30acf9bbd2914f682ba6b90a0b22b72dd4b27c Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 28 Jul 2026 17:48:24 -0300 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 7d0e1ba1e554ae4f960ef844c8a4bd5a08416526 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Fri, 31 Jul 2026 16:04:55 -0300 Subject: [PATCH 6/9] 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 7/9] 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 8/9] 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 9/9] 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); }