Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
72e17f7
Add on-demand hint ecall (host-computed)
jotabulacios Jul 24, 2026
d01d145
Add HINT prover table for the hint ecall
jotabulacios Jul 24, 2026
c378f56
Add hint ecall guest tests and test programs
jotabulacios Jul 24, 2026
3b599b3
Route ecsm inverses and sqrt through hint ecall
jotabulacios Jul 24, 2026
e8f421c
Make the hint ecall ABI big-endian
jotabulacios Jul 27, 2026
9f83982
Validate the Hint ecall operand addresses
jotabulacios Jul 27, 2026
eac970b
Verify hints by difference instead of byte compare
jotabulacios Jul 27, 2026
8fffbfb
Bind HINT writes to x12 and range-check bytes
jotabulacios Jul 29, 2026
f417360
Fix hint doc placement and guest cargo config
jotabulacios Jul 29, 2026
2f30acf
Return the affine point (x, y) from ECSM
jotabulacios Jul 28, 2026
ca01856
Make ECSM affine y-return sound
jotabulacios Jul 28, 2026
cd603c2
Select ECSM x-only vs affine ecall via IS_AFFINE
jotabulacios Jul 28, 2026
729647b
Merge branch 'main' into perf/ecsm-affine-selector
jotabulacios Jul 29, 2026
287b2f5
Force yR < p in the ECSM chip
jotabulacios Jul 30, 2026
5d04947
Drop stale affine docs and dead entry point
jotabulacios Jul 30, 2026
5743caa
Verify hints with a mandatory software fallback
jotabulacios Jul 30, 2026
ad21c37
Constrain the HINT multiplicity column as boolean
jotabulacios Jul 30, 2026
515a921
Drop BENCH-ONLY labels from the hint ecall
jotabulacios Jul 30, 2026
da14fc8
Test that IS_BIT rejects a non-boolean HINT mu
jotabulacios Jul 30, 2026
71ba613
Merge branch 'main' into feat/hint-ecall
jotabulacios Jul 30, 2026
17b874f
Merge remote-tracking branch 'origin/feat/hint-ecall' into perf/ecsm-…
jotabulacios Jul 30, 2026
56a1717
Merge branch 'main' into feat/hint-ecall
jotabulacios Jul 30, 2026
2e0c16d
solve conflicts
jotabulacios Jul 31, 2026
c75121a
Run ethrex-crypto host tests in CI
jotabulacios Jul 31, 2026
3b1a012
Add software fallback and test seam to field_inv
jotabulacios Jul 31, 2026
32f9cd1
GPU parity-check the HINT table
jotabulacios Jul 31, 2026
be9066b
Move HINT syscall off the FEXT_FMA numberD
jotabulacios Jul 31, 2026
95b62de
Merge feat/hint-ecall into perf/ecsm-affine-selector
jotabulacios Jul 31, 2026
7d0e1ba
Test the affine ECSM operand ranges instead of their distance
jotabulacios Jul 31, 2026
58ad49f
Fix stale ECSM affine docs, counts and the new guest's lock
jotabulacios Jul 31, 2026
5aa7d0c
Cover the IS_AFFINE selector and the yR < p check
jotabulacios Jul 31, 2026
9ce5c1b
Widen the ECSM error wording to the affine path
jotabulacios Jul 31, 2026
c2283bf
Bind and range-check the HINT ecall operands
jotabulacios Jul 31, 2026
64a22ff
lint
jotabulacios Jul 31, 2026
847954b
Merge main into feat/hint-ecall (pick up the real-block benchmark)
MauroToscano Aug 1, 2026
abd3a8b
Merge feat/hint-ecall (now including main) into the selector branch
MauroToscano Aug 1, 2026
a88406c
Merge remote-tracking branch 'origin/main' into perf/ecsm-affine-sele…
nicole-graus Aug 10, 2026
1a99431
Range-check the ECSM operand address limbs in the AIR and stop the af…
nicole-graus Aug 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions crypto/ecsm/src/curve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,14 @@ pub struct AffinePoint {
/// Recovers the canonical (even) `y` for a given `x` such that `y^2 = x^3 + b mod p`.
///
/// Both `y` and `p - y` are valid; we pick the even one so the executor and prover agree
/// deterministically. The chip never constrains the parity (it only writes back `xR`, and
/// `k·P` and `k·(-P)` share an x-coordinate), so any consistent choice is sound.
/// deterministically. Any consistent choice is sound **on the x-only path**, which is the
/// only caller: it writes back just `xR`, and `k·P` and `k·(-P)` share an x-coordinate, so
/// the parity never escapes the chip and the AIR need not constrain it.
///
/// The affine path does not use this function — it takes `yG` from the caller (see
/// `prepare_with_y`), because there `yR` *is* returned and the parity is observable. There
/// the AIR pins `yG` to the caller's input buffer with an `IS_AFFINE`-gated memory read
/// rather than leaving the root to the witness.
///
/// Returns `None` when `x` is not a valid curve x-coordinate (`x^3 + b` is not a quadratic
/// residue, or `x` is not a canonical field element).
Expand Down Expand Up @@ -153,11 +159,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: 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::<Scalar>::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
Expand Down
80 changes: 69 additions & 11 deletions crypto/ecsm/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
//! Reference secp256k1 scalar multiplication and ECSM-accelerator witness generation.
//!
//! This crate is shared by the executor (which needs `k·G`'s x-coordinate to write back
//! This crate is shared by the executor (which needs `k·G`'s coordinates to write back
//! to guest memory) and the prover (which replays the full double-and-add sequence to
//! fill the ECSM / ECDAS trace witnesses). Both entry points compute the same
//! `k·G` over the audited `k256` curve arithmetic — the executor via `k256`'s scalar
//! multiplication, the prover via a projective double-and-add replay — so the x-coordinate
//! they write/prove agrees. It is also independent of the `yG` root: both recover the same
//! canonical `yG` in `prepare`, and `k·P` and `k·(-P)` share an x.
//! multiplication, the prover via a projective double-and-add replay — so the coordinates
//! they write/prove agree.
//!
//! There are two families of entry point, and they differ in exactly one respect — which
//! `yG` they use:
//! - **x-only** (`scalar_mul_x` / [`compute_witness`], via `prepare`): `yG` is recovered as
//! the canonical *even* lift of `xG`, so the result is independent of the root — only
//! `xR` is returned, and `k·P` and `k·(-P)` share an x.
//! - **affine** (`scalar_mul_xy_with_y` / [`compute_witness_with_y`], via `prepare_with_y`):
//! `yG` is the caller's own value, validated on-curve but *not* canonicalized. The result
//! IS root-dependent — `yR` is the y of the caller's chosen lift — which is the whole
//! point, since the affine ecall returns `yR` to the guest. In the prover the witnessed
//! `yG` is pinned to the caller's input buffer by a memory read, so the root is not a
//! free choice.
//!
//! Curve point operations are delegated to the RustCrypto `k256` crate; witness generation
//! replays the schedule in `k256` projective coordinates and batch-inverts the slope
Expand All @@ -24,7 +35,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;
Expand Down Expand Up @@ -65,11 +76,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,
}

