Skip to content
Merged
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
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
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
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
cb4a782
Fix stale hint-ecall comments (#899)
diegokingston Aug 4, 2026
2a5a612
Correct the hint_min alignment comment
jotabulacios Aug 4, 2026
1fa60ec
Drop the BENCH ONLY label from the k256 dependency
jotabulacios Aug 4, 2026
5c55e4c
Range-check the HINT output address low limb, like the input one
jotabulacios Aug 4, 2026
d3a85f4
Derive the HINT selector bound from the executor's accepted set
jotabulacios Aug 4, 2026
a03d513
Merge origin/feat/hint-ecall, taking #899's wording for the stale hin…
jotabulacios Aug 4, 2026
2c3b3e8
Merge branch 'main' into feat/hint-ecall
jotabulacios Aug 4, 2026
d738047
ci(executor): run the executor lib unit tests
MauroToscano Aug 5, 2026
0c29db1
test(ethrex-crypto): cover the negated-sqrt and canonical-but-wrong h…
MauroToscano Aug 5, 2026
762b354
test(hint): exercise all three selectors in the hint_multi guest
MauroToscano Aug 5, 2026
0b93d94
test(hint): pin the guest's selector constants against the executor's
MauroToscano Aug 5, 2026
23384f9
docs(hint): correct three comments the operand work left stale
MauroToscano Aug 5, 2026
a0c52f1
Merge pull request #905 from yetanotherco/review/hint-fixes
jotabulacios Aug 6, 2026
02d8168
ci(ethrex-crypto): run the hint tests in release too, not only debug
MauroToscano Aug 6, 2026
3280384
Merge branch 'main' into feat/hint-ecall
diegokingston Aug 7, 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
12 changes: 12 additions & 0 deletions .github/workflows/pr_main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,15 @@ jobs:
run: |
cargo test --release -p executor --test flamegraph

# The unit tests under `executor/src/tests/` are a *lib* target (`pub mod tests;`
# in lib.rs), which none of the `--test <name>` steps above select — and the
# `test_ckzg` step below filters by name, so it doesn't run them either. Without
# this step they never run in CI. It shares the lib test binary with that step,
# so it costs a test run, not an extra compile.
- name: Run executor lib unit tests
run: |
cargo test --release -p executor --lib

- name: Run ignored executor tests
run: |
cargo test --release -p executor test_ckzg -- --ignored
Expand Down Expand Up @@ -169,6 +178,9 @@ jobs:
- name: Run syscalls host tests (keccak differential vs sha3)
run: make test-syscalls

- name: Run ethrex-crypto host tests (hint verify-then-fallback + ecrecover)
run: make test-ethrex-crypto

# "Test" is a required check — keep this name to avoid branch protection changes.
# This gate job passes only when CLI, executor, disk-spill, and prover tests succeed.
test:
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 15 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ ASM_LDFLAGS ?= -fuse-ld=lld -nostdlib -Wl,-e,main
# Custom RV64IM target spec location
RV64_TARGET_SPEC=$(CURDIR)/executor/programs/riscv64im-lambda-vm-elf.json

.PHONY: test prepare-sysroot
.PHONY: test test-syscalls test-ethrex-crypto prepare-sysroot

# The guard checks for include/stdlib.h (not just the include/ dir) so that a PARTIAL
# sysroot — directories present but missing the C standard library headers — is detected
Expand Down Expand Up @@ -517,7 +517,20 @@ check-ethrex-fixture-checksums:
test-syscalls:
cd syscalls && cargo test

test: compile-programs test-syscalls
# ethrex-crypto is a detached workspace (excluded from the root members), so a
# root `cargo test` never runs it. Run it explicitly, like test-syscalls.
# Run BOTH profiles deliberately. k256 swaps its FieldElement implementation on
# `debug_assertions` (k256 0.13.4 arithmetic/field.rs): debug uses the
# magnitude-tracking `field_impl` wrapper, release uses the raw FieldElement5x52.
# The guest ELF is built with --release, so a release run is the only one that
# exercises the implementation that actually ships; the debug run is kept because
# its magnitude debug_asserts turn a contract violation into a loud panic instead
# of a silently wrong value.
test-ethrex-crypto:
cd crypto/ethrex-crypto && cargo test
cd crypto/ethrex-crypto && cargo test --release

test: compile-programs test-syscalls test-ethrex-crypto
cargo test

# === Quick test shortcuts ===
Expand Down
1 change: 1 addition & 0 deletions bench_vs/lambda/recursion/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

216 changes: 210 additions & 6 deletions crypto/ethrex-crypto/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,12 @@
use ethrex_crypto::keccak::keccak_hash;
use ethrex_crypto::{Crypto, CryptoError};
use k256::elliptic_curve::group::prime::PrimeCurveAffine;
use k256::elliptic_curve::ops::{Invert, LinearCombination, Reduce};
use k256::elliptic_curve::point::DecompressPoint;
use k256::elliptic_curve::ops::{LinearCombination, Reduce};
// `Invert` provides the software `x.invert()/invert_vartime()`. It is used by the
// host path AND, on the riscv64 guest, by the mandatory software fallback that
// runs whenever a hinted inverse fails to verify (a lying host). It is therefore
// needed in every build, not only off-target.
use k256::elliptic_curve::ops::Invert;
use k256::elliptic_curve::sec1::ToEncodedPoint;
use k256::elliptic_curve::PrimeField;
use k256::{AffinePoint, FieldBytes, ProjectivePoint, Scalar, U256};
Expand Down Expand Up @@ -60,6 +64,158 @@ impl Crypto for LambdaVmEcsmCrypto {

// ── ECDSA secp256k1 recovery via the ECSM precompile ────────────────────────

/// Obtain a 32-byte big-endian hint for `x_be` via the executor `hint` ecall
Comment thread
jotabulacios marked this conversation as resolved.
/// (the host computes the modular inverse / sqrt; the value is provable via the
/// prover's HINT table). The result is UNTRUSTED — the ecall adds no correctness
/// constraint, so every caller MUST verify it in-guest (`x·inv == 1`, `y² == x³+7`)
/// AND recompute in software on any verification failure. The hint is only ever
/// allowed to save work, never to change the answer: because the prover chooses the
/// bytes, an unverified-or-rejected-outright hint would let it steer a caller's
/// accept/reject outcome (e.g. force a valid signature to look invalid). See
/// [`scalar_inv`] / [`decompress_r`] for the fallback that closes that hole.
#[cfg(target_arch = "riscv64")]
fn get_hint(hint_id: usize, x_be: &[u8; 32]) -> [u8; 32] {
// 8-byte-aligned output buffer so the HINT table's four 8-byte writes land on the
// aligned memory path (MEMW_A) instead of the general MEMW path. An `[u8; 32]` on
// the stack is only 1-aligned, which forces the four writes onto the unaligned
// path and inflates the trace.
#[repr(C, align(8))]
struct Aligned32([u8; 32]);
let mut out = Aligned32([0u8; 32]);
lambda_vm_syscalls::syscalls::hint(hint_id, &mut out.0, x_be);
out.0
}

/// Scalar-field inverse `x⁻¹ mod n`.
///
/// On riscv64 the inverse is first requested from the untrusted `hint` ecall and
/// verified in-guest (`x·inv == 1`); **on any verification failure it is recomputed
/// in software.** `x⁻¹` exists for every `x` this is called with — the only caller,
/// `ecsm_ecrecover`, guarantees `r ≠ 0` before calling — so a failed verify can only
/// mean the host lied, and the software value is authoritative. This is what keeps
/// the result independent of the prover-chosen hint: a bad hint makes the guest do
/// more work, it can never change the answer, so it cannot turn a valid signature
/// into a recovery failure. Off-target (host) it inverts in software directly.
fn scalar_inv(x: &Scalar) -> Option<Scalar> {
#[cfg(target_arch = "riscv64")]
{
scalar_inv_with_oracle(x, |x_be| {
get_hint(lambda_vm_syscalls::syscalls::HINT_SCALAR_INV, x_be)
})
}
#[cfg(not(target_arch = "riscv64"))]
{
x.invert_vartime().into()
}
}

/// Core of [`scalar_inv`], generic over the hint source so host tests can inject an
/// honest or a lying oracle and assert the software fallback keeps the result
/// correct either way. See [`scalar_inv`] for the verify-then-fallback rationale.
#[cfg(any(target_arch = "riscv64", test))]
fn scalar_inv_with_oracle<O>(x: &Scalar, hint: O) -> Option<Scalar>
where
O: FnOnce(&[u8; 32]) -> [u8; 32],
{
use k256::elliptic_curve::subtle::ConstantTimeEq;
let x_be: [u8; 32] = x.to_bytes().into();
let inv_be = hint(&x_be);
// Fast path: a canonical hint that verifies (x·inv == 1 mod n) is used as-is.
if let Some(inv) = Option::<Scalar>::from(Scalar::from_repr(inv_be.into())) {
if bool::from((*x * inv).ct_eq(&Scalar::ONE)) {
return Some(inv);
}
}
// Hint absent / malformed / wrong: recompute authoritatively. `x⁻¹` exists for
// every input the callers pass (`r ≠ 0`), so this is `Some` on the honest path.
x.invert_vartime().into()
}

/// Decompress R from its x-coordinate + parity.
///
/// On riscv64 the square root `y = sqrt(x³+7)` is first requested from the untrusted
/// `hint` ecall and verified in-guest (`y² == x³+7`), with parity selection; **on any
/// verification failure the point is recomputed with the software
/// `AffinePoint::decompress`.** Unlike the inverse, a failure here is *not*
/// necessarily a lying host: a genuine non-residue (an invalid signature) has no
/// root and must legitimately yield `None`. So the fallback is the authoritative
/// software decompress, which returns `Some` for a residue and `None` for a
/// non-residue regardless of the prover-chosen hint — the hint can only save work,
/// never steer the accept/reject outcome. Off-target it uses the software
/// decompress directly.
fn decompress_r(r_bytes: &FieldBytes, y_is_odd: bool) -> Option<AffinePoint> {
#[cfg(target_arch = "riscv64")]
{
decompress_r_with_oracle(r_bytes, y_is_odd, |rhs_be| {
get_hint(lambda_vm_syscalls::syscalls::HINT_FIELD_SQRT, rhs_be)
})
}
#[cfg(not(target_arch = "riscv64"))]
{
use k256::elliptic_curve::point::DecompressPoint;
AffinePoint::decompress(r_bytes, u8::from(y_is_odd).into()).into()
}
}

/// Core of [`decompress_r`], generic over the hint source for host tests: try the
/// hinted sqrt, then fall back to the authoritative software decompress on any
/// failure. See [`decompress_r`] for the rationale.
#[cfg(any(target_arch = "riscv64", test))]
fn decompress_r_with_oracle<O>(r_bytes: &FieldBytes, y_is_odd: bool, hint: O) -> Option<AffinePoint>
where
O: FnOnce(&[u8; 32]) -> [u8; 32],
{
if let Some(p) = decompress_r_hinted(r_bytes, y_is_odd, hint) {
return Some(p);
}
// Hinted root absent / malformed / wrong, OR a genuine non-residue: the software
// decompress is authoritative — `Some` for a residue, `None` for a non-residue.
use k256::elliptic_curve::point::DecompressPoint;
AffinePoint::decompress(r_bytes, u8::from(y_is_odd).into()).into()
}

/// The hint-accelerated decompress attempt: returns the point only if the hinted
/// root verifies (`y² == x³+7`); `None` on any failure, so the caller falls back to
/// the software decompress. Never the last word — a `None` here is not a decision
/// that R is invalid, only that the fast path did not produce a verified root.
#[cfg(any(target_arch = "riscv64", test))]
fn decompress_r_hinted<O>(r_bytes: &FieldBytes, y_is_odd: bool, hint: O) -> Option<AffinePoint>
where
O: FnOnce(&[u8; 32]) -> [u8; 32],
{
let x: FieldElement = Option::from(FieldElement::from_bytes(r_bytes))?;
// secp256k1: y² = x³ + 7.
let mut seven_bytes = [0u8; 32];
seven_bytes[31] = 7;
let seven: FieldElement = Option::from(FieldElement::from_bytes(&seven_bytes.into()))?;
let x3: FieldElement = x.square() * x;
let rhs: FieldElement = x3 + seven;
// Hinted sqrt (BE in/out), then verify y² == rhs canonically.
let rhs_be: [u8; 32] = rhs.to_bytes().into();
let y_be = hint(&rhs_be);
let mut y: FieldElement = Option::from(FieldElement::from_bytes(&y_be.into()))?;
let y2: FieldElement = y.square();
// Verify the untrusted root: y² must equal x³+7. Negate `y2`, not `rhs`:
// `Neg` is `negate(1)`, whose debug assert requires magnitude <= 1. `square()`
// always returns magnitude 1, whereas `rhs` is a sum carrying magnitude 2, so
// negating it would trip that assert and panic in debug builds. (The value would
// still come out right — `negate(m)` computes `2*(m+1)*P_limb - self`, which for a
// magnitude-2 operand stays non-negative — so this is a build-configuration
// hazard, not a wrong answer.)
// (`ct_eq` is unusable here for the same reason as in `field_inv`.)
if !bool::from((rhs + y2.negate(1)).normalizes_to_zero()) {
return None;
}
// Select the root whose canonical LSB matches the requested parity.
let y_odd = (y.to_bytes()[31] & 1) == 1;
if y_odd != y_is_odd {
y = -y;
}
// Build the affine point; `from_encoded_point` re-checks it's on-curve.
let ep = EncodedPoint::from_affine_coordinates(&x.to_bytes(), &y.to_bytes(), false);
Option::from(AffinePoint::from_encoded_point(&ep))
}

/// Recover the uncompressed public key bytes (X‖Y, 64 bytes) from a 64-byte
/// signature, recovery id, and 32-byte message hash. Used by the ECRECOVER
/// precompile (0x01).
Expand Down Expand Up @@ -96,15 +252,14 @@ fn ecsm_ecrecover(sig: &[u8; 64], recid: u8, msg: &[u8; 32]) -> Result<[u8; 64],
// precompile; we don't handle it (decompression simply fails), matching the
// trait default.
let y_is_odd = (recid & 1) != 0;
let r_point: Option<AffinePoint> =
AffinePoint::decompress(r_bytes, u8::from(y_is_odd).into()).into();
let r_point: Option<AffinePoint> = decompress_r(r_bytes, y_is_odd);
let Some(r_point) = r_point else {
return Err(CryptoError::RecoveryFailed);
};
let r_proj = ProjectivePoint::from(r_point);

let z = <Scalar as Reduce<U256>>::reduce_bytes(&FieldBytes::from(*msg));
let r_inv: Option<Scalar> = r.invert_vartime().into();
let r_inv: Option<Scalar> = scalar_inv(&r);
let Some(r_inv) = r_inv else {
return Err(CryptoError::RecoveryFailed);
};
Expand Down Expand Up @@ -180,6 +335,55 @@ fn ecsm_oracle(x: &FieldElement, k: &Scalar) -> Option<FieldElement> {
Option::from(FieldElement::from_bytes(&xr_le.into()))
}

/// Base-field inverse `x⁻¹ mod p`.
///
/// On riscv64 the inverse is first requested from the untrusted `hint` ecall and
/// verified in-guest (`x·inv == 1`); **on any verification failure it is recomputed
/// in software.** A bad hint can only cost the guest extra work, never change the
/// answer — it cannot steer a caller's accept/reject outcome. Off-target it inverts
/// in software directly. Returns `None` only for a genuinely non-invertible input
/// (`x = 0`), which the callers' degeneracy guards already exclude.
#[cfg(any(target_arch = "riscv64", test))]
fn field_inv(x: &FieldElement) -> Option<FieldElement> {
#[cfg(target_arch = "riscv64")]
{
field_inv_with_oracle(x, |x_be| {
get_hint(lambda_vm_syscalls::syscalls::HINT_FIELD_INV, x_be)
})
}
#[cfg(not(target_arch = "riscv64"))]
{
Option::from(x.invert())
}
}

/// Core of [`field_inv`], generic over the hint source so host tests can inject an
/// honest or a lying oracle and assert the software fallback keeps the result
/// correct either way. See [`scalar_inv`] for the verify-then-fallback rationale.
#[cfg(any(target_arch = "riscv64", test))]
fn field_inv_with_oracle<O>(x: &FieldElement, hint: O) -> Option<FieldElement>
where
O: FnOnce(&[u8; 32]) -> [u8; 32],
{
let x_be: [u8; 32] = x.to_bytes().into();
let inv_be = hint(&x_be);
// Fast path: a canonical hint that verifies (x·inv == 1 mod p) is used as-is.
// Verify by asking whether the difference normalizes to zero — a value-level test
// that skips the two full normalizations a `to_bytes()` compare pays. `ct_eq` is
// NOT a substitute: k256's FieldElement compares raw limbs *and* the magnitude and
// `normalized` tags, so a `mul` result (magnitude 1, unnormalized) never compares
// equal to the normalized `ONE` constant whatever its value.
// `Neg` is `negate(1)`, valid here because `mul` yields magnitude 1.
if let Some(inv) = Option::<FieldElement>::from(FieldElement::from_bytes(&inv_be.into())) {
if bool::from((*x * inv - FieldElement::ONE).normalizes_to_zero()) {
return Some(inv);
}
}
// Hint absent / malformed / wrong: recompute authoritatively. `None` only for a
// genuine `x = 0`, excluded by the callers' guards.
Option::from(x.invert())
}

/// Computes `k1·P1 + k2·P2` from four x-only oracle queries, or `None` if any
/// degenerate-configuration guard trips.
///
Expand Down Expand Up @@ -232,7 +436,7 @@ where
// One shared inversion for the two λ denominators and the final chord.
let den1 = y1.double() * dx1;
let den2 = y2.double() * dx2;
let inv = Option::<FieldElement>::from((den1 * den2 * dxq).invert())?;
let inv = field_inv(&(den1 * den2 * dxq))?;
let inv_den1 = inv * den2 * dxq;
let inv_den2 = inv * den1 * dxq;
let inv_dxq = inv * den1 * den2;
Expand Down
48 changes: 18 additions & 30 deletions crypto/ethrex-crypto/src/tests/ecrecover_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,36 +57,24 @@ fn make_ecdsa_fixture(d: Scalar, kk: Scalar, msg: [u8; 32]) -> ([u8; 64], u8, [u
fn ecrecover_known_answer_three_tuples() {
// Three distinct (d, kk, msg) tuples — deterministic, no RNG.
let tuples: &[(u64, u64, [u8; 32])] = &[
(
0x0000_0000_0000_0001u64,
0x0000_0000_dead_beefu64,
{
let mut m = [0u8; 32];
m[31] = 0x42;
m
},
),
(
0x00c0_ffee_dead_beef_u64,
0x0123_4567_89ab_cdef_u64,
{
let mut m = [0u8; 32];
m[0] = 0xff;
m[31] = 0x01;
m
},
),
(
0x0bad_f00d_1337_cafe,
0xfeed_face_0000_0001,
{
let mut m = [0u8; 32];
for (i, b) in m.iter_mut().enumerate() {
*b = i as u8;
}
m
},
),
(0x0000_0000_0000_0001u64, 0x0000_0000_dead_beefu64, {
let mut m = [0u8; 32];
m[31] = 0x42;
m
}),
(0x00c0_ffee_dead_beef_u64, 0x0123_4567_89ab_cdef_u64, {
let mut m = [0u8; 32];
m[0] = 0xff;
m[31] = 0x01;
m
}),
(0x0bad_f00d_1337_cafe, 0xfeed_face_0000_0001, {
let mut m = [0u8; 32];
for (i, b) in m.iter_mut().enumerate() {
*b = i as u8;
}
m
}),
];

for &(d_u64, kk_u64, msg) in tuples {
Expand Down
Loading
Loading