From f7143568cac4cdf6cf29a2492304ee0cbdfb2dea Mon Sep 17 00:00:00 2001 From: diegokingston Date: Wed, 19 Aug 2026 16:07:34 -0300 Subject: [PATCH 1/2] perf(ecsm): echo yR and yG, halving the ecalls per ecrecover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ecrecover needs the full public key (the address is keccak(X‖Y)), but the ECSM ecall returned only xR. With an x-only k·P oracle that costs FOUR ecalls: one x(k·P) query carries no information about the sign of y, since x(k·P) = x(k·(−P)), so each of the two lincomb terms needs a second query at a shifted scalar to pin it (`solve_y`). Hinting does not help — verifying a sign costs exactly the scalar multiplication it would save. The chip already computes yR (it arrives on the ECDAS bus) and witnesses yG (the yG convolution proves it). Writing both back drops ecrecover to two ecalls, so ~1533 ECDAS rows per signature become ~768 (measured over 2000 random scalars: 383 rows per ecall). yG is echoed, not consumed. The chip may witness EITHER root of xG — the AIR binds only yG² ≡ xG³ + b — so yR alone would be ambiguous: it is ±y(k·P) for the caller's own point. Returning the root used lets the guest resolve it with one comparison, which keeps the root a free choice exactly as spec/ecsm.typ's "Two options for y_G" aside argues, while still handing back a usable y. The alternative (feeding yG IN, as PR #879 does) has to pin the root by a memory read instead, which invalidates that aside, widens the input ABI to 64 bytes and adds an on-curve validation path to the executor. Costs no column: ECSM stays at 667, gaining 8 bus interactions (579 -> 587), i.e. +4 LogUp aux columns. The guest's yR < p check moves from the AIR to the caller's FieldElement::from_bytes, which is free (a CtOption that rejects >= p) and is also what pins yG to a true root: p is odd, so y and p − y differ in parity, but y + p — a second 256-bit representative when y < 2^256 − p ≈ 2^32, and constructible by choosing r — carries the opposite parity. Guest side: solve_y and scalar_near_edge are gone, three batched inversions become one, and k = 1 / k = N−1 stop being degenerate. Operand buffers are now 8-byte aligned so the twelve doubleword accesses take MEMW_A (29 columns + 1 range check) instead of the general path (49 + 8) — the same fix get_hint already carries. Executor: the output buffer is 96 bytes, so its address bound moves from +31 to +95. Reads stay at T and T+1 and writes at T+2 (xR) and T+3 (yR, yG), the free fourth sub-timestamp, so aliasing the output over either input keeps every per-address chain monotone. Verified locally: ECSM AIR constraints hold on generated traces (including k = N−1 and the forged-row rejections), the executor writes and bounds all 96 bytes, and the guest reconstruction matches ProjectivePoint::lincomb under BOTH witnessed roots (an implementation without the fix-up passes the even-root test and fails the odd-root one). The prover suite is unchanged at 390 passed / 167 failed, byte-identical to main: the failures are missing guest ELF artifacts, which this machine cannot build (Apple clang has no RISC-V target). The end-to-end ELF tests and the row-count measurement still need CI / the server. spec/main needs the matching edit: yR and yG become outputs, the write_xR group gains two MEMW groups, and the "Two options for y_G" aside moves from "only x is output" to "the guest resolves the root from the echoed yG". --- crypto/ecsm/src/curve.rs | 11 +- crypto/ecsm/src/lib.rs | 25 ++- crypto/ethrex-crypto/src/lib.rs | 188 ++++++++--------- crypto/ethrex-crypto/src/tests/ecsm_tests.rs | 202 +++++++++++++------ executor/programs/asm/test_ecsm.s | 9 +- executor/programs/asm/test_ecsm_multi.s | 7 +- executor/programs/asm/test_ecsm_split.s | 6 +- executor/programs/bench/ecsm/src/main.rs | 11 +- executor/programs/rust/ecsm/src/main.rs | 10 +- executor/src/tests/ecsm_tests.rs | 78 +++++++ executor/src/vm/instruction/execution.rs | 16 +- prover/src/tables/ecsm.rs | 61 ++++-- prover/src/tables/trace_builder.rs | 38 ++-- syscalls/src/syscalls.rs | 28 ++- 14 files changed, 463 insertions(+), 227 deletions(-) diff --git a/crypto/ecsm/src/curve.rs b/crypto/ecsm/src/curve.rs index c5c9f5714..f1499dd32 100644 --- a/crypto/ecsm/src/curve.rs +++ b/crypto/ecsm/src/curve.rs @@ -149,15 +149,20 @@ fn schedule(k: &BigUint) -> Vec<(u8, u8, u8)> { sched } -/// Executor fast path: the x-coordinate of `k·g`, via k256's optimized scalar +/// Executor fast path: `k·g` in affine coordinates, via k256's optimized scalar /// 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 { +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) +} + +/// The x-coordinate of `k·g`. Thin wrapper over [`scalar_mul_affine`]. +pub fn scalar_mul_affine_x(k: &BigUint, g: &AffinePoint) -> BigUint { + scalar_mul_affine(k, g).x } /// 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..e27bbd6e6 100644 --- a/crypto/ecsm/src/lib.rs +++ b/crypto/ecsm/src/lib.rs @@ -120,9 +120,28 @@ pub(crate) fn prepare( } /// 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`. +/// 32-byte values. pub fn scalar_mul_x(k_le: &[u8; 32], xg_le: &[u8; 32]) -> Result<[u8; 32], EcsmError> { + Ok(scalar_mul_full(k_le, xg_le)?.0) +} + +/// The ECSM ecall's memory image: `(xR, yR, yG)`, three little-endian 32-byte values written +/// back as one contiguous 96-byte buffer. +pub type EcsmOutput = ([u8; 32], [u8; 32], [u8; 32]); + +/// The executor's entry point: `(xR, yR, yG)` as little-endian 32-byte values, written back +/// to guest memory as one contiguous 96-byte buffer at `addr_xR`. +/// +/// `yG` is echoed because the chip is free to witness *either* root of `xG` — the AIR only +/// binds `yG² ≡ xG³ + b`, so nothing pins the sign (see `spec/ecsm.typ`, "Two options for +/// `y_G`"). Returning `yR` alone would therefore be ambiguous: it is the y of `k·(xG, yG)` +/// for whichever root the prover chose, which is `±y(k·P)` for the caller's own point `P`. +/// Echoing `yG` resolves it caller-side at no cost: the caller checks `yG < p` (free — it +/// is the field-element parse) and compares `yG`'s parity against its own base point's, so +/// a flipped root just flips the sign it applies to `yR`. That keeps the root a free choice +/// for the prover, exactly as the spec's aside argues, while still handing back a usable y. +pub fn scalar_mul_full(k_le: &[u8; 32], xg_le: &[u8; 32]) -> Result { let (k, g) = prepare(k_le, xg_le)?; - Ok(to_le_32(&curve::scalar_mul_affine_x(&k, &g))) + let r = curve::scalar_mul_affine(&k, &g); + Ok((to_le_32(&r.x), to_le_32(&r.y), to_le_32(&g.y))) } diff --git a/crypto/ethrex-crypto/src/lib.rs b/crypto/ethrex-crypto/src/lib.rs index ec36b0831..7a4632d1b 100644 --- a/crypto/ethrex-crypto/src/lib.rs +++ b/crypto/ethrex-crypto/src/lib.rs @@ -9,9 +9,10 @@ //! - `keccak256`: a sponge over the `keccak_permute` precompile (riscv64; on //! host it falls back to software keccak for tests). //! - `secp256k1_ecrecover`: the ECDSA recovery's 2-term linear combination is -//! evaluated through the ECSM `ecsm_mul` precompile (riscv64), reconstructing -//! the full point from x-only queries; on host / degenerate inputs it falls -//! back to the pure-Rust `ProjectivePoint::lincomb`. +//! evaluated through the ECSM `ecsm_mul` precompile (riscv64), which returns +//! each `k·P` in full together with the base-point root it used — so the two +//! products cost one query each and are combined with a single chord addition. +//! On host / degenerate inputs it falls back to `ProjectivePoint::lincomb`. //! //! Every other `Crypto` method inherits the trait default (vetted pure-Rust //! crates: `ark-bn254`, `bls12_381`, `p256`, `sha2`, `ripemd`, …). @@ -29,7 +30,7 @@ use k256::elliptic_curve::sec1::ToEncodedPoint; use k256::elliptic_curve::PrimeField; use k256::{AffinePoint, FieldBytes, ProjectivePoint, Scalar, U256}; -// Used only by the x-only point reconstruction (riscv accelerated path + the +// Used only by the point reconstruction (riscv accelerated path + the // host unit tests); unused on a non-test host build. #[cfg(any(target_arch = "riscv64", test))] use k256::elliptic_curve::sec1::FromEncodedPoint; @@ -75,17 +76,19 @@ impl Crypto for LambdaVmEcsmCrypto { /// [`scalar_inv`] / [`decompress_r`] for the fallback that closes that hole. #[cfg(target_arch = "riscv64")] fn get_hint(hint_id: usize, x_be: &[u8; 32]) -> [u8; 32] { - // 8-byte-aligned output buffer so the HINT table's four 8-byte writes land on the - // aligned memory path (MEMW_A) instead of the general MEMW path. An `[u8; 32]` on - // the stack is only 1-aligned, which forces the four writes onto the unaligned - // path and inflates the trace. - #[repr(C, align(8))] - struct Aligned32([u8; 32]); - let mut out = Aligned32([0u8; 32]); + let mut out = Align8([0u8; 32]); lambda_vm_syscalls::syscalls::hint(hint_id, &mut out.0, x_be); out.0 } +/// 8-byte-aligned wrapper for an ecall operand buffer, so the table's 8-byte accesses land +/// on the aligned memory path (MEMW_A, 29 columns + 1 range check) instead of the general +/// one (49 + 8). A bare `[u8; N]` on the stack is only 1-aligned, which forces every access +/// onto the unaligned path and inflates the trace. +#[cfg(target_arch = "riscv64")] +#[repr(C, align(8))] +struct Align8([u8; N]); + /// Scalar-field inverse `x⁻¹ mod n`. /// /// On riscv64 the inverse is first requested from the untrusted `hint` ecall and @@ -288,10 +291,11 @@ 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`. +/// On riscv64 this uses two ECSM queries (the precompile returns the full `k·P` plus +/// the root it used, see [`lincomb2_with_oracle`]) instead of four x-only queries plus +/// chord-law y-reconstruction; on other targets, and whenever a guard trips (degenerate +/// input or an unusable oracle result), it returns `None` so the caller uses the +/// pure-Rust `ProjectivePoint::lincomb`. #[cfg(target_arch = "riscv64")] fn ecsm_lincomb2( a1: &AffinePoint, @@ -312,27 +316,41 @@ 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. +/// Scalar-mul oracle backed by the ECSM precompile: for the curve point `P` whose +/// x-coordinate is passed in, returns `(x(k·P̂), y(k·P̂), ŷ)`, where `P̂ = (x, ŷ)` is the root +/// of `x` the chip actually witnessed. The chip is free to pick either root — the AIR binds +/// only `ŷ² ≡ x³ + b` — so the caller resolves the sign from `ŷ` (see +/// [`lincomb2_with_oracle`]). `x` must be the x-coordinate of a curve point and `k` in +/// `(0, N)` (N = curve order), guaranteed by the guards there. +/// +/// 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. +/// +/// `None` on any coordinate that is not a canonical field element. That parse is load-bearing +/// for `ŷ`, not just hygiene: `p` is odd, so `y` and `p − y` differ in parity, but a value +/// `y + p` (a second 256-bit representative of `y`, possible when `y < 2^256 − p ≈ 2^32`) +/// would carry the *opposite* parity. Rejecting `≥ p` here is what pins `ŷ` to exactly one +/// of the two true roots, and it costs nothing — it is the field-element parse. #[cfg(target_arch = "riscv64")] -fn ecsm_oracle(x: &FieldElement, k: &Scalar) -> Option { +fn ecsm_oracle(x: &FieldElement, k: &Scalar) -> Option<(FieldElement, FieldElement, FieldElement)> { let x_be = x.to_bytes(); let k_be = k.to_bytes(); - let mut x_le = [0u8; 32]; - let mut k_le = [0u8; 32]; + let mut x_le = Align8([0u8; 32]); + let mut k_le = Align8([0u8; 32]); for i in 0..32 { - x_le[i] = x_be[31 - i]; - k_le[i] = k_be[31 - i]; + x_le.0[i] = x_be[31 - i]; + k_le.0[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 = Align8([0u8; 96]); + lambda_vm_syscalls::syscalls::ecsm_mul(&mut out.0, &x_le.0, &k_le.0); + let load = |chunk: usize| -> Option { + let mut be = [0u8; 32]; + for i in 0..32 { + be[i] = out.0[chunk * 32 + 31 - i]; + } + Option::from(FieldElement::from_bytes(&be.into())) + }; + Some((load(0)?, load(1)?, load(2)?)) } /// Base-field inverse `x⁻¹ mod p`. @@ -384,18 +402,20 @@ where Option::from(x.invert()) } -/// 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 oracle queries, or `None` if a degenerate +/// configuration or an unusable oracle result 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 ECSM ecall returns the full point `k·P̂` together with the root `ŷ` it used, and the +/// chip may pick either root of `x(P)` — the AIR binds only `ŷ² ≡ x³ + b`. So `k·P̂ = ±(k·P)`: +/// comparing `ŷ` against the caller's own `y` says which, and one conditional negation +/// recovers `k·P`. `Q = A + B` is then a single chord addition with a single field inversion. +/// +/// The x-only predecessor needed a second query `x((k+1)·P)` per point plus the chord-law +/// y-reconstruction, which is what made `k1 = 1` and `k1 = N−1` degenerate; with `y` in hand +/// 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. `dx = 0` still covers both remaining +/// degenerate cases at once (two curve points share an x only when they are equal or +/// negatives), and the caller falls back to the software `lincomb` there. /// /// Generic over the oracle so unit tests can substitute a software stand-in. #[cfg(any(target_arch = "riscv64", test))] @@ -407,45 +427,30 @@ fn lincomb2_with_oracle( oracle: O, ) -> Option where - O: Fn(&FieldElement, &Scalar) -> Option, + O: Fn(&FieldElement, &Scalar) -> Option<(FieldElement, 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))?; + let (xa, ya) = oracle_point(&x1, &y1, k1, &oracle)?; + let (xb, yb) = oracle_point(&x2, &y2, k2, &oracle)?; - 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(); @@ -456,36 +461,37 @@ 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`]. +/// One oracle query plus the root fix-up: `k·(xp, yp)` in affine coordinates. +/// +/// The oracle multiplied `(xp, ŷ)` for whichever root `ŷ` the chip witnessed, so the result +/// is `k·(xp, yp)` when `ŷ = yp` and `−k·(xp, yp)` when `ŷ = −yp`. Since `ŷ` is canonical +/// (the oracle's field-element parse rejected `≥ p`) and satisfies `ŷ² ≡ xp³ + b`, those are +/// the only two cases; anything else means the oracle did not multiply *this* point, so we +/// return `None` and the caller falls back to software. +/// +/// Compared by value rather than `ct_eq`: k256 compares raw limbs *and* the magnitude and +/// `normalized` tags, so a subtraction result never compares equal to a normalized constant +/// whatever its value. Both operands are `from_bytes` outputs (magnitude 1), which keeps +/// `Sub`'s internal `negate(1)` within its contract; the negated `yr` is re-normalized so +/// the caller's later subtraction stays within it too. #[cfg(any(target_arch = "riscv64", test))] -fn solve_y( +fn oracle_point( 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; + k: &Scalar, + oracle: &O, +) -> Option<(FieldElement, FieldElement)> +where + O: Fn(&FieldElement, &Scalar) -> Option<(FieldElement, FieldElement, FieldElement)>, +{ + let (xr, yr, yg) = oracle(xp, k)?; + if bool::from((*yp - yg).normalizes_to_zero()) { + return Some((xr, yr)); } - 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))) + if bool::from((*yp + yg).normalizes_to_zero()) { + return Some((xr, (-yr).normalize())); + } + None } /// Affine `(x, y)` of a non-identity point as field elements, via its SEC1 diff --git a/crypto/ethrex-crypto/src/tests/ecsm_tests.rs b/crypto/ethrex-crypto/src/tests/ecsm_tests.rs index 42e80224b..f884de8e6 100644 --- a/crypto/ethrex-crypto/src/tests/ecsm_tests.rs +++ b/crypto/ethrex-crypto/src/tests/ecsm_tests.rs @@ -1,6 +1,6 @@ -//! Tests for the x-only ECSM linear-combination reconstruction +//! Tests for the ECSM linear-combination reconstruction //! (`lincomb2_with_oracle`) against the software `ProjectivePoint::lincomb`, -//! plus the degenerate-configuration fallback guards. +//! plus the root fix-up and the degenerate-configuration fallback guards. use crate::*; @@ -11,15 +11,45 @@ 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 { +fn is_odd(y: &FieldElement) -> bool { + y.normalize().to_bytes()[31] & 1 == 1 +} + +/// Software stand-in for the ECSM precompile, parameterised by which root of `x` the chip +/// witnesses. The real chip is free to pick either (the AIR binds only `yG² ≡ xG³ + b`), so +/// both settings are legal traces and the caller must handle them identically. +/// Returns `(x(k·P̂), y(k·P̂), ŷ)` with `P̂ = (x, ŷ)`. +fn soft_oracle_with_root( + x: &FieldElement, + k: &Scalar, + want_odd_root: bool, +) -> Option<(FieldElement, 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(); + let yg = if is_odd(&y) == want_odd_root { + y + } else { + (-y).normalize() + }; + let p = point_from_xy(&xn, &yg)?; let prod = (p * k).to_affine(); - Some(affine_xy(&prod)?.0) + let (xr, yr) = affine_xy(&prod)?; + Some((xr, yr, yg)) +} + +/// The canonical (even-root) lift, matching what `ecsm::recover_y_canonical` produces. +fn soft_oracle(x: &FieldElement, k: &Scalar) -> Option<(FieldElement, FieldElement, FieldElement)> { + soft_oracle_with_root(x, k, false) +} + +/// The other legal choice: the odd root. `k·P̂ = −(k·P)` here, so the caller's sign fix-up +/// is what keeps the answer right. +fn soft_oracle_odd( + x: &FieldElement, + k: &Scalar, +) -> Option<(FieldElement, FieldElement, FieldElement)> { + soft_oracle_with_root(x, k, true) } fn g_times(n: u64) -> ProjectivePoint { @@ -55,23 +85,96 @@ fn matches_software_lincomb_on_recovery_shape() { assert_eq!(got, expected.to_affine()); } +/// The property the echoed root buys: whichever root the chip picks, the reconstruction is +/// the same point. With the odd root every `k·P̂` comes back negated, and only the caller's +/// fix-up puts it right — so an unfixed implementation fails this and passes the one above. +#[test] +fn either_witnessed_root_gives_the_same_result() { + let cases = [ + (g_times(3), 123_456_789u64, g_times(7), 987_654_321u64), + ( + ProjectivePoint::GENERATOR, + 0xdead_beefu64, + g_times(0x1234), + 0x0bad_f00du64, + ), + (g_times(11), 2u64.pow(20) + 5, g_times(2), 42u64), + ]; + for (p1, k1, p2, k2) in cases { + let (k1, k2) = (Scalar::from(k1), Scalar::from(k2)); + let expected = ProjectivePoint::lincomb(&p1, &k1, &p2, &k2).to_affine(); + let even = lincomb2_with_oracle(&p1.to_affine(), &k1, &p2.to_affine(), &k2, soft_oracle) + .expect("even-root oracle must reconstruct"); + let odd = lincomb2_with_oracle(&p1.to_affine(), &k1, &p2.to_affine(), &k2, soft_oracle_odd) + .expect("odd-root oracle must reconstruct"); + assert_eq!(even, expected); + assert_eq!( + odd, expected, + "the root fix-up must absorb the chip's choice" + ); + } +} + +/// `ŷ` that is neither `y` nor `−y` means the oracle did not multiply the caller's point. +/// The caller must decline rather than use the result. #[test] -fn edge_scalars_fall_back() { +fn foreign_root_falls_back() { + let bogus = |x: &FieldElement, k: &Scalar| { + let (xr, yr, _) = soft_oracle(x, k)?; + // A valid field element, but not a root of this x. + let mut bytes = [0u8; 32]; + bytes[31] = 9; + Some((xr, yr, FieldElement::from_bytes(&bytes.into()).unwrap())) + }; let p1 = g_times(3); - let p2 = g_times(5); + let p2 = g_times(7); + let k = Scalar::from(12345u64); + assert!( + lincomb2_with_oracle(&p1.to_affine(), &k, &p2.to_affine(), &k, bogus).is_none(), + "a yG that is neither root must be rejected" + ); +} + +/// `k = 1` and `k = N−1` were degenerate for the x-only predecessor (which needed a second +/// `(k+1)·P` query); with `y` returned they are ordinary scalars. +#[test] +fn former_edge_scalars_now_reconstruct() { + let p1 = g_times(3); + let p2 = g_times(7); let ok = Scalar::from(12345u64); - for bad in [Scalar::ZERO, Scalar::ONE, -Scalar::ONE] { - assert!( - lincomb2_with_oracle(&p1.to_affine(), &bad, &p2.to_affine(), &ok, soft_oracle) - .is_none() - ); - assert!( - lincomb2_with_oracle(&p1.to_affine(), &ok, &p2.to_affine(), &bad, soft_oracle) - .is_none() - ); + for k in [Scalar::ONE, -Scalar::ONE] { + for (a, ka, b, kb) in [(p1, k, p2, ok), (p1, ok, p2, k)] { + let expected = ProjectivePoint::lincomb(&a, &ka, &b, &kb); + let got = lincomb2_with_oracle(&a.to_affine(), &ka, &b.to_affine(), &kb, soft_oracle) + .expect("k = 1 / N−1 are ordinary scalars now"); + assert_eq!(got, expected.to_affine()); + } } } +#[test] +fn zero_scalars_fall_back() { + let p1 = g_times(3); + let p2 = g_times(5); + let ok = Scalar::from(12345u64); + assert!(lincomb2_with_oracle( + &p1.to_affine(), + &Scalar::ZERO, + &p2.to_affine(), + &ok, + soft_oracle + ) + .is_none()); + assert!(lincomb2_with_oracle( + &p1.to_affine(), + &ok, + &p2.to_affine(), + &Scalar::ZERO, + soft_oracle + ) + .is_none()); +} + #[test] fn identity_points_fall_back() { let p = g_times(3); @@ -92,10 +195,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 satisfies k·P = -(k+1)·P for any P. It broke nothing before and it + // breaks nothing now; kept as a regression pin on a scalar with structure. let two_inv = Scalar::from(2u64) .invert_vartime() .expect("2 is invertible mod n"); @@ -107,7 +208,7 @@ fn k_half_n_minus_1_reconstructs_correctly() { let expected = ProjectivePoint::lincomb(&p1, &k_half, &p2, &k2); let got = lincomb2_with_oracle(&p1.to_affine(), &k_half, &p2.to_affine(), &k2, soft_oracle) - .expect("k=(n-1)/2 is not near-edge and must reconstruct correctly"); + .expect("k=(n-1)/2 must reconstruct correctly"); assert_eq!(got, expected.to_affine()); } @@ -129,55 +230,28 @@ 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. - let (p1, _k_gen) = (2u64..200) + // A base point with odd y exercises the fix-up from the other side: the caller's own y + // is the odd root, so the canonical-lift oracle is the one that comes back negated. + let p1 = (2u64..200) .find_map(|n| { let p = g_times(n); let (_, y) = affine_xy(&p.to_affine())?; - if y.normalize().to_bytes()[31] & 1 == 1 { - Some((p, n)) - } else { - None - } + is_odd(&y).then_some(p) }) .expect("at least one of the first 200 multiples of G has odd y"); let p2 = g_times(13); let k1 = Scalar::from(54321u64); let k2 = Scalar::from(11111u64); - let expected = ProjectivePoint::lincomb(&p1, &k1, &p2, &k2); - let got = lincomb2_with_oracle(&p1.to_affine(), &k1, &p2.to_affine(), &k2, soft_oracle) - .expect("odd-y base point is non-degenerate and must reconstruct correctly"); - assert_eq!(got, expected.to_affine()); + let expected = ProjectivePoint::lincomb(&p1, &k1, &p2, &k2).to_affine(); + for oracle in [ + soft_oracle as fn(&FieldElement, &Scalar) -> _, + soft_oracle_odd as fn(&FieldElement, &Scalar) -> _, + ] { + let got = lincomb2_with_oracle(&p1.to_affine(), &k1, &p2.to_affine(), &k2, oracle) + .expect("odd-y base point is non-degenerate and must reconstruct correctly"); + assert_eq!(got, expected); + } } diff --git a/executor/programs/asm/test_ecsm.s b/executor/programs/asm/test_ecsm.s index 67298f810..13968dfb1 100644 --- a/executor/programs/asm/test_ecsm.s +++ b/executor/programs/asm/test_ecsm.s @@ -1,8 +1,9 @@ .attribute 5, "rv64i2p1_m2p0_zmmul1p0" .globl main main: - # Stack layout (96 bytes): xG at sp+0, k at sp+32, xR at sp+64. - addi sp, sp, -96 + # Stack layout (160 bytes): xG at sp+0, k at sp+32, and the ECSM output buffer + # [xR ‖ yR ‖ yG] at sp+64..sp+160. Only xR is committed. + addi sp, sp, -160 # xG = secp256k1 Gx, little-endian (4 doublewords). li t0, 0x59F2815B16F81798 @@ -21,7 +22,7 @@ main: sd zero, 48(sp) sd zero, 56(sp) - # ECSM ecall: a0 = &xR, a1 = &xG, a2 = &k, a7 = -11. + # ECSM ecall: a0 = &out (96 bytes), a1 = &xG, a2 = &k, a7 = -11. addi a0, sp, 64 addi a1, sp, 0 addi a2, sp, 32 @@ -37,7 +38,7 @@ main: ecall # Restore stack and halt. - addi sp, sp, 96 + addi sp, sp, 160 li a0, 0 li a7, 93 ecall diff --git a/executor/programs/asm/test_ecsm_multi.s b/executor/programs/asm/test_ecsm_multi.s index bc0fcfd23..4885b4aff 100644 --- a/executor/programs/asm/test_ecsm_multi.s +++ b/executor/programs/asm/test_ecsm_multi.s @@ -1,8 +1,9 @@ .attribute 5, "rv64i2p1_m2p0_zmmul1p0" .globl main main: - # Stack layout (96 bytes): xG at sp+0, k at sp+32, xR at sp+64. - addi sp, sp, -96 + # Stack layout (160 bytes): xG at sp+0, k at sp+32, and the ECSM output buffer + # [xR ‖ yR ‖ yG] at sp+64..sp+160. Only xR is committed. + addi sp, sp, -160 # xG = secp256k1 Gx, little-endian (written once; reused by all calls). li t0, 0x59F2815B16F81798 @@ -62,7 +63,7 @@ main: ecall # Restore stack and halt. - addi sp, sp, 96 + addi sp, sp, 160 li a0, 0 li a7, 93 ecall diff --git a/executor/programs/asm/test_ecsm_split.s b/executor/programs/asm/test_ecsm_split.s index e0e1666ae..4df68155b 100644 --- a/executor/programs/asm/test_ecsm_split.s +++ b/executor/programs/asm/test_ecsm_split.s @@ -1,12 +1,12 @@ .attribute 5, "rv64i2p1_m2p0_zmmul1p0" .globl main main: - # Like test_ecsm.s, but the ECSM pointer registers (a0=&xR, a1=&xG, a2=&k) + # Like test_ecsm.s, but the ECSM pointer registers (a0=&out, a1=&xG, a2=&k) # are set at the very START and never rewritten before the ecall. With a small # continuation epoch size the ecall lands in a LATER epoch than the one that set # the pointers, so the per-epoch touched-cell pass must carry registers across # the boundary to compute the right addresses. - addi sp, sp, -96 + addi sp, sp, -160 addi a0, sp, 64 addi a1, sp, 0 addi a2, sp, 32 @@ -41,7 +41,7 @@ main: ecall # Restore stack and halt. - addi sp, sp, 96 + addi sp, sp, 160 li a0, 0 li a7, 93 ecall diff --git a/executor/programs/bench/ecsm/src/main.rs b/executor/programs/bench/ecsm/src/main.rs index 78549d35b..4d311d05b 100644 --- a/executor/programs/bench/ecsm/src/main.rs +++ b/executor/programs/bench/ecsm/src/main.rs @@ -22,10 +22,13 @@ pub fn main() { ]; k.reverse(); - let mut xr = [0u8; 32]; + // The precompile writes [xR ‖ yR ‖ yG]; the chain feeds xR back as the next base point. + #[repr(C, align(8))] + struct Align8([u8; N]); + let mut out = Align8([0u8; 96]); for _ in 0..ITERATIONS { - syscalls::syscalls::ecsm_mul(&mut xr, &xg, &k); - xg = xr; + syscalls::syscalls::ecsm_mul(&mut out.0, &xg, &k); + xg.copy_from_slice(&out.0[..32]); } - syscalls::syscalls::commit(&xr); + syscalls::syscalls::commit(&out.0[..32]); } diff --git a/executor/programs/rust/ecsm/src/main.rs b/executor/programs/rust/ecsm/src/main.rs index 709d4a4ae..ea553c6d9 100644 --- a/executor/programs/rust/ecsm/src/main.rs +++ b/executor/programs/rust/ecsm/src/main.rs @@ -14,7 +14,11 @@ pub fn main() { let mut k = [0u8; 32]; k[0] = 5; - let mut xr = [0u8; 32]; - syscalls::syscalls::ecsm_mul(&mut xr, &xg, &k); - syscalls::syscalls::commit(&xr); + // The precompile writes [xR ‖ yR ‖ yG]; only xR is committed. 8-byte aligned so the + // twelve doubleword accesses take the aligned memory path (MEMW_A). + #[repr(C, align(8))] + struct Align8([u8; N]); + let mut out = Align8([0u8; 96]); + syscalls::syscalls::ecsm_mul(&mut out.0, &xg, &k); + syscalls::syscalls::commit(&out.0[..32]); } diff --git a/executor/src/tests/ecsm_tests.rs b/executor/src/tests/ecsm_tests.rs index 0fa240a8e..2b2d2d220 100644 --- a/executor/src/tests/ecsm_tests.rs +++ b/executor/src/tests/ecsm_tests.rs @@ -174,3 +174,81 @@ fn ecsm_syscall_rejects_address_overflow() { ); } } + +/// Runs the ECSM syscall and returns the whole 96-byte output buffer as +/// `(xR, yR, yG)`, all little-endian. +fn run_ecsm_full( + k_bytes: &[u8; 32], + xg_le: &[u8; 32], +) -> Result<([u8; 32], [u8; 32], [u8; 32]), ExecutionError> { + let mut pc = 0; + let mut registers = Registers::default(); + let mut memory = Memory::default(); + + let addr_out = 0x1000u64; + let addr_xg = 0x2000u64; + let addr_k = 0x3000u64; + write_u256_le(&mut memory, addr_xg, xg_le); + write_u256_le(&mut memory, addr_k, k_bytes); + + registers.write(17, ECSM_SYSCALL_NUMBER).unwrap(); + registers.write(10, addr_out).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_out), + read_u256_le(&memory, addr_out + 32), + read_u256_le(&memory, addr_out + 64), + )) +} + +/// `y² ≡ x³ + 7 (mod p)` for little-endian 32-byte coordinates. +fn on_curve(x_le: &[u8; 32], y_le: &[u8; 32]) -> bool { + let fe = |le: &[u8; 32]| { + let mut be = *le; + be.reverse(); + Option::::from(k256::FieldElement::from_bytes(&be.into())) + }; + let (Some(x), Some(y)) = (fe(x_le), fe(y_le)) else { + return false; + }; + let mut seven = [0u8; 32]; + seven[31] = 7; + let b = k256::FieldElement::from_bytes(&seven.into()).unwrap(); + // Negate `y²` (magnitude 1), not the RHS: the RHS is a sum carrying magnitude 2, and + // k256's `negate(1)` asserts its operand's magnitude is <= 1 in debug builds. + ((x.square() * x + b) + y.square().negate(1)) + .normalizes_to_zero() + .into() +} + +#[test] +fn ecsm_syscall_writes_the_full_96_byte_output() { + let xg = gx_le(); + for v in [1u64, 2, 5, 0xFFFF, 1_000_003] { + let k = k_le(v); + let got = run_ecsm_full(&k, &xg).unwrap(); + assert_eq!(got, ecsm::scalar_mul_full(&k, &xg).unwrap()); + let (xr, yr, yg) = got; + // yG is the root of xG the chip used, and the executor lifts to the even one. + assert!(on_curve(&xg, &yg), "yG must satisfy yG² = xG³ + 7"); + assert_eq!(yg[0] & 1, 0, "the executor lifts xG to its even root"); + // yR is the y of k·(xG, yG), so the result is a curve point too. + assert!(on_curve(&xr, &yr), "yR must satisfy yR² = xR³ + 7"); + } +} + +#[test] +fn ecsm_syscall_output_bound_covers_all_96_bytes() { + // The output spans +0..+95, so its low limb must stay under 2^32 - 95. One past the + // last accepted base is where the 96th byte would cross the limb boundary. + let last_ok = 0x1_0000_0000u64 - 96; + run_ecsm_at(last_ok, 0x2000, 0x3000).expect("+95 lands on the last byte of the limb"); + let err = run_ecsm_at(last_ok + 1, 0x2000, 0x3000).unwrap_err(); + assert!( + matches!(err, ExecutionError::EcsmAddressOverflow), + "an output whose 96th byte crosses the limb must be rejected" + ); +} diff --git a/executor/src/vm/instruction/execution.rs b/executor/src/vm/instruction/execution.rs index 592af95e8..33026f6ee 100644 --- a/executor/src/vm/instruction/execution.rs +++ b/executor/src/vm/instruction/execution.rs @@ -519,14 +519,16 @@ impl Instruction { } SyscallNumbers::Ecsm => { // ECSM(-11): k×G 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 + // x10 = addr of the 96-byte output buffer [xR ‖ yR ‖ yG], + // x11 = addr of xG, x12 = addr of k. + // All six values are 32-byte little-endian; 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)?; + // The output spans +0..+95, so its bound is 95, not 31. if !addr_limb_ok(addr_xg, 31) - || !addr_limb_ok(addr_xr, 31) + || !addr_limb_ok(addr_xr, 95) || !addr_limb_ok(addr_k, 31) { return Err(ExecutionError::EcsmAddressOverflow); @@ -537,14 +539,18 @@ impl Instruction { // 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. + // The output may alias either (even though it now spans 96 bytes and + // so can cover both): its accesses are at T+2 and T+3, strictly after + // both reads, so every per-address chain stays monotone. 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)?; + let (xr, yr, yg) = ecsm::scalar_mul_full(&k, &xg)?; store_u256_le(memory, addr_xr, &xr)?; + store_u256_le(memory, addr_xr + 32, &yr)?; + store_u256_le(memory, addr_xr + 64, &yg)?; // 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 746bef91c..84603c09c 100644 --- a/prover/src/tables/ecsm.rs +++ b/prover/src/tables/ecsm.rs @@ -420,27 +420,46 @@ pub fn bus_interactions() -> Vec { 0, ), )); - // write xR: 4 doublewords at addr_xR + 8i (ts + 2). - for i in 0..4 { - let base_lo = BusValue::linear(vec![ - LinearTerm::Column { - coefficient: 1, - column: cols::ADDR_XR_0, - }, - LinearTerm::Constant((8 * i) as i64), - ]); - out.push(BusInteraction::sender( - BusId::Memw, - mu(), - memw_write( - dword_bytes(cols::XR, i), - base_lo, - packed(cols::ADDR_XR_1), - ts_lo_plus(2), - ts_hi(), - 1, - ), - )); + // Write the 96-byte output buffer [xR ‖ yR ‖ yG] as 12 doublewords at addr_xR + off + 8i. + // + // `yG` is echoed because the chip may witness EITHER root of xG — the AIR binds only + // `yG² ≡ xG³ + b`, so nothing here pins the sign (see the "Two options for y_G" aside in + // `spec/ecsm.typ`). `yR` alone would therefore be ambiguous: it is `±y(k·P)` for the + // caller's own point P. Handing back the root the chip used lets the guest resolve it + // with one comparison, which keeps the root a free choice exactly as the aside argues + // while still exposing a usable y — and costs no column, since `YR` and `YG` are already + // witnessed (YR arrives on the ECDAS bus, YG is proved by the yG convolution). + // + // xR keeps ts + 2 (grouped with the x10 register read); yR and yG take ts + 3, the free + // fourth sub-timestamp of the instruction's stride-4 window. Several doubleword accesses + // may share a timestamp as long as their addresses differ, which they do — the three + // chunks are disjoint 32-byte ranges of one buffer. + for (col, off, ts) in [ + (cols::XR, 0i64, ts_lo_plus(2)), + (cols::YR, 32, ts_lo_plus(3)), + (cols::YG, 64, ts_lo_plus(3)), + ] { + for i in 0..4 { + let base_lo = BusValue::linear(vec![ + LinearTerm::Column { + coefficient: 1, + column: cols::ADDR_XR_0, + }, + LinearTerm::Constant(off + (8 * i) as i64), + ]); + out.push(BusInteraction::sender( + BusId::Memw, + mu(), + memw_write( + dword_bytes(col, i), + base_lo, + packed(cols::ADDR_XR_1), + ts.clone(), + ts_hi(), + 1, + ), + )); + } } // IS_BYTE range checks (single byte → AreBytes[x, 0]). diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 29874caef..9ab202144 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -862,7 +862,7 @@ fn collect_ecsm_ops( let witness = ::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); + let mut memw_ops = Vec::with_capacity(23); // x11 -> addr_xG (register read at T), x12 -> addr_k (register read at T+1). { @@ -926,20 +926,30 @@ fn collect_ecsm_ops( register_state.write(10, val, t + 2); } - // xR writes at T + 2 (4 doublewords). - for i in 0..4 { - let addr = addr_xr.wrapping_add((8 * i) as u64); - let mut value = [0u32; 8]; - let mut dword = 0u64; - for j in 0..8 { - value[j] = witness.x_r[8 * i + j] as u32; - dword |= (witness.x_r[8 * i + j] as u64) << (8 * j); + // Output buffer [xR ‖ yR ‖ yG] — 12 doubleword writes at addr_xR + off + 8i. + // xR at T + 2 (grouped with the x10 register read), yR and yG at T + 3, the free fourth + // sub-timestamp of the stride-4 window. The three chunks are disjoint 32-byte ranges, so + // sharing T + 3 between the last two never touches an address twice. `yG` is echoed so + // the guest can tell which root of xG the chip witnessed; see `ecsm::bus_interactions`. + for (bytes, off, ts) in [ + (&witness.x_r, 0u64, t + 2), + (&witness.y_r, 32, t + 3), + (&witness.y_g, 64, t + 3), + ] { + for i in 0..4 { + let addr = addr_xr.wrapping_add(off).wrapping_add((8 * i) as u64); + let mut value = [0u32; 8]; + let mut dword = 0u64; + for j in 0..8 { + value[j] = bytes[8 * i + j] as u32; + dword |= (bytes[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, ts, 8, false).with_old(old_vals, old_ts), + ); + memory_state.write_bytes(addr, dword, 8, ts); } - let (old_vals, old_ts) = memory_state.read_bytes(addr, 8); - memw_ops.push( - MemwOperation::new(false, addr, value, t + 2, 8, false).with_old(old_vals, old_ts), - ); - memory_state.write_bytes(addr, dword, 8, t + 2); } let ecdas_ops = witness diff --git a/syscalls/src/syscalls.rs b/syscalls/src/syscalls.rs index 5228455ea..3b3592b08 100644 --- a/syscalls/src/syscalls.rs +++ b/syscalls/src/syscalls.rs @@ -176,24 +176,34 @@ pub fn keccak_permute(_state: &mut [u64; 25]) { } #[cfg(target_arch = "riscv64")] -/// Compute `xR = (k·G)_x` on secp256k1 via the ECSM accelerator. All values are 32-byte -/// little-endian. Requires `0 < k < N` and a canonical valid `xG` curve coordinate. -/// `xG` and `k` must not overlap; `xR` may alias either input. -pub fn ecsm_mul(xr: &mut [u8; 32], xg: &[u8; 32], k: &[u8; 32]) { +/// Compute `k·G` on secp256k1 via the ECSM accelerator, writing `[xR ‖ yR ‖ yG]` as three +/// contiguous 32-byte little-endian values into `out`. Requires `0 < k < N` and a canonical +/// valid `xG` curve coordinate. `xG` and `k` must not overlap; `out` may alias either input. +/// +/// `yG` is the root of `xG` the chip actually used, and the chip is free to pick either one +/// (the AIR binds only `yG² ≡ xG³ + b`). So `yR` is the y of `k·(xG, yG)`, which is `±y(k·P)` +/// for the caller's point `P`. Compare `yG` against your own base point's y and negate `yR` +/// when they differ; that also validates `yG`, since a value that is neither root means the +/// output is unusable and the caller should fall back. +/// +/// `out` should be 8-byte aligned so the twelve doubleword accesses land on the aligned +/// memory path (MEMW_A) instead of the general one; the same goes for `xg` and `k`. +pub fn ecsm_mul(out: &mut [u8; 96], xg: &[u8; 32], k: &[u8; 32]) { unsafe { asm!( "ecall", - in("a0") xr.as_mut_ptr(), // x10 = address to write xR - 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 ‖ yG] + 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"))] -/// 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]) { +/// Compute `k·G` on secp256k1 via the ECSM accelerator, writing `[xR ‖ yR ‖ yG]` +/// (three 32-byte little-endian values) into `out`. +pub fn ecsm_mul(_out: &mut [u8; 96], _xg: &[u8; 32], _k: &[u8; 32]) { unimplemented!("syscalls are only implemented for riscv64 targets"); } From 2b15c92946bcc4d8c40c9b9d6c4204948782bfae Mon Sep 17 00:00:00 2001 From: diegokingston Date: Wed, 19 Aug 2026 16:26:29 -0300 Subject: [PATCH 2/2] fix(ci): satisfy the lint gate on the new ECSM executor test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `make lint` (the required check) runs `cargo fmt --check --all` and four clippy passes with `-D warnings`. The new `run_ecsm_full` helper tripped both: clippy::type_complexity on its `Result<([u8; 32], [u8; 32], [u8; 32]), _>` return, and then rustfmt on the one-line form the fix produced. Reuse the `ecsm::EcsmOutput` alias the crate already exports for exactly this shape, and re-run rustfmt. `make lint` now exits 0. Also type-checked the riscv64-gated `ecsm_oracle` body on host with a stub ecall (that code is behind `#[cfg(target_arch = "riscv64")]`, so neither `make lint` nor the host tests ever compile it): it builds, both `Align8` buffers come out 8-aligned, and the three 32-byte chunks are extracted in [xR ‖ yR ‖ yG] order. `commit` takes `&[u8]`, so the guest program's `commit(&out.0[..32])` is well-typed. `make test-ethrex-crypto` runs both profiles deliberately (k256 swaps its FieldElement implementation and only asserts magnitudes in debug); 26/26 in each. --- executor/src/tests/ecsm_tests.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/executor/src/tests/ecsm_tests.rs b/executor/src/tests/ecsm_tests.rs index 2b2d2d220..ce4e3c11b 100644 --- a/executor/src/tests/ecsm_tests.rs +++ b/executor/src/tests/ecsm_tests.rs @@ -177,10 +177,7 @@ fn ecsm_syscall_rejects_address_overflow() { /// Runs the ECSM syscall and returns the whole 96-byte output buffer as /// `(xR, yR, yG)`, all little-endian. -fn run_ecsm_full( - k_bytes: &[u8; 32], - xg_le: &[u8; 32], -) -> Result<([u8; 32], [u8; 32], [u8; 32]), ExecutionError> { +fn run_ecsm_full(k_bytes: &[u8; 32], xg_le: &[u8; 32]) -> Result { let mut pc = 0; let mut registers = Registers::default(); let mut memory = Memory::default();