Expand All @@ -78,8 +92,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"),
}
}
}
Expand Down Expand Up @@ -119,6 +133,50 @@ 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 p = p();
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`.
Expand Down
55 changes: 54 additions & 1 deletion crypto/ecsm/src/tests/lib_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

use num_bigint::BigUint;

use crate::{B, EcsmError, n, p, recover_y_canonical, scalar_mul_x, to_le_32};
use crate::{
B, EcsmError, n, p, recover_y_canonical, scalar_mul_x, scalar_mul_xy_with_y, to_le_32,
};

/// Parses a big-endian hex string into a `BigUint`.
fn be_hex(s: &str) -> BigUint {
Expand Down Expand Up @@ -137,3 +139,54 @@ fn rejects_non_canonical_xg() {
Err(EcsmError::NotOnCurve)
);
}

/// The affine path must use the caller's `yG`, not the canonical even lift.
///
/// This is the one property that distinguishes `prepare_with_y` from `prepare`, and every
/// other affine test in the tree feeds the generator's `Gy`, which *is* the even lift — so
/// they cannot observe the difference. Negating the input point must negate the output:
/// `k·(xG, p − yG) = −(k·(xG, yG))`, i.e. same `xR`, and `yR' = p − yR`.
#[test]
fn affine_uses_the_callers_y_not_the_canonical_lift() {
let gy = be_hex(GY_HEX);
assert_eq!(&gy % 2u8, BigUint::from(0u8), "Gy is the even lift");
let odd_gy = p() - &gy;
let xg = to_le_32(&gx());

for k_val in [1u32, 2, 5, 0xFFFF, 1_000_003] {
let k = to_le_32(&BigUint::from(k_val));
let (xr_even, yr_even) =
scalar_mul_xy_with_y(&k, &xg, &to_le_32(&gy)).expect("even lift is on the curve");
let (xr_odd, yr_odd) =
scalar_mul_xy_with_y(&k, &xg, &to_le_32(&odd_gy)).expect("odd lift is on the curve");

assert_eq!(xr_odd, xr_even, "k = {k_val}: x(k·(-P)) must equal x(k·P)");
let yr_even_big = BigUint::from_bytes_le(&yr_even);
let yr_odd_big = BigUint::from_bytes_le(&yr_odd);
assert_eq!(
yr_odd_big,
p() - &yr_even_big,
"k = {k_val}: y(k·(-P)) must be p - y(k·P) — the odd input lift was ignored"
);
}
}

/// `yG` must be rejected when it is not the y of the given `xG`, and when it is
/// non-canonical. Complements the x-only `rejects_non_canonical_xg`.
#[test]
fn affine_rejects_bad_y() {
let k = to_le_32(&BigUint::from(5u8));
let xg = to_le_32(&gx());
let gy = be_hex(GY_HEX);

// yG = p is out of range (and p ≡ 0, which is not on the curve either).
assert_eq!(
scalar_mul_xy_with_y(&k, &xg, &to_le_32(&p())),
Err(EcsmError::CoordinateOutOfRange)
);
// A canonical but wrong y: on-curve check must reject.
assert_eq!(
scalar_mul_xy_with_y(&k, &xg, &to_le_32(&(&gy + BigUint::from(1u8)))),
Err(EcsmError::NotOnCurve)
);
}
28 changes: 27 additions & 1 deletion crypto/ecsm/src/witness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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],
Expand Down Expand Up @@ -280,7 +285,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<EcsmWitness, EcsmError> {
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<EcsmWitness, EcsmError> {
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<EcsmWitness, EcsmError> {
let p_big = BigInt::from(p());
let r_big = BigInt::from(BigUint::from_bytes_le(&R_BYTES)); // r = 3p

Expand Down Expand Up @@ -328,6 +352,7 @@ pub fn compute_witness(k_le: &[u8; 32], xg_le: &[u8; 32]) -> Result<EcsmWitness,
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.
Expand All @@ -354,6 +379,7 @@ pub fn compute_witness(k_le: &[u8; 32], xg_le: &[u8; 32]) -> Result<EcsmWitness,
x_g_sub_p,
k_sub_n,
x_r_sub_p,
y_r_sub_p,
len_k,
x_r,
y_r,
Expand Down
Loading
Loading