diff --git a/.github/workflows/pr_main.yaml b/.github/workflows/pr_main.yaml index 5d560a63a..da5472172 100644 --- a/.github/workflows/pr_main.yaml +++ b/.github/workflows/pr_main.yaml @@ -161,6 +161,9 @@ jobs: - name: Run syscalls host tests (keccak differential vs sha3) run: make test-syscalls + - name: Run ethrex-crypto host tests (hint verify-then-fallback + ecrecover) + run: make test-ethrex-crypto + # "Test" is a required check — keep this name to avoid branch protection changes. # This gate job passes only when CLI, executor, disk-spill, and prover tests succeed. test: diff --git a/Cargo.lock b/Cargo.lock index fd763f24b..bcee4a74b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -584,6 +584,7 @@ name = "executor" version = "0.1.0" dependencies = [ "ecsm", + "k256", "rustc-demangle", "serde", "serde_json", diff --git a/Makefile b/Makefile index a3471bdb4..b799317af 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -512,7 +512,12 @@ check-ethrex-fixture-checksums: test-syscalls: cd syscalls && cargo test -test: compile-programs test-syscalls +# ethrex-crypto is a detached workspace (excluded from the root members), so a +# root `cargo test` never runs it. Run it explicitly, like test-syscalls. +test-ethrex-crypto: + cd crypto/ethrex-crypto && cargo test + +test: compile-programs test-syscalls test-ethrex-crypto cargo test # === Quick test shortcuts === diff --git a/bench_vs/lambda/recursion/Cargo.lock b/bench_vs/lambda/recursion/Cargo.lock index 3e7f8e9a5..c358f86ec 100644 --- a/bench_vs/lambda/recursion/Cargo.lock +++ b/bench_vs/lambda/recursion/Cargo.lock @@ -234,6 +234,7 @@ name = "executor" version = "0.1.0" dependencies = [ "ecsm", + "k256", "rustc-demangle", "thiserror", ] diff --git a/crypto/ecsm/src/curve.rs b/crypto/ecsm/src/curve.rs index c5c9f5714..1ef5954c2 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: 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"); 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..d69340e5c 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; @@ -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"), } } } @@ -119,6 +122,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`. diff --git a/crypto/ecsm/src/witness.rs b/crypto/ecsm/src/witness.rs index 28b971383..d412ab8aa 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)] @@ -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], @@ -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 { 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 @@ -328,6 +352,7 @@ pub fn compute_witness(k_le: &[u8; 32], xg_le: &[u8; 32]) -> Result Result [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 { + #[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(x: &Scalar, hint: O) -> Option +where + O: FnOnce(&[u8; 32]) -> [u8; 32], +{ + use k256::elliptic_curve::subtle::ConstantTimeEq; + let x_be: [u8; 32] = x.to_bytes().into(); + let inv_be = hint(&x_be); + // Fast path: a canonical hint that verifies (x·inv == 1 mod n) is used as-is. + if let Some(inv) = Option::::from(Scalar::from_repr(inv_be.into())) { + if bool::from((*x * inv).ct_eq(&Scalar::ONE)) { + return Some(inv); + } + } + // Hint absent / malformed / wrong: recompute authoritatively. `x⁻¹` exists for + // every input the callers pass (`r ≠ 0`), so this is `Some` on the honest path. + x.invert_vartime().into() +} + +/// Decompress R from its x-coordinate + parity. +/// +/// On riscv64 the square root `y = sqrt(x³+7)` is first requested from the untrusted +/// `hint` ecall and verified in-guest (`y² == x³+7`), with parity selection; **on any +/// verification failure the point is recomputed with the software +/// `AffinePoint::decompress`.** Unlike the inverse, a failure here is *not* +/// necessarily a lying host: a genuine non-residue (an invalid signature) has no +/// root and must legitimately yield `None`. So the fallback is the authoritative +/// software decompress, which returns `Some` for a residue and `None` for a +/// non-residue regardless of the prover-chosen hint — the hint can only save work, +/// never steer the accept/reject outcome. Off-target it uses the software +/// decompress directly. +fn decompress_r(r_bytes: &FieldBytes, y_is_odd: bool) -> Option { + #[cfg(target_arch = "riscv64")] + { + 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(r_bytes: &FieldBytes, y_is_odd: bool, hint: O) -> Option +where + O: FnOnce(&[u8; 32]) -> [u8; 32], +{ + if let Some(p) = decompress_r_hinted(r_bytes, y_is_odd, hint) { + return Some(p); + } + // Hinted root absent / malformed / wrong, OR a genuine non-residue: the software + // decompress is authoritative — `Some` for a residue, `None` for a non-residue. + use k256::elliptic_curve::point::DecompressPoint; + AffinePoint::decompress(r_bytes, u8::from(y_is_odd).into()).into() +} + +/// The hint-accelerated decompress attempt: returns the point only if the hinted +/// root verifies (`y² == x³+7`); `None` on any failure, so the caller falls back to +/// the software decompress. Never the last word — a `None` here is not a decision +/// that R is invalid, only that the fast path did not produce a verified root. +#[cfg(any(target_arch = "riscv64", test))] +fn decompress_r_hinted(r_bytes: &FieldBytes, y_is_odd: bool, hint: O) -> Option +where + O: FnOnce(&[u8; 32]) -> [u8; 32], +{ + let x: FieldElement = Option::from(FieldElement::from_bytes(r_bytes))?; + // secp256k1: y² = x³ + 7. + let mut seven_bytes = [0u8; 32]; + seven_bytes[31] = 7; + let seven: FieldElement = Option::from(FieldElement::from_bytes(&seven_bytes.into()))?; + let x3: FieldElement = x.square() * x; + let rhs: FieldElement = x3 + seven; + // Hinted sqrt (BE in/out), then verify y² == rhs canonically. + let rhs_be: [u8; 32] = rhs.to_bytes().into(); + let y_be = hint(&rhs_be); + let mut y: FieldElement = Option::from(FieldElement::from_bytes(&y_be.into()))?; + let y2: FieldElement = y.square(); + // Verify the untrusted root: y² must equal x³+7. Negate `y2`, not `rhs`: + // `Neg` is `negate(1)` and only accepts magnitude 1, which `square()` always + // returns, whereas `rhs` is a sum and carries magnitude 2 — negating it would + // silently compute the wrong value in release, where the debug assert is gone. + // (`ct_eq` is unusable here for the same reason as in `field_inv`.) + if !bool::from((rhs + y2.negate(1)).normalizes_to_zero()) { + return None; + } + // Select the root whose canonical LSB matches the requested parity. + let y_odd = (y.to_bytes()[31] & 1) == 1; + if y_odd != y_is_odd { + y = -y; + } + // Build the affine point; `from_encoded_point` re-checks it's on-curve. + let ep = EncodedPoint::from_affine_coordinates(&x.to_bytes(), &y.to_bytes(), false); + Option::from(AffinePoint::from_encoded_point(&ep)) +} + /// Recover the uncompressed public key bytes (X‖Y, 64 bytes) from a 64-byte /// signature, recovery id, and 32-byte message hash. Used by the ECRECOVER /// precompile (0x01). @@ -96,15 +249,14 @@ fn ecsm_ecrecover(sig: &[u8; 64], recid: u8, msg: &[u8; 32]) -> Result<[u8; 64], // precompile; we don't handle it (decompression simply fails), matching the // trait default. let y_is_odd = (recid & 1) != 0; - let r_point: Option = - AffinePoint::decompress(r_bytes, u8::from(y_is_odd).into()).into(); + let r_point: Option = decompress_r(r_bytes, y_is_odd); let Some(r_point) = r_point else { return Err(CryptoError::RecoveryFailed); }; let r_proj = ProjectivePoint::from(r_point); let z = >::reduce_bytes(&FieldBytes::from(*msg)); - let r_inv: Option = r.invert_vartime().into(); + let r_inv: Option = scalar_inv(&r); let Some(r_inv) = r_inv else { return Err(CryptoError::RecoveryFailed); }; @@ -133,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`. /// -/// 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 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, @@ -157,41 +309,106 @@ 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·(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 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, k: &Scalar) -> Option { +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 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, &input, &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())) } -/// Computes `k1·P1 + k2·P2` from four x-only oracle queries, or `None` if any -/// degenerate-configuration guard trips. +/// Base-field inverse `x⁻¹ mod p`. /// -/// 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. +/// On riscv64 the inverse is first requested from the untrusted `hint` ecall and +/// verified in-guest (`x·inv == 1`); **on any verification failure it is recomputed +/// in software.** A bad hint can only cost the guest extra work, never change the +/// answer — it cannot steer a caller's accept/reject outcome. Off-target it inverts +/// in software directly. Returns `None` only for a genuinely non-invertible input +/// (`x = 0`), which the callers' degeneracy guards already exclude. +#[cfg(any(target_arch = "riscv64", test))] +fn field_inv(x: &FieldElement) -> Option { + #[cfg(target_arch = "riscv64")] + { + 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(x: &FieldElement, hint: O) -> Option +where + O: FnOnce(&[u8; 32]) -> [u8; 32], +{ + let x_be: [u8; 32] = x.to_bytes().into(); + let inv_be = hint(&x_be); + // Fast path: a canonical hint that verifies (x·inv == 1 mod p) is used as-is. + // Verify by asking whether the difference normalizes to zero — a value-level test + // that skips the two full normalizations a `to_bytes()` compare pays. `ct_eq` is + // NOT a substitute: k256's FieldElement compares raw limbs *and* the magnitude and + // `normalized` tags, so a `mul` result (magnitude 1, unnormalized) never compares + // equal to the normalized `ONE` constant whatever its value. + // `Neg` is `negate(1)`, valid here because `mul` yields magnitude 1. + if let Some(inv) = Option::::from(FieldElement::from_bytes(&inv_be.into())) { + if bool::from((*x * inv - FieldElement::ONE).normalizes_to_zero()) { + return Some(inv); + } + } + // Hint absent / malformed / wrong: recompute authoritatively. `None` only for a + // genuine `x = 0`, excluded by the callers' guards. + Option::from(x.invert()) +} + +/// Computes `k1·P1 + k2·P2` from two affine oracle queries, or `None` if a +/// degenerate configuration trips a guard. +/// +/// 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))] @@ -203,45 +420,33 @@ fn lincomb2_with_oracle( oracle: O, ) -> Option where - O: Fn(&FieldElement, &Scalar) -> Option, + 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. 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 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)?; - 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 = Option::::from((den1 * den2 * dxq).invert())?; - 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(); @@ -252,38 +457,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/ecrecover_tests.rs b/crypto/ethrex-crypto/src/tests/ecrecover_tests.rs index f9c1d9242..af2ab1f1d 100644 --- a/crypto/ethrex-crypto/src/tests/ecrecover_tests.rs +++ b/crypto/ethrex-crypto/src/tests/ecrecover_tests.rs @@ -57,36 +57,24 @@ fn make_ecdsa_fixture(d: Scalar, kk: Scalar, msg: [u8; 32]) -> ([u8; 64], u8, [u fn ecrecover_known_answer_three_tuples() { // Three distinct (d, kk, msg) tuples — deterministic, no RNG. let tuples: &[(u64, u64, [u8; 32])] = &[ - ( - 0x0000_0000_0000_0001u64, - 0x0000_0000_dead_beefu64, - { - let mut m = [0u8; 32]; - m[31] = 0x42; - m - }, - ), - ( - 0x00c0_ffee_dead_beef_u64, - 0x0123_4567_89ab_cdef_u64, - { - let mut m = [0u8; 32]; - m[0] = 0xff; - m[31] = 0x01; - m - }, - ), - ( - 0x0bad_f00d_1337_cafe, - 0xfeed_face_0000_0001, - { - let mut m = [0u8; 32]; - for (i, b) in m.iter_mut().enumerate() { - *b = i as u8; - } - m - }, - ), + (0x0000_0000_0000_0001u64, 0x0000_0000_dead_beefu64, { + let mut m = [0u8; 32]; + m[31] = 0x42; + m + }), + (0x00c0_ffee_dead_beef_u64, 0x0123_4567_89ab_cdef_u64, { + let mut m = [0u8; 32]; + m[0] = 0xff; + m[31] = 0x01; + m + }), + (0x0bad_f00d_1337_cafe, 0xfeed_face_0000_0001, { + let mut m = [0u8; 32]; + for (i, b) in m.iter_mut().enumerate() { + *b = i as u8; + } + m + }), ]; for &(d_u64, kk_u64, msg) in tuples { diff --git a/crypto/ethrex-crypto/src/tests/ecsm_tests.rs b/crypto/ethrex-crypto/src/tests/ecsm_tests.rs index 89c911db7..f10259f50 100644 --- a/crypto/ethrex-crypto/src/tests/ecsm_tests.rs +++ b/crypto/ethrex-crypto/src/tests/ecsm_tests.rs @@ -1,25 +1,21 @@ -//! 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::*; -/// 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 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 { - 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())?; +/// 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(); - Some(affine_xy(&prod)?.0) + let (xr, yr) = affine_xy(&prod)?; + Some((xr.normalize(), yr.normalize())) } fn g_times(n: u64) -> ProjectivePoint { @@ -57,12 +53,25 @@ fn matches_software_lincomb_on_recovery_shape() { #[test] fn edge_scalars_fall_back() { + // 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, 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()); + 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); + 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()); } } @@ -86,10 +95,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 +130,11 @@ 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. + // 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); diff --git a/crypto/ethrex-crypto/src/tests/hint_tests.rs b/crypto/ethrex-crypto/src/tests/hint_tests.rs new file mode 100644 index 000000000..a7e992577 --- /dev/null +++ b/crypto/ethrex-crypto/src/tests/hint_tests.rs @@ -0,0 +1,189 @@ +//! Host tests for the untrusted-hint verify-then-fallback paths (`scalar_inv`, +//! `field_inv`, `decompress_r`). +//! +//! The guest asks the (untrusted, prover-chosen) `hint` ecall for a modular +//! inverse / square root, then verifies it in-circuit. These tests inject the +//! oracle directly — an *honest* oracle (matching the executor's `compute_hint`) +//! and a *lying* one — and assert the software fallback makes the result identical +//! either way. That is the property C1 turns on: a bad hint can only make the guest +//! do more work, never change its accept/reject outcome. On the guest this code is +//! `cfg(target_arch = "riscv64")`; the `test` gate on `*_with_oracle` is what lets +//! CI compile and exercise it on the host. + +use crate::*; + +/// A `[u8; 32]` big-endian field element from a small integer. +fn fe_from_u64(k: u64) -> FieldElement { + let mut be = [0u8; 32]; + be[24..32].copy_from_slice(&k.to_be_bytes()); + Option::::from(FieldElement::from_bytes(&be.into())).expect("k < p") +} + +/// Honest scalar-inverse oracle (BE in/out, mod n) — mirrors the executor's +/// `compute_hint(HINT_SCALAR_INV, ..)`: the inverse if it exists, else zeros. +fn honest_scalar_inv(x_be: &[u8; 32]) -> [u8; 32] { + let x = Option::::from(Scalar::from_repr((*x_be).into())).expect("canonical input"); + match Option::::from(x.invert()) { + Some(inv) => inv.to_bytes().into(), + None => [0u8; 32], + } +} + +/// Honest base-field sqrt oracle (BE in/out, mod p) — mirrors +/// `compute_hint(HINT_FIELD_SQRT, ..)`: a root if one exists, else zeros. +fn honest_field_sqrt(rhs_be: &[u8; 32]) -> [u8; 32] { + let rhs = Option::::from(FieldElement::from_bytes(&(*rhs_be).into())) + .expect("canonical"); + match Option::::from(rhs.sqrt()) { + Some(y) => y.to_bytes().into(), + None => [0u8; 32], + } +} + +fn sec1(p: &AffinePoint) -> Vec { + p.to_encoded_point(false).as_bytes().to_vec() +} + +#[test] +fn scalar_inv_honest_hint_matches_software() { + for k in [1u64, 2, 3, 7, 1000, 12345, u64::MAX] { + let x = Scalar::from(k); + let sw = x.invert_vartime().expect("k != 0 is invertible"); + let got = scalar_inv_with_oracle(&x, honest_scalar_inv).expect("inverse exists"); + assert_eq!( + got, sw, + "honest hint must equal the software inverse (k={k})" + ); + } +} + +#[test] +fn scalar_inv_lying_hint_falls_back_to_software() { + // The prover-chosen hint returns garbage; the result must be unchanged. `x⁻¹` + // exists (the caller guarantees `r != 0`), so the software fallback is + // authoritative — a lie cannot turn a recoverable signature into a failure. + for lie in [[0u8; 32], [0xFFu8; 32]] { + for k in [1u64, 2, 12345, u64::MAX] { + let x = Scalar::from(k); + let sw = x.invert_vartime().unwrap(); + let got = scalar_inv_with_oracle(&x, |_| lie).expect("fallback recomputes"); + assert_eq!( + got, sw, + "lying hint must fall back to the software inverse (k={k})" + ); + } + } +} + +#[test] +fn decompress_r_honest_hint_matches_software() { + // x-coordinates of real points are guaranteed residues. + for k in [1u64, 2, 5, 12345] { + let p = (ProjectivePoint::GENERATOR * Scalar::from(k)).to_affine(); + let (x, y) = affine_xy(&p).unwrap(); + let rb = x.to_bytes(); + let y_is_odd = (y.normalize().to_bytes()[31] & 1) == 1; + let got = decompress_r_with_oracle(&rb, y_is_odd, honest_field_sqrt) + .expect("valid residue decompresses"); + assert_eq!( + sec1(&got), + sec1(&p), + "honest hint must recover the point (k={k})" + ); + } +} + +#[test] +fn decompress_r_lying_hint_falls_back_to_software() { + // A residue x with a garbage sqrt hint must still decompress to the true point. + for lie in [[0u8; 32], [0xFFu8; 32]] { + for k in [1u64, 5, 12345] { + let p = (ProjectivePoint::GENERATOR * Scalar::from(k)).to_affine(); + let (x, y) = affine_xy(&p).unwrap(); + let rb = x.to_bytes(); + let y_is_odd = (y.normalize().to_bytes()[31] & 1) == 1; + let got = decompress_r_with_oracle(&rb, y_is_odd, |_| lie) + .expect("software fallback decompresses a residue"); + assert_eq!( + sec1(&got), + sec1(&p), + "lying hint must fall back to software (k={k})" + ); + } + } +} + +#[test] +fn decompress_r_non_residue_is_none_regardless_of_hint() { + // Find a small x whose x³+7 has no square root: R is genuinely undecompressable + // and must be `None`. A lying hint must NOT be able to force a `Some`, and the + // honest path must NOT spuriously fail — both stem from the same software + // fallback being the sole authority on rejection. + let mut seven = [0u8; 32]; + seven[31] = 7; + let seven = Option::::from(FieldElement::from_bytes(&seven.into())).unwrap(); + + let x = (1u64..10_000) + .map(fe_from_u64) + .find(|x| { + let rhs = (x.square() * *x + seven).normalize(); + Option::::from(rhs.sqrt()).is_none() + }) + .expect("some small x has a non-residue x³+7"); + let rb = x.to_bytes(); + + assert!( + decompress_r_with_oracle(&rb, false, honest_field_sqrt).is_none(), + "a genuine non-residue must decompress to None (honest hint)" + ); + for lie in [[0u8; 32], [0xFFu8; 32]] { + assert!( + decompress_r_with_oracle(&rb, false, |_| lie).is_none(), + "a lying hint must not force a non-residue to decompress" + ); + } +} + +/// Honest base-field inverse oracle (BE in/out, mod p) — mirrors the executor's +/// `compute_hint(HINT_FIELD_INV, ..)`: the inverse if it exists, else zeros. +fn honest_field_inv(x_be: &[u8; 32]) -> [u8; 32] { + let x = Option::::from(FieldElement::from_bytes(&(*x_be).into())) + .expect("canonical input"); + match Option::::from(x.invert()) { + Some(inv) => inv.to_bytes().into(), + None => [0u8; 32], + } +} + +#[test] +fn field_inv_honest_hint_matches_software() { + for k in [1u64, 2, 3, 7, 1000, 12345] { + let x = fe_from_u64(k); + let sw = Option::::from(x.invert()).expect("k != 0 is invertible"); + let got = field_inv_with_oracle(&x, honest_field_inv).expect("inverse exists"); + assert_eq!( + got.normalize().to_bytes(), + sw.normalize().to_bytes(), + "honest hint must equal the software inverse (k={k})" + ); + } +} + +#[test] +fn field_inv_lying_hint_falls_back_to_software() { + // A prover-chosen garbage inverse must not change the result: `x⁻¹` exists for + // every input the callers pass (guarded non-zero denominators), so the software + // fallback is authoritative — a lie can only cost work, never steer the outcome. + for lie in [[0u8; 32], [0xFFu8; 32]] { + for k in [1u64, 2, 12345] { + let x = fe_from_u64(k); + let sw = Option::::from(x.invert()).unwrap(); + let got = field_inv_with_oracle(&x, |_| lie).expect("fallback recomputes"); + assert_eq!( + got.normalize().to_bytes(), + sw.normalize().to_bytes(), + "lying hint must fall back to the software inverse (k={k})" + ); + } + } +} diff --git a/crypto/ethrex-crypto/src/tests/keccak_tests.rs b/crypto/ethrex-crypto/src/tests/keccak_tests.rs index cde649fcb..14d497520 100644 --- a/crypto/ethrex-crypto/src/tests/keccak_tests.rs +++ b/crypto/ethrex-crypto/src/tests/keccak_tests.rs @@ -8,7 +8,12 @@ use crate::*; fn check_keccak(input: &[u8]) { let got = keccak256_with_permute(input, keccak::f1600); let want = keccak_hash(input); - assert_eq!(got, want, "keccak256 mismatch for {}-byte input", input.len()); + assert_eq!( + got, + want, + "keccak256 mismatch for {}-byte input", + input.len() + ); } /// Cross-check our sponge against a hardcoded vector from the Ethereum spec. diff --git a/crypto/ethrex-crypto/src/tests/mod.rs b/crypto/ethrex-crypto/src/tests/mod.rs index f050a8e48..37fc9b3a0 100644 --- a/crypto/ethrex-crypto/src/tests/mod.rs +++ b/crypto/ethrex-crypto/src/tests/mod.rs @@ -3,4 +3,6 @@ pub mod ecrecover_tests; #[cfg(test)] pub mod ecsm_tests; #[cfg(test)] +pub mod hint_tests; +#[cfg(test)] pub mod keccak_tests; diff --git a/executor/Cargo.toml b/executor/Cargo.toml index 3f278e1c6..fb890e353 100644 --- a/executor/Cargo.toml +++ b/executor/Cargo.toml @@ -8,6 +8,9 @@ license.workspace = true thiserror = "1.0.68" rustc-demangle = "0.1" ecsm = { path = "../crypto/ecsm" } +# Host-side computation of non-constraining hints (modular inverse / sqrt) for the +# `Hint` ecall — same k256 arithmetic the guest verifies against. BENCH ONLY. +k256 = { version = "0.13", default-features = false, features = ["arithmetic", "expose-field"] } [dev-dependencies] serde = { version = "1.0", features = ["derive"] } diff --git a/executor/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..dc4399287 --- /dev/null +++ b/executor/programs/rust/ecsm_affine/Cargo.lock @@ -0,0 +1,331 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-default" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "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 = [ + "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 = "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/programs/rust/hint_min/.cargo/config.toml b/executor/programs/rust/hint_min/.cargo/config.toml new file mode 100644 index 000000000..ca99a3f45 --- /dev/null +++ b/executor/programs/rust/hint_min/.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/hint_min/Cargo.lock b/executor/programs/rust/hint_min/Cargo.lock new file mode 100644 index 000000000..cc02eff98 --- /dev/null +++ b/executor/programs/rust/hint_min/Cargo.lock @@ -0,0 +1,331 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-default" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "embedded-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f2de9133f68db0d4627ad69db767726c99ff8585272716708227008d3f1bddd" +dependencies = [ + "const-default", + "critical-section", + "linked_list_allocator", + "rlsf", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hint_min" +version = "0.1.0" +dependencies = [ + "lambda-vm-syscalls", +] + +[[package]] +name = "lambda-vm-syscalls" +version = "0.1.0" +dependencies = [ + "embedded-alloc", + "getrandom 0.2.17", + "getrandom 0.3.4", + "lazy_static", + "rand", + "riscv", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linked_list_allocator" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "riscv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" +dependencies = [ + "critical-section", + "embedded-hal", + "paste", + "riscv-macros", + "riscv-pac", +] + +[[package]] +name = "riscv-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "riscv-pac" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" + +[[package]] +name = "rlsf" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1646a59a9734b8b7a0ac51689388a60fe1625d4b956348e9de07591a1478457a" +dependencies = [ + "cfg-if", + "const-default", + "libc", + "rustversion", + "svgbobdoc", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "svgbobdoc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c04b93fc15d79b39c63218f15e3fdffaa4c227830686e3b7c5f41244eb3e50" +dependencies = [ + "base64", + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-width", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/executor/programs/rust/hint_min/Cargo.toml b/executor/programs/rust/hint_min/Cargo.toml new file mode 100644 index 000000000..4bfe4614f --- /dev/null +++ b/executor/programs/rust/hint_min/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] + +[package] +name = "hint_min" +version = "0.1.0" +edition = "2024" + +[dependencies] +lambda-vm-syscalls = { path = "../../../../syscalls" } diff --git a/executor/programs/rust/hint_min/src/main.rs b/executor/programs/rust/hint_min/src/main.rs new file mode 100644 index 000000000..2a93ff4eb --- /dev/null +++ b/executor/programs/rust/hint_min/src/main.rs @@ -0,0 +1,30 @@ +//! Minimal P0 guest for the Hint prover table: one `hint` ecall (field inverse of +//! a small value) + commit the result. No in-guest verify — this exercises exactly +//! the Hint table's bus surface (Ecall receive, the register read binding `out_addr` +//! to `a2`, four 8-byte MEMW writes and the output range checks; the input read is +//! deliberately not modelled) so we can get prove→verify to balance before scaling +//! to ethrex. +//! +//! Buffers are 8-byte aligned so the writes land in the aligned MEMW table, which is +//! a preference rather than a requirement — `classify_memw` routes unaligned accesses +//! to the general MEMW table, and the ethrex call site is in fact unaligned. + +use lambda_vm_syscalls as syscalls; + +#[repr(align(8))] +struct Aligned32([u8; 32]); + +pub fn main() { + // input = 3 (big-endian), a valid invertible field element. + let mut x = Aligned32([0u8; 32]); + x.0[31] = 3; + let mut inv = Aligned32([0u8; 32]); + + syscalls::syscalls::hint( + syscalls::syscalls::HINT_FIELD_INV, + &mut inv.0, + &x.0, + ); + + syscalls::syscalls::commit(&inv.0); +} diff --git a/executor/programs/rust/hint_multi/.cargo/config.toml b/executor/programs/rust/hint_multi/.cargo/config.toml new file mode 100644 index 000000000..ca99a3f45 --- /dev/null +++ b/executor/programs/rust/hint_multi/.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/hint_multi/Cargo.lock b/executor/programs/rust/hint_multi/Cargo.lock new file mode 100644 index 000000000..9803c875a --- /dev/null +++ b/executor/programs/rust/hint_multi/Cargo.lock @@ -0,0 +1,331 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-default" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "embedded-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f2de9133f68db0d4627ad69db767726c99ff8585272716708227008d3f1bddd" +dependencies = [ + "const-default", + "critical-section", + "linked_list_allocator", + "rlsf", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hint_multi" +version = "0.1.0" +dependencies = [ + "lambda-vm-syscalls", +] + +[[package]] +name = "lambda-vm-syscalls" +version = "0.1.0" +dependencies = [ + "embedded-alloc", + "getrandom 0.2.17", + "getrandom 0.3.4", + "lazy_static", + "rand", + "riscv", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linked_list_allocator" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "riscv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" +dependencies = [ + "critical-section", + "embedded-hal", + "paste", + "riscv-macros", + "riscv-pac", +] + +[[package]] +name = "riscv-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "riscv-pac" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" + +[[package]] +name = "rlsf" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1646a59a9734b8b7a0ac51689388a60fe1625d4b956348e9de07591a1478457a" +dependencies = [ + "cfg-if", + "const-default", + "libc", + "rustversion", + "svgbobdoc", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "svgbobdoc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c04b93fc15d79b39c63218f15e3fdffaa4c227830686e3b7c5f41244eb3e50" +dependencies = [ + "base64", + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-width", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/executor/programs/rust/hint_multi/Cargo.toml b/executor/programs/rust/hint_multi/Cargo.toml new file mode 100644 index 000000000..faacdb38e --- /dev/null +++ b/executor/programs/rust/hint_multi/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] + +[package] +name = "hint_multi" +version = "0.1.0" +edition = "2024" + +[dependencies] +lambda-vm-syscalls = { path = "../../../../syscalls" } diff --git a/executor/programs/rust/hint_multi/src/main.rs b/executor/programs/rust/hint_multi/src/main.rs new file mode 100644 index 000000000..67f2ab90f --- /dev/null +++ b/executor/programs/rust/hint_multi/src/main.rs @@ -0,0 +1,35 @@ +//! Multi-hint P0/P2 guest for the Hint prover table: THREE `hint` ecalls +//! (field inverse of three different small values), each result read back with +//! ordinary `LOAD`s (XOR-accumulated) and the accumulator committed. +//! +//! Complements `hint_min` (one hint, read back via `commit`): this exercises the +//! parts the ethrex consumer relies on that a single-call guest does not — +//! **multiple real HINT rows** (padded to a power of two) and **read-back of the +//! hinted output via normal `LOAD` instructions** (whose MEMW reads must chain to +//! the HINT table's writes). Buffers are 8-byte aligned so the writes land in the +//! aligned MEMW table. + +use lambda_vm_syscalls as syscalls; + +#[repr(align(8))] +struct Aligned32([u8; 32]); + +pub fn main() { + let mut acc = Aligned32([0u8; 32]); + + for seed in [3u8, 5u8, 7u8] { + let mut x = Aligned32([0u8; 32]); + x.0[31] = seed; + let mut inv = Aligned32([0u8; 32]); + + syscalls::syscalls::hint(syscalls::syscalls::HINT_FIELD_INV, &mut inv.0, &x.0); + + // Read the hinted output back via ordinary loads and fold it in, so the + // MEMW reads of `inv` must chain to the HINT table's writes. + for i in 0..32 { + acc.0[i] ^= inv.0[i]; + } + } + + syscalls::syscalls::commit(&acc.0); +} diff --git a/executor/src/tests/ecsm_tests.rs b/executor/src/tests/ecsm_tests.rs index 0fa240a8e..3a80abc08 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(); @@ -122,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/tests/hint_tests.rs b/executor/src/tests/hint_tests.rs new file mode 100644 index 000000000..f32f25aea --- /dev/null +++ b/executor/src/tests/hint_tests.rs @@ -0,0 +1,167 @@ +//! Tests for the non-constraining `Hint` syscall. + +use crate::vm::instruction::decoding::Instruction; +use crate::vm::instruction::execution::{ + ExecutionError, HINT_FIELD_INV, HINT_FIELD_SQRT, HINT_SCALAR_INV, HINT_SYSCALL_NUMBER, + compute_hint, +}; +use crate::vm::memory::Memory; +use crate::vm::registers::Registers; + +fn write_u256(memory: &mut Memory, addr: u64, bytes: &[u8; 32]) { + for i in 0..4 { + let mut dw = [0u8; 8]; + dw.copy_from_slice(&bytes[i * 8..i * 8 + 8]); + memory + .store_doubleword(addr + (i as u64) * 8, u64::from_le_bytes(dw)) + .unwrap(); + } +} + +fn read_u256(memory: &Memory, addr: u64) -> [u8; 32] { + let mut out = [0u8; 32]; + for i in 0..4 { + let dw = memory.load_doubleword(addr + (i as u64) * 8).unwrap(); + out[i * 8..i * 8 + 8].copy_from_slice(&dw.to_le_bytes()); + } + out +} + +/// Runs one `Hint` ecall with the given operand addresses, returning the 32 bytes +/// written at `out_addr`. +fn run_hint_at( + hint_id: u64, + in_addr: u64, + out_addr: u64, + input: &[u8; 32], +) -> Result<[u8; 32], ExecutionError> { + let mut memory = Memory::default(); + let mut registers = Registers::default(); + let mut pc = 0u64; + + write_u256(&mut memory, in_addr, input); + registers.write(17, HINT_SYSCALL_NUMBER).unwrap(); + registers.write(10, hint_id).unwrap(); + registers.write(11, in_addr).unwrap(); + registers.write(12, out_addr).unwrap(); + Instruction::EcallEbreak.run(&mut pc, &mut registers, &mut memory)?; + Ok(read_u256(&memory, out_addr)) +} + +/// The base-field inverse hint round-trips through guest memory, big-endian in and +/// out, and matches `compute_hint` (the value the prover recomputes). +#[test] +fn hint_syscall_writes_the_field_inverse() { + let mut input = [0u8; 32]; + input[31] = 3; // 3, big-endian + + let out = run_hint_at(HINT_FIELD_INV, 0x1000, 0x2000, &input).expect("hint must run"); + assert_eq!(out, compute_hint(HINT_FIELD_INV, &input)); + + // 3 · 3⁻¹ ≡ 1 (mod p) — the same check the guest performs on the untrusted value. + let three: k256::FieldElement = + Option::from(k256::FieldElement::from_bytes(&input.into())).unwrap(); + let inv: k256::FieldElement = + Option::from(k256::FieldElement::from_bytes(&out.into())).unwrap(); + assert_eq!( + (three * inv).to_bytes(), + k256::FieldElement::ONE.to_bytes(), + "hinted inverse must satisfy x·inv == 1" + ); +} + +/// Both operands must keep their 32-byte range inside the lower address limb: the +/// HINT table sends the output writes as `[out_addr_lo + 8i, out_addr_hi]`, which +/// cannot represent a carry into the high limb, so a straddling operand would make +/// the trace unprovable. The executor rejects it upfront instead. +#[test] +fn hint_syscall_rejects_address_overflow() { + let input = [0u8; 32]; + // Last accessed byte is at +31, so the first rejected base is 2^32 - 31. + for (in_addr, out_addr) in [ + (0x1000, 0xFFFF_FFE8), + (0xFFFF_FFE8, 0x2000), + (0x1000, 0xFFFF_FFE1), + (0xFFFF_FFE1, 0x2000), + (0x1000, 0xFFFF_FFFF), + ] { + let err = run_hint_at(HINT_FIELD_INV, in_addr, out_addr, &input) + .expect_err("straddling operand must be rejected"); + assert!( + matches!(err, ExecutionError::HintAddressOverflow), + "expected address overflow for in={in_addr:#x}, out={out_addr:#x}, got {err:?}" + ); + } +} + +/// The boundary case: an operand ending exactly on the last byte of the limb is +/// still representable and must be accepted. +#[test] +fn hint_syscall_accepts_operand_ending_at_the_limb_boundary() { + let input = [0u8; 32]; + // 2^32 - 32: last byte lands at 2^32 - 1, the largest in-limb address. + run_hint_at(HINT_FIELD_INV, 0x1000, 0xFFFF_FFE0, &input) + .expect("operand ending at the limb boundary must run"); + run_hint_at(HINT_FIELD_INV, 0xFFFF_FFE0, 0x2000, &input) + .expect("operand ending at the limb boundary must run"); +} + +/// The scalar-field inverse hint (mod n) round-trips through guest memory and +/// satisfies `x·inv == 1 (mod n)` — the check the guest performs on the untrusted +/// value. Used by production ecrecover (`r⁻¹`). +#[test] +fn hint_syscall_writes_the_scalar_inverse() { + use k256::elliptic_curve::PrimeField; + + let mut input = [0u8; 32]; + input[31] = 3; // 3, big-endian + + let out = run_hint_at(HINT_SCALAR_INV, 0x1000, 0x2000, &input).expect("hint must run"); + assert_eq!(out, compute_hint(HINT_SCALAR_INV, &input)); + + let three: k256::Scalar = Option::from(k256::Scalar::from_repr(input.into())).unwrap(); + let inv: k256::Scalar = Option::from(k256::Scalar::from_repr(out.into())).unwrap(); + assert_eq!( + (three * inv).to_bytes(), + k256::Scalar::ONE.to_bytes(), + "hinted scalar inverse must satisfy x·inv == 1 (mod n)" + ); +} + +/// The base-field sqrt hint (mod p) round-trips and satisfies `y² == rhs (mod p)`. +/// Used by production ecrecover (decompressing R). `4 = 2²` is a residue. +#[test] +fn hint_syscall_writes_the_field_sqrt() { + let mut input = [0u8; 32]; + input[31] = 4; // rhs = 4, big-endian + + let out = run_hint_at(HINT_FIELD_SQRT, 0x1000, 0x2000, &input).expect("hint must run"); + assert_eq!(out, compute_hint(HINT_FIELD_SQRT, &input)); + + let rhs: k256::FieldElement = + Option::from(k256::FieldElement::from_bytes(&input.into())).unwrap(); + let y: k256::FieldElement = Option::from(k256::FieldElement::from_bytes(&out.into())).unwrap(); + assert_eq!( + y.square().to_bytes(), + rhs.to_bytes(), + "hinted sqrt must satisfy y² == rhs (mod p)" + ); +} + +/// An unknown `hint_id` is rejected up front. Silently writing zeros would be +/// indistinguishable from a legitimate numeric failure and — because the guest reads +/// the value back — could let a prover-chosen selector steer a caller's accept/reject +/// outcome. The executor traps so a guest bug surfaces loudly. `HINT_FIELD_SQRT = 2` +/// is the last known selector, so 3 is the first unknown one. +#[test] +fn hint_syscall_rejects_an_unknown_selector() { + let mut input = [0u8; 32]; + input[31] = 3; + for bad in [3u64, 100, u64::MAX] { + let err = run_hint_at(bad, 0x1000, 0x2000, &input).expect_err("unknown selector must trap"); + assert!( + matches!(err, ExecutionError::HintUnknownSelector(id) if id == bad), + "expected HintUnknownSelector({bad}), got {err:?}" + ); + } +} diff --git a/executor/src/tests/mod.rs b/executor/src/tests/mod.rs index 456607433..244447b22 100644 --- a/executor/src/tests/mod.rs +++ b/executor/src/tests/mod.rs @@ -1,4 +1,5 @@ pub mod ecsm_tests; pub mod flamegraph_tests; +pub mod hint_tests; pub mod keccak_tests; pub mod memory_tests; diff --git a/executor/src/vm/instruction/execution.rs b/executor/src/vm/instruction/execution.rs index c92c0ab88..6253f6bcd 100644 --- a/executor/src/vm/instruction/execution.rs +++ b/executor/src/vm/instruction/execution.rs @@ -16,6 +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 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). @@ -29,8 +34,33 @@ 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. +/// +/// The host computes a modular inverse or square root and writes it back to the +/// guest, which MUST verify it (e.g. `x·inv == 1`) and recompute in software on a +/// verification failure. The ecall adds no in-circuit correctness constraint of its +/// own — it lets the guest replace an expensive computation with a cheap check, +/// without letting the (prover-chosen) hinted value change the guest's result. +pub const HINT_SYSCALL_NUMBER: u64 = u64::MAX - 30; + +/// Hint operation selector passed in `a0`. +pub const HINT_FIELD_INV: u64 = 0; // secp256k1 base-field inverse (mod p) +pub const HINT_SCALAR_INV: u64 = 1; // secp256k1 scalar-field inverse (mod n) +pub const HINT_FIELD_SQRT: u64 = 2; // secp256k1 base-field square root + /// `2^32`. ECSM memory operands must not overflow their lower 32-bit address limb when the /// largest per-access offset is added: the 32-byte operands reach offset +31 (last byte). const LOW_LIMB: u64 = 1 << 32; @@ -45,6 +75,8 @@ 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(()), } } @@ -64,11 +96,12 @@ 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 - | SyscallNumbers::Halt => None, + | SyscallNumbers::Halt + | SyscallNumbers::Hint => None, } } } @@ -93,8 +126,59 @@ fn store_u256_le(memory: &mut Memory, addr: u64, bytes: &[u8; 32]) -> Result<(), Ok(()) } -/// Checks the ECSM address-alignment assumption: `(addr mod 2^32) + max_offset < 2^32`. -fn ecsm_addr_ok(addr: u64, max_offset: u64) -> bool { +/// Compute a non-constraining hint (modular inverse / sqrt) with the same k256 +/// arithmetic the guest verifies against. Input/output are 32-byte big-endian, +/// k256's own serialization — unlike the ECSM ABI, which is little-endian because +/// its chip consumes little-endian limbs. The HINT table only copies these bytes +/// into memory writes, so the order is free to match the consumers. +/// +/// On a numeric failure (non-canonical input, no inverse/sqrt) returns zeros. This +/// is NOT a loud failure and must not be treated as one: the guest's in-circuit +/// verify rejects the value and recomputes it in software (see the `ethrex-crypto` +/// crate), so a zero/garbage hint only costs the guest extra work — it can never +/// change the guest's result. An *unknown* `hint_id` never reaches here: the ecall +/// dispatch rejects it up front with [`ExecutionError::HintUnknownSelector`], so the +/// `_` arm below is defensive only. +/// +/// `pub` so the prover's `collect_hint_ops` can reproduce the exact output value +/// the executor wrote to guest memory (the value is not carried in the CPU log). +pub fn compute_hint(hint_id: u64, in_be: &[u8; 32]) -> [u8; 32] { + use k256::elliptic_curve::PrimeField; + let mut fb = k256::FieldBytes::default(); + fb.copy_from_slice(in_be); + + match hint_id { + HINT_FIELD_INV => { + let x: Option = Option::from(k256::FieldElement::from_bytes(&fb)); + match x.and_then(|x| Option::::from(x.invert())) { + Some(inv) => inv.to_bytes().into(), + None => [0u8; 32], + } + } + HINT_SCALAR_INV => { + let x: Option = Option::from(k256::Scalar::from_repr(fb)); + match x.and_then(|x| Option::::from(x.invert())) { + Some(inv) => inv.to_bytes().into(), + None => [0u8; 32], + } + } + HINT_FIELD_SQRT => { + let x: Option = Option::from(k256::FieldElement::from_bytes(&fb)); + match x.and_then(|x| Option::::from(x.sqrt())) { + Some(r) => r.to_bytes().into(), + None => [0u8; 32], + } + } + _ => [0u8; 32], + } +} + +/// Checks that a 32-byte operand does not overflow its lower 32-bit address limb: +/// `(addr mod 2^32) + max_offset < 2^32`. Tables that send an address to the memory +/// bus as a `[lo32, hi32]` pair with the per-access offset added to `lo32` alone +/// cannot represent a carry into `hi32`, so an operand straddling the limb boundary +/// makes the trace unprovable. Used by the ECSM and Hint ecalls. +fn addr_limb_ok(addr: u64, max_offset: u64) -> bool { (addr % LOW_LIMB) + max_offset < LOW_LIMB } @@ -422,16 +506,16 @@ 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)?; - if !ecsm_addr_ok(addr_xg, 31) - || !ecsm_addr_ok(addr_xr, 31) - || !ecsm_addr_ok(addr_k, 31) + if !addr_limb_ok(addr_xg, 31) + || !addr_limb_ok(addr_xr, 31) + || !addr_limb_ok(addr_k, 31) { return Err(ExecutionError::EcsmAddressOverflow); } @@ -439,8 +523,7 @@ impl Instruction { // 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. + // 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); @@ -454,6 +537,84 @@ impl Instruction { 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); + } + // 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. + // + // 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)?; + let yg = load_u256_le(memory, addr_xg.wrapping_add(32))?; + let k = load_u256_le(memory, addr_k)?; + // 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 + // by the ECSM register-read path in the trace builder. + src2_val = addr_xg; + dst_val = addr_k; + } + SyscallNumbers::Hint => { + // Non-constraining hint: host computes a modular inverse/sqrt + // and writes it to the guest, which verifies it (and falls back + // to software on failure). a0 = hint_id, a1 = input addr + // (32-byte BE), a2 = output addr. The `_le` helpers only move + // bytes in address order, which is what a raw big-endian buffer + // needs. + let hint_id = registers.read(10)?; + let in_addr = registers.read(11)?; + let out_addr = registers.read(12)?; + // Reject an unrecognized selector up front: an unknown `hint_id` + // would otherwise silently produce a zero output (see + // `compute_hint`), indistinguishable from a legitimate numeric + // failure. Fail loudly instead so a guest bug surfaces here. + if !matches!(hint_id, HINT_FIELD_INV | HINT_SCALAR_INV | HINT_FIELD_SQRT) { + return Err(ExecutionError::HintUnknownSelector(hint_id)); + } + // The HINT table sends the output writes as `[out_addr_lo + 8i, + // out_addr_hi]`, so an `out_addr` whose 32-byte range crosses the + // limb boundary would unbalance the memory bus. `in_addr` is not on + // the bus (the input read is not modeled) but is bounded too, so the + // ecall's operand contract is uniform and `load_u256_le` cannot + // overflow its address arithmetic. + if !addr_limb_ok(in_addr, 31) || !addr_limb_ok(out_addr, 31) { + return Err(ExecutionError::HintAddressOverflow); + } + let input = load_u256_le(memory, in_addr)?; + let output = compute_hint(hint_id, &input); + store_u256_le(memory, out_addr, &output)?; + src2_val = in_addr; + dst_val = out_addr; + } SyscallNumbers::Halt => { // halt return Ok(Log { @@ -634,6 +795,10 @@ pub enum ExecutionError { EcsmAddressOverflow, #[error("ECSM xG and k operand ranges overlap")] EcsmOperandOverlap, + #[error("Hint address range overflows the lower 32-bit limb")] + HintAddressOverflow, + #[error("Unknown hint selector: {0}")] + HintUnknownSelector(u64), #[error("ECSM scalar multiplication error: {0}")] Ecsm(#[from] ecsm::EcsmError), } diff --git a/prover/src/lib.rs b/prover/src/lib.rs index a8e89f989..359e9c16b 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -53,8 +53,8 @@ use crate::tables::types::BusId; use crate::test_utils::{ E, F, VmAir, create_bitwise_air, create_branch_air, create_bytewise_air, create_commit_air, create_cpu_air, create_cpu32_air, create_decode_air, create_dvrm_air, create_ecdas_air, - create_ecsm_air, create_eq_air, create_halt_air, create_keccak_air, create_keccak_rc_air, - create_keccak_rnd_air, create_load_air, create_lt_air, create_memw_air, + create_ecsm_air, create_eq_air, create_halt_air, create_hint_air, create_keccak_air, + create_keccak_rc_air, create_keccak_rnd_air, create_load_air, create_lt_air, create_memw_air, create_memw_aligned_air, create_memw_register_air, create_mul_air, create_page_air, create_register_air, create_shift_air, create_store_air, }; @@ -82,8 +82,8 @@ pub struct RuntimePageRange { /// Number of tables that always contribute exactly one sub-proof, regardless /// of `TableCounts`: bitwise, decode, halt, commit, keccak, keccak_rnd, -/// keccak_rc, register, ecsm, ecdas. -pub const FIXED_TABLE_COUNT: usize = 10; +/// keccak_rc, register, ecsm, ecdas, hint. +pub const FIXED_TABLE_COUNT: usize = 11; /// Number of chunks for each split table. /// The verifier needs this to reconstruct matching AIRs. @@ -517,6 +517,7 @@ pub(crate) struct VmAirs { pub keccak_rc: VmAir, pub ecsm: VmAir, pub ecdas: VmAir, + pub hint: VmAir, pub register: VmAir, pub pages: Vec, pub memw_registers: Vec, @@ -542,6 +543,7 @@ impl VmAirs { (self.keccak_rc.as_ref(), &mut traces.keccak_rc, &()), (self.ecsm.as_ref(), &mut traces.ecsm, &()), (self.ecdas.as_ref(), &mut traces.ecdas, &()), + (self.hint.as_ref(), &mut traces.hint, &()), (self.register.as_ref(), &mut traces.register, &()), ]; if self.include_halt { @@ -616,6 +618,7 @@ impl VmAirs { self.keccak_rc.as_ref(), self.ecsm.as_ref(), self.ecdas.as_ref(), + self.hint.as_ref(), self.register.as_ref(), ]; if self.include_halt { @@ -773,6 +776,7 @@ impl VmAirs { )); let ecsm: VmAir = Box::new(create_ecsm_air(proof_options)); let ecdas: VmAir = Box::new(create_ecdas_air(proof_options)); + let hint: VmAir = Box::new(create_hint_air(proof_options)); let register: VmAir = if let Some((commitment, num_preprocessed_cols)) = register_preprocessed { Box::new( @@ -879,6 +883,7 @@ impl VmAirs { keccak_rc, ecsm, ecdas, + hint, register, pages, memw_registers, diff --git a/prover/src/tables/cpu.rs b/prover/src/tables/cpu.rs index 781bb02b0..bff303421 100644 --- a/prover/src/tables/cpu.rs +++ b/prover/src/tables/cpu.rs @@ -187,7 +187,18 @@ 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. The hint operand + /// addresses (x10/x11/x12) are recovered from the register state in the trace + /// builder, exactly like ECSM. + pub ecall_hint: bool, } impl CpuOperation { @@ -232,9 +243,15 @@ 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; // Word instructions are fully handled by CPU32; the main CPU row is a // delegate that only advances the PC and sends the CPU32 lookup. We still @@ -353,6 +370,8 @@ 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 746bef91c..7e73c540d 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 (684 columns; keep in sync with NUM_COLUMNS below) // ========================================================================= pub mod cols { @@ -56,8 +66,13 @@ 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; + /// `(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 = 667; + pub const NUM_COLUMNS: usize = 684; #[inline] pub const fn xr(i: usize) -> usize { @@ -108,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 + } } // ========================================================================= @@ -121,6 +140,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, } @@ -181,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))); @@ -190,6 +213,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 +325,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,6 +403,34 @@ pub fn bus_interactions() -> Vec { ), )); } + // 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 { + coefficient: 1, + column: cols::ADDR_XG_0, + }, + LinearTerm::Constant((32 + 8 * i) as i64), + ]); + out.push(BusInteraction::sender( + BusId::Memw, + affine(), + 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![ @@ -443,6 +520,33 @@ pub fn bus_interactions() -> Vec { )); } + // 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 { + coefficient: 1, + column: cols::ADDR_XR_0, + }, + LinearTerm::Constant((32 + 8 * i) as i64), + ]); + out.push(BusInteraction::sender( + BusId::Memw, + affine(), + 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 { @@ -504,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]. @@ -628,6 +739,7 @@ pub enum OverflowKind { XgLtP, KLtN, XrLtP, + YrLtP, } impl OverflowKind { @@ -637,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 { @@ -650,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. @@ -658,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). @@ -671,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..413: +// 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−µ) @@ -686,10 +801,14 @@ impl OverflowKind { // 404 : OverflowRequired(KLtN) // 405..412 : CarryBit(XrLtP, 0..7) // 412 : OverflowRequired(XrLtP) +// 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`] (413 +/// ECSM transition constraints as a single-source [`ConstraintSet`] (423 /// total). No column configuration needed (the layout is fixed via `cols`). #[derive(Clone, Copy)] pub struct EcsmConstraints; @@ -881,7 +1000,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) @@ -897,6 +1021,20 @@ impl ConstraintSet for EcsmConstraints { idx += 1; } - debug_assert_eq!(idx, 413); + // 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 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); + let one = b.one(); + b.emit_base(idx, is_affine * (one - mu)); + idx += 1; + + debug_assert_eq!(idx, 423); } } diff --git a/prover/src/tables/hint.rs b/prover/src/tables/hint.rs new file mode 100644 index 000000000..8b8877d05 --- /dev/null +++ b/prover/src/tables/hint.rs @@ -0,0 +1,356 @@ +//! HINT table — receiver for the non-constraining `hint` ecall. +//! +//! The `hint` ecall (syscall `u64::MAX - 30`) lets the executor hand the guest a +//! value that is expensive to compute but cheap to verify (modular inverse, sqrt, +//! …); the guest verifies it with ordinary constrained instructions. Unlike a +//! normal `STORE`, the ecall writes the 32-byte output to guest memory *directly* +//! (not through the CPU load/store decode), so those writes are invisible to the +//! CPU op stream — this table is what puts them into the memory argument. +//! +//! The table therefore does exactly four things, and constrains **nothing** about +//! *which* value was hinted (that is the point — soundness lives in the guest's +//! verify). It does constrain *where* the value lands and that it is 32 bytes: +//! +//! 1. **Receives** the `Hint` ecall on the `Ecall` bus (balances the CPU's send; +//! a syscall with no receiver leaves the LogUp argument unbalanced). +//! 2. **Reads `x12`** (`a2`) through the memory argument, which pins `out_addr` to +//! the value the CPU had in that register. The writes below take their base from +//! an ordinary trace column, so without this read that column is free and the +//! witness chooses *where* the 32 bytes land — an arbitrary memory write, which +//! is a strictly larger hole than the unconstrained value. +//! 3. **Sends** the four 8-byte MEMW writes of the output at `out_addr` +0/8/16/24 +//! (received by the MEMW table). Without these the output's initial→final +//! memory chain is unexplained and the memory argument fails to balance. +//! 4. **Range-checks** the 32 output cells as bytes (`AreBytes`). MEMW does not +//! range-check what it receives, so each table that writes fresh values into +//! memory checks its own cells; skipping it lets the witness put arbitrary field +//! elements where loads and the ALU expect bytes. +//! +//! The input read (the ecall also reads `in_addr`) is intentionally **not** modeled: +//! a read leaves the value unchanged, the guest supplies the input via ordinary +//! stores, and nothing depends on the ecall having re-read it — so omitting it is +//! sound and avoids the mixed-timestamp bookkeeping of a partial-buffer read. +//! +//! `mu` is constrained to a bit (`IS_BIT`, the table's only algebraic constraint) — +//! the same guard every other multiplicity-column table carries (ECSM/ECDAS/COMMIT/ +//! STORE/MEMW_R). The `Ecall` bus alone does not establish it: its tuple carries the +//! timestamp, a free column, so the LogUp identity pins only the *sum* of `mu` over +//! the rows sharing a `(ts, syscall)` tuple to the CPU's send — it does not rule out +//! a witness that spreads `mu` across rows with integer weights summing to 1 (a `+1` +//! row plus a `+1`/`-1` pair, each keeping its own `out_addr`, the base the four +//! output writes take). MEMW does NOT catch this: it only ever receives the legal +//! `+1`, while the `-1` cancels an honest STORE on the sender side, so MEMW's own +//! multiplicity constraints stay satisfied and nothing downstream rejects it. The +//! `IS_BIT` on `mu` here is therefore load-bearing -- not a redundant restatement of +//! a check some other table performs. +//! +//! ## Columns (41) +//! - `timestamp[0..1]` (DWordWL): the ecall timestamp `T` +//! - `out_addr[0..1]` (DWordWL): base address of the 32-byte output buffer +//! - `out_bytes[0..31]`: the 32 output bytes (the hint) — **unconstrained** +//! - `mu`: multiplicity flag (1 = real hint call, 0 = padding) — gates every bus +//! - `selector[0..1]` (DWordWL): `a0`, bound to `x10` and range-checked `< 3` +//! - `in_addr[0..1]` (DWordWL): `a1`, bound to `x11`; its low limb is range-checked +//! so the ecall's input range cannot straddle the 32-bit limb boundary + +use executor::vm::instruction::execution::HINT_SYSCALL_NUMBER; +use stark::constraints::builder::{ConstraintBuilder, ConstraintSet}; +use stark::lookup::{BusInteraction, BusValue, LinearTerm, Multiplicity, Packing}; +use stark::trace::TraceTable; + +use crate::constraints::templates::emit_is_bit; + +use super::types::{BusId, FE, GoldilocksExtension, GoldilocksField, VmTable, alu_op}; + +/// One past the largest valid hint selector (`a0 ∈ {0, 1, 2}` = FIELD_INV / +/// SCALAR_INV / FIELD_SQRT). The executor rejects anything else up front, so the +/// AIR range-checks `selector < 3` to accept exactly the same set. +pub const HINT_SELECTOR_BOUND: u64 = 3; + +/// Bound the low 32-bit limb of `in_addr` must stay under so the ecall's 32-byte +/// input range (`+0..+31`) cannot straddle the 2^32 limb boundary. Mirrors the +/// executor's `addr_limb_ok(in_addr, 31)`: `(in_addr % 2^32) + 31 < 2^32`. +pub const HINT_IN_ADDR_LIMB_BOUND: u64 = (1 << 32) - 31; + +pub mod cols { + /// timestamp[0]: lower 32 bits of the ecall timestamp + pub const TIMESTAMP_0: usize = 0; + /// timestamp[1]: upper 32 bits (always 0 — timestamps fit u32) + pub const TIMESTAMP_1: usize = 1; + /// out_addr[0]: lower 32 bits of the output base address + pub const ADDR_OUT_0: usize = 2; + /// out_addr[1]: upper 32 bits of the output base address + pub const ADDR_OUT_1: usize = 3; + /// out_bytes[0..31]: the 32 output bytes, one per column + pub const OUT: usize = 4; + /// multiplicity flag (1 = real hint call, 0 = padding) + pub const MU: usize = 36; + /// selector[0]: lower 32 bits of `a0` (the hint id) + pub const SEL_0: usize = 37; + /// selector[1]: upper 32 bits of `a0` + pub const SEL_1: usize = 38; + /// in_addr[0]: lower 32 bits of `a1` (the input base address) + pub const ADDR_IN_0: usize = 39; + /// in_addr[1]: upper 32 bits of `a1` + pub const ADDR_IN_1: usize = 40; + + pub const NUM_COLUMNS: usize = 41; + + /// Column of output byte `i` (0..32). + #[inline] + pub const fn out(i: usize) -> usize { + OUT + i + } +} + +/// One `hint` ecall: the timestamp, the output base address, and the 32 output +/// bytes the executor wrote to guest memory (recomputed by the trace builder). +#[derive(Debug, Clone)] +pub struct HintOperation { + pub timestamp: u64, + pub out_addr: u64, + pub out_bytes: [u8; 32], + /// `a0` — the hint selector, bound to `x10` and range-checked `< 3`. + pub hint_id: u64, + /// `a1` — the input base address, bound to `x11` and low-limb range-checked. + pub in_addr: u64, +} + +/// Generates the HINT trace: one row per hint-ecall call (in program order), +/// `mu = 1`; padding rows are all-zero (`mu = 0`, inert on the bus). Empty (all +/// padding) for programs that make no hint calls. +pub fn generate_hint_trace( + ops: &[HintOperation], +) -> TraceTable { + let num_rows = ops.len().next_power_of_two().max(4); + let mut trace = TraceTable::new_main( + crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), + cols::NUM_COLUMNS, + 1, + ); + let table = &mut trace.main_table; + + for (row, op) in ops.iter().enumerate() { + debug_assert!( + op.timestamp <= u32::MAX as u64, + "HINT timestamp {} exceeds u32", + op.timestamp + ); + table.set_dword_wl(row, cols::TIMESTAMP_0, op.timestamp); + table.set_dword_wl(row, cols::ADDR_OUT_0, op.out_addr); + table.set_bytes(row, cols::OUT, &op.out_bytes); + table.set_dword_wl(row, cols::SEL_0, op.hint_id); + table.set_dword_wl(row, cols::ADDR_IN_0, op.in_addr); + table.set_fe(row, cols::MU, FE::one()); + } + + trace +} + +// ========================================================================= +// Bus interactions +// ========================================================================= + +fn packed(col: usize) -> BusValue { + BusValue::Packed { + start_column: col, + packing: Packing::Direct, + } +} + +/// The eight output bytes of doubleword `chunk` (`out_bytes[8*chunk .. 8*chunk+7]`) +/// as MEMW value elements. +fn out_dword_bytes(chunk: usize) -> [BusValue; 8] { + std::array::from_fn(|b| packed(cols::out(8 * chunk + b))) +} + +/// A 16-element MEMW **write** tuple (CO25): `[is_register=0, base_lo, base_hi, +/// value[8], ts_lo, ts_hi, w2=0, w4=0, w8=1]`. The MEMW table supplies `old`. +fn memw_write(value: [BusValue; 8], base_lo: BusValue, base_hi: BusValue) -> Vec { + let mut v = Vec::with_capacity(16); + v.push(BusValue::constant(0)); // is_register = 0 (memory) + v.push(base_lo); + v.push(base_hi); + v.extend(value); + v.push(packed(cols::TIMESTAMP_0)); // ts_lo + v.push(packed(cols::TIMESTAMP_1)); // ts_hi + v.push(BusValue::constant(0)); // w2 + v.push(BusValue::constant(0)); // w4 + v.push(BusValue::constant(1)); // w8 = 1 (8-byte write) + v +} + +/// A 24-element MEMW **read** tuple (CO24) for a register: `[old[8], is_register=1, +/// base_lo=2*reg, base_hi=0, value[8], ts_lo, ts_hi, w2=1, w4=0, w8=0]`, with +/// `old == value` because a read leaves the register unchanged. Binds `x{reg}` to +/// the `(lo, hi)` column pair at the ecall timestamp. +fn memw_register_read(reg: u64, lo_col: usize, hi_col: usize) -> Vec { + let value = || [packed(lo_col), packed(hi_col)]; + let mut v = Vec::with_capacity(24); + v.extend(value()); // old[0..2] + v.extend(std::iter::repeat_n(BusValue::constant(0), 6)); // old[2..8] + v.push(BusValue::constant(1)); // is_register = 1 + v.push(BusValue::constant(2 * reg)); // base_address lo + v.push(BusValue::constant(0)); // base_address hi + v.extend(value()); // value[0..2] == old + v.extend(std::iter::repeat_n(BusValue::constant(0), 6)); // value[2..8] + v.push(packed(cols::TIMESTAMP_0)); + v.push(packed(cols::TIMESTAMP_1)); + v.push(BusValue::constant(1)); // w2 = 1 (register = 2 words) + v.push(BusValue::constant(0)); // w4 + v.push(BusValue::constant(0)); // w8 + v +} + +/// Bus interactions: +/// - **`Ecall` receiver** (mult `mu`): `[timestamp, cast(HINT_SYSCALL_NUMBER, +/// DWordWL)]` — HALT-shaped, balances the CPU's ECALL send. +/// - **MEMW register-read sender** (mult `mu`): binds `out_addr` to `x12`, the +/// ecall's `a2`. Without it the write addresses below are free columns, so a +/// witness could place the output bytes at any address it likes — an arbitrary +/// memory write, independent of whether the hinted *value* is constrained. +/// - **MEMW write senders** (mult `mu`, ×4): the four 8-byte writes of the output +/// at `out_addr` +0/8/16/24, timestamp `T`. Received by the MEMW table. +/// - **`AreBytes` senders** (mult `mu`, ×16): range-check the 32 output cells. +/// +/// - **MEMW register-read senders** (mult `mu`, ×2): bind `a0` (`x10`, the selector) +/// and `a1` (`x11`, the input address) to their register columns. +/// - **ALU `LT` senders** (mult `mu`, ×2): assert `selector < 3` and that `in_addr`'s +/// low limb `< 2^32 − 31`, matching the executor's up-front rejections +/// (`HintUnknownSelector`, `HintAddressOverflow`). Without them the AIR would accept +/// hints the executor rejects — a malicious prover could prove an execution the VM +/// would halt on. The value stays unconstrained (the guest verifies it); this only +/// pins the *operands* to the executor's accepted set. +pub fn bus_interactions() -> Vec { + let mu = || Multiplicity::Column(cols::MU); + let mut out = Vec::with_capacity(26); + + // ECALL receiver: [ts_lo, ts_hi, syscall_lo32, syscall_hi32]. + out.push(BusInteraction::receiver( + BusId::Ecall, + mu(), + vec![ + packed(cols::TIMESTAMP_0), + packed(cols::TIMESTAMP_1), + BusValue::constant(HINT_SYSCALL_NUMBER & 0xFFFF_FFFF), + BusValue::constant(HINT_SYSCALL_NUMBER >> 32), + ], + )); + + // Bind out_addr to x12 (a2): without this the write base below is a free column. + out.push(BusInteraction::sender( + BusId::Memw, + mu(), + memw_register_read(12, cols::ADDR_OUT_0, cols::ADDR_OUT_1), + )); + + // Bind a0 (x10 = selector) and a1 (x11 = in_addr). Without these the range-checks + // below would constrain free columns instead of the registers the CPU held. + out.push(BusInteraction::sender( + BusId::Memw, + mu(), + memw_register_read(10, cols::SEL_0, cols::SEL_1), + )); + out.push(BusInteraction::sender( + BusId::Memw, + mu(), + memw_register_read(11, cols::ADDR_IN_0, cols::ADDR_IN_1), + )); + + // ALU LT: selector < 3 (full 64-bit value), asserting the result is 1. A witness + // with an out-of-range selector has no matching LT row and unbalances the bus. + // ALU LT tuple (matching the LT table's receiver): `[lhs_lo, lhs_hi, rhs_lo, + // rhs_hi, op_encoding, result, 0]` — both operands are two elements (low, high + // 32-bit words), `op_encoding = LT` for an unsigned non-inverted compare, and + // `result = 1` asserts the strict inequality holds. + // + // selector < 3 (full 64-bit value: SEL_0/SEL_1). + out.push(BusInteraction::sender( + BusId::Alu, + mu(), + vec![ + BusValue::Packed { + start_column: cols::SEL_0, + packing: Packing::DWordWL, + }, + BusValue::constant(HINT_SELECTOR_BOUND), + BusValue::constant(0), + BusValue::constant(alu_op::LT as u64), + BusValue::constant(1), + BusValue::constant(0), + ], + )); + + // in_addr's low limb < 2^32 - 31, matching addr_limb_ok(in_addr, 31). The lhs high + // word is a literal 0, so only ADDR_IN_0 (the low limb) is compared — exactly the + // executor's check, which ignores the high limb. + out.push(BusInteraction::sender( + BusId::Alu, + mu(), + vec![ + packed(cols::ADDR_IN_0), + BusValue::constant(0), + BusValue::constant(HINT_IN_ADDR_LIMB_BOUND), + BusValue::constant(0), + BusValue::constant(alu_op::LT as u64), + BusValue::constant(1), + BusValue::constant(0), + ], + )); + + // write output: 4 doublewords at out_addr + 8i (timestamp T). + for i in 0..4 { + let base_lo = BusValue::linear(vec![ + LinearTerm::Column { + coefficient: 1, + column: cols::ADDR_OUT_0, + }, + LinearTerm::Constant((8 * i) as i64), + ]); + out.push(BusInteraction::sender( + BusId::Memw, + mu(), + memw_write(out_dword_bytes(i), base_lo, packed(cols::ADDR_OUT_1)), + )); + } + + // ARE_BYTES[out_bytes[2i], out_bytes[2i+1]]: the output cells are free columns + // that enter memory as MEMW write values, and MEMW range-checks nothing it + // receives. Every other table that puts fresh values into memory (STORE, KECCAK, + // ECSM, PAGE) range-checks its own cells for this reason: the value is allowed to + // be *wrong* here, but it must still be 32 bytes, or the witness can smuggle + // arbitrary field elements into memory and break the byte decomposition that + // loads and the ALU depend on. 16 sends, pairing cells as ECSM/KECCAK do. + for i in 0..16 { + out.push(BusInteraction::sender( + BusId::AreBytes, + mu(), + vec![packed(cols::out(2 * i)), packed(cols::out(2 * i + 1))], + )); + } + + out +} + +// ========================================================================= +// Single-source constraint set (ConstraintBuilder front-end) +// ========================================================================= + +/// The HINT table's single transition constraint: `mu·(1−mu) = 0`. +/// +/// `mu` is the multiplicity gating every one of this table's bus interactions +/// (the `Ecall` receive, the `x12` register read, the four output writes, the +/// 16 byte range-checks). It must be boolean, or a witness could put a non-`{0,1}` +/// value on the `AreBytes`/MEMW sends. The LogUp argument already fixes `mu`'s value +/// via the timestamp-unique `Ecall` tuple, but every other multiplicity-column table +/// bit-constrains its column in-circuit; HINT does the same rather than being the +/// lone exception that relies solely on bus balance. +#[derive(Clone, Copy)] +pub struct HintConstraints; + +impl ConstraintSet for HintConstraints { + fn eval>(&self, b: &mut B) { + // idx 0: IS_BIT for mu. + emit_is_bit(b, 0, cols::MU, None); + } +} diff --git a/prover/src/tables/mod.rs b/prover/src/tables/mod.rs index 0a86e4149..f1a899f56 100644 --- a/prover/src/tables/mod.rs +++ b/prover/src/tables/mod.rs @@ -34,6 +34,7 @@ pub mod ecsm; pub mod eq; pub mod global_memory; pub mod halt; +pub mod hint; pub mod keccak; pub mod keccak_rc; pub mod keccak_rnd; diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 43654bb54..afdcf3888 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -51,6 +51,7 @@ use super::ecdas; use super::ecsm; use super::eq; use super::halt; +use super::hint; use super::keccak::{self, KeccakOperation}; use super::keccak_rc; use super::keccak_rnd::{self, KeccakRoundOperation}; @@ -549,6 +550,7 @@ fn collect_ops_from_cpu( Vec, Vec, Vec, + Vec, ) { let mut memw = MemwBuckets::with_register_capacity(cpu_ops.len() * 3); let mut load_ops = Vec::with_capacity(cpu_ops.len() / 8 + 1); @@ -560,6 +562,7 @@ fn collect_ops_from_cpu( let mut cpu32_ops = Vec::new(); let mut ecsm_ops = Vec::new(); let mut ecdas_ops = Vec::new(); + let mut hint_ops = Vec::new(); // Seed from the carried x254 (0 for a monolithic run or the first epoch) so a // continuation epoch indexes its commits globally, matching the x254 the // register binding transports across epochs. Resetting to 0 here would drift @@ -654,6 +657,13 @@ fn collect_ops_from_cpu( ecdas_ops.extend(ecdas_rows); } + // Collect Hint ecall operations (the 32-byte output write). + if op.ecall_hint { + let (hint_memw, hint_op) = collect_hint_ops(op, memory_state, register_state); + memw.extend_ops(hint_memw); + hint_ops.push(hint_op); + } + // --- ALU chip dispatch (no state tracking) --- // Word (`*W`) instructions are delegated to CPU32 (which itself drives // the ALU chips); the main CPU does not send the ALU bus for them, so we @@ -709,6 +719,7 @@ fn collect_ops_from_cpu( cpu32_ops, ecsm_ops, ecdas_ops, + hint_ops, ) } @@ -836,22 +847,37 @@ 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 xG and k operands (32 little-endian bytes each) from memory. + // 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; + 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(&k, &xg) - .expect("ECSM witness: executor validates 0 < k < N and xG 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); + // 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). { @@ -878,6 +904,25 @@ fn collect_ecsm_ops( memory_state.write_bytes(addr, dword, 8, t); } + // 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); + } + } + // x12 -> addr_k (register read at T+1). { let (val, old_ts) = register_state.read(12); @@ -931,6 +976,26 @@ fn collect_ecsm_ops( memory_state.write_bytes(addr, dword, 8, t + 2); } + // 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 ecdas_ops = witness .steps .iter() @@ -942,12 +1007,88 @@ fn collect_ecsm_ops( addr_xg, addr_k, addr_xr, + is_affine, witness, }; (memw_ops, ecsm_op, ecdas_ops) } +/// Collects the memory operations for a `Hint` ecall. +/// +/// The `hint` ecall writes a 32-byte value (a modular inverse / sqrt) to guest +/// memory *directly* — bypassing the CPU load/store decode — so the trace builder +/// must reproduce that write itself: the value is not carried in the CPU log. We +/// re-derive the operand addresses from the register state (a0/a1/a2 = x10/x11/x12, +/// like ECSM), read the input from the replayed memory, recompute the output with +/// the executor's `compute_hint` (deterministic, same k256 arithmetic), then emit +/// four 8-byte MEMW writes at `out_addr` +0/8/16/24 and advance `memory_state`. +/// +/// The input read is intentionally not modeled (a read leaves the value unchanged; +/// the guest supplied the input via ordinary stores). The value itself is +/// unconstrained — soundness lives in the guest's in-circuit verify. +fn collect_hint_ops( + op: &CpuOperation, + memory_state: &mut MemoryState, + register_state: &mut RegisterState, +) -> (Vec, hint::HintOperation) { + let t = op.timestamp; + let hint_id = register_state.read(10).0; + let in_addr = register_state.read(11).0; + let out_addr = register_state.read(12).0; + + let mut memw_ops = Vec::with_capacity(7); + + // Bind a0/a1/a2 (x10/x11/x12) at ts through the memory argument. x12 ties the + // output-write base below to the ecall's a2; x10 (selector) and x11 (in_addr) pin + // the operands the HINT table range-checks against the executor's accepted set, so + // the AIR cannot prove a hint the executor would reject. All three are register + // reads (old == value; a read leaves the register unchanged). See `tables::hint`. + for (reg, value) in [(10u8, hint_id), (11, in_addr), (12, out_addr)] { + let reg_value = pack_register_value(value); + let (_old_val, old_ts) = register_state.read(reg); + memw_ops.push( + MemwOperation::new(true, 2 * reg as u64, reg_value, t, 2, true) + .with_old(reg_value, [old_ts, old_ts, 0, 0, 0, 0, 0, 0]), + ); + register_state.write(reg, value, t); + } + + // Read the 32-byte big-endian input from the replayed memory. + let mut input = [0u8; 32]; + for (i, b) in input.iter_mut().enumerate() { + *b = memory_state.read_byte(in_addr.wrapping_add(i as u64)).0; + } + + // Recompute the output exactly as the executor did (the value isn't in the log). + let out_bytes = executor::vm::instruction::execution::compute_hint(hint_id, &input); + + // Emit the 32-byte output as four 8-byte MEMW writes at ts = T. + for i in 0..4 { + let addr = out_addr.wrapping_add((8 * i) as u64); + let mut value = [0u32; 8]; + let mut dword = 0u64; + for j in 0..8 { + let byte = out_bytes[8 * i + j]; + value[j] = byte as u32; + dword |= (byte as u64) << (8 * j); + } + let (old_vals, old_ts) = memory_state.read_bytes(addr, 8); + memw_ops + .push(MemwOperation::new(false, addr, value, t, 8, false).with_old(old_vals, old_ts)); + memory_state.write_bytes(addr, dword, 8, t); + } + + let hint_op = hint::HintOperation { + timestamp: t, + out_addr, + out_bytes, + hint_id, + in_addr, + }; + (memw_ops, hint_op) +} + /// Collects register read/write operations (M1, M3, M5) from CpuOperation, /// pushing them into `memw_ops`. fn collect_register_ops_from_cpu( @@ -2248,6 +2389,23 @@ fn collect_bitwise_from_commit(commit_ops: &[CommitOperation]) -> Vec Vec { + let mut lookups = Vec::with_capacity(16 * hint_ops.len()); + for op in hint_ops { + for i in 0..16 { + lookups.push(BitwiseOperation::byte_op( + BitwiseOperationType::AreBytes, + op.out_bytes[2 * i], + op.out_bytes[2 * i + 1], + )); + } + } + lookups +} + // ============================================================================= // BITWISE lookup helpers // ============================================================================= @@ -2289,7 +2447,7 @@ pub(crate) fn collect_bitwise_from_ecsm(ops: &[ecsm::EcsmOperation]) -> Vec Vec, + /// HINT table (one row per non-constraining hint ecall). + pub hint: TraceTable, + /// MEMW_R register-only fast-path traces (split into chunks of max_rows::MEMW_R) pub memw_registers: Vec>, /// Local-to-global boundary table for continuation epochs. Empty unless the @@ -2818,6 +2982,8 @@ struct CollectedOps { // EC scalar-multiplication accelerator chips. ecsm_ops: Vec, ecdas_ops: Vec, + // Non-constraining hint ecall. + hint_ops: Vec, } /// Chunk raw ops and generate one trace table per chunk. When `storage_mode` @@ -2872,6 +3038,7 @@ fn collect_all_ops( cpu32_ops: Vec, ecsm_ops: Vec, ecdas_ops: Vec, + hint_ops: Vec, register_state: &mut RegisterState, is_final: bool, ) -> CollectedOps { @@ -3014,6 +3181,7 @@ fn collect_all_ops( cpu32_ops, ecsm_ops, ecdas_ops, + hint_ops, } } @@ -3057,6 +3225,7 @@ fn build_traces( cpu32_ops, ecsm_ops, ecdas_ops, + hint_ops, } = ops; // ===================================================================== @@ -3064,6 +3233,19 @@ fn build_traces( // ===================================================================== lt_ops.extend(collect_lt_from_memw(&memw_ops)); lt_ops.extend(collect_lt_from_memw_aligned(&memw_aligned_ops)); + // HINT range-checks: selector < 3 and in_addr's low limb < 2^32 - 31 (matching the + // executor's HintUnknownSelector / HintAddressOverflow rejections). Two LT ops per + // hint call; the HINT table sends the matching ALU LT interactions. + lt_ops.extend(hint_ops.iter().flat_map(|op| { + [ + LtOperation::new(op.hint_id, hint::HINT_SELECTOR_BOUND, false), + LtOperation::new( + op.in_addr & 0xFFFF_FFFF, + hint::HINT_IN_ADDR_LIMB_BOUND, + false, + ), + ] + })); // ===================================================================== // PHASE 4: All → Bitwise lookups @@ -3093,7 +3275,8 @@ fn build_traces( // chunk size used to split them into instances so multiplicities match the per-instance // sends. MEMW_R sends IS_HALFWORD[timestamp_0 - old_timestamp_lo - 1]. PAGE does a // batched ARE_BYTES[init, fini] per row (skipped in continuation epochs, which the L2G - // table owns). COMMIT sends AreBytes+IsHalfword; KECCAK_RND sends XOR/AND/ARE_BYTES/HWSL. + // table owns). COMMIT sends AreBytes+IsHalfword; KECCAK_RND sends XOR/AND/ARE_BYTES/HWSL; + // HINT sends ARE_BYTES for its 32 output cells. // We never concatenate the lookups into one giant `Vec` (~140 M ops / // ~560 MB at 10-tx whose only consumer is the multiplicity count). Each collector bumps // the `BitwiseHistogram` it is handed: the heavy sources (MEMW_R one-per-row, PAGE @@ -3132,6 +3315,7 @@ fn build_traces( Box::new(|h| h.add_ops(&collect_bitwise_from_keccak(&keccak_ops))), Box::new(|h| h.add_ops(&collect_bitwise_from_ecsm(&ecsm_ops))), Box::new(|h| h.add_ops(&collect_bitwise_from_ecdas(&ecdas_ops))), + Box::new(|h| h.add_ops(&collect_bitwise_from_hint(&hint_ops))), Box::new(|h| add_padding_byte_checks(h, num_padding_rows)), ]; if let Some(image) = initial_image @@ -3418,6 +3602,8 @@ fn build_traces( // ECSM accelerator traces (empty/all-padding for programs that do not use ECSM). let gen_ecsm = || ecsm::generate_ecsm_trace(&ecsm_ops); let gen_ecdas = || ecdas::generate_ecdas_trace(&ecdas_ops); + // HINT table (all-padding for programs that make no hint ecalls). + let gen_hint = || hint::generate_hint_trace(&hint_ops); let (mut cpus_slot, mut memws_slot, mut memw_aligneds_slot, mut memw_registers_slot) = (None, None, None, None); @@ -3430,6 +3616,7 @@ fn build_traces( let (mut eqs_slot, mut bytewises_slot, mut stores_slot, mut cpu32s_slot) = (None, None, None, None); let (mut ecsm_slot, mut ecdas_slot) = (None, None); + let mut hint_slot = None; #[cfg(feature = "disk-spill")] let sequential = storage_mode == StorageMode::Disk || cfg!(not(feature = "parallel")); @@ -3471,6 +3658,7 @@ fn build_traces( spawn_into!(cpu32s_slot, gen_cpu32s); spawn_into!(ecsm_slot, gen_ecsm); spawn_into!(ecdas_slot, gen_ecdas); + spawn_into!(hint_slot, gen_hint); }); } else { cpus_slot = Some(gen_cpus()); @@ -3498,6 +3686,7 @@ fn build_traces( cpu32s_slot = Some(gen_cpu32s()); ecsm_slot = Some(gen_ecsm()); ecdas_slot = Some(gen_ecdas()); + hint_slot = Some(gen_hint()); } const PHASE5_RAN: &str = "phase 5 generation ran in one of the branches above"; @@ -3532,6 +3721,7 @@ fn build_traces( let mut halt_trace = halt_slot.expect(PHASE5_RAN); let ecsm_trace = ecsm_slot.expect(PHASE5_RAN); let ecdas_trace = ecdas_slot.expect(PHASE5_RAN); + let hint_trace = hint_slot.expect(PHASE5_RAN); // Fixed-size and per-page tables aren't built through `chunk_and_generate`, // so spill them here before returning. @@ -3599,6 +3789,7 @@ fn build_traces( keccak_rc: keccak_rc_trace, ecsm: ecsm_trace, ecdas: ecdas_trace, + hint: hint_trace, memw_registers, local_to_global, touched_memory_cells, @@ -3772,6 +3963,24 @@ pub fn count_table_lengths( .ok_or_else(|| Error::Execution("commit index exceeds u32 range".into()))?; } + if cpu_op.ecall_hint { + // Mirror `collect_hint_ops`: three register reads (a0/a1/a2) and four + // 8-byte output writes go through the memory argument, plus the two LT + // range-checks (selector < 3, in_addr low limb). Replaying it here keeps + // memory/register state in sync with generation, exactly like commit above. + let (hint_memw, _hint_op) = + collect_hint_ops(&cpu_op, &mut memory_state, &mut register_state); + for memw_op in &hint_memw { + partition_memw( + memw_op, + &mut memw_by_width, + &mut memw_aligned_count, + &mut memw_register_count, + ); + } + lt_count += 2; + } + // CPU-side per-instruction-kind counters (non-word; word → CPU32, B5b) let f = &cpu_op.decode.fields; if !f.word_instr && f.is_lt() { @@ -3861,6 +4070,7 @@ impl Traces { use super::ecsm::cols::NUM_COLUMNS as ECSM_COLS; use super::eq::cols::NUM_COLUMNS as EQ_COLS; use super::halt::cols::NUM_COLUMNS as HALT_COLS; + use super::hint::cols::NUM_COLUMNS as HINT_COLS; use super::keccak::cols::NUM_COLUMNS as KECCAK_COLS; use super::keccak_rc::NUM_PRECOMPUTED_COLS as KECCAK_RC_PRECOMPUTED; use super::keccak_rc::cols::NUM_COLUMNS as KECCAK_RC_COLS; @@ -3899,6 +4109,7 @@ impl Traces { keccak_rc, ecsm, ecdas, + hint, memw_registers, eqs, bytewises, @@ -3966,6 +4177,7 @@ impl Traces { } total += (ecsm.num_rows() * ECSM_COLS) as u64; total += (ecdas.num_rows() * ECDAS_COLS) as u64; + total += (hint.num_rows() * HINT_COLS) as u64; total } @@ -4007,6 +4219,7 @@ impl Traces { let n_cpu32 = aux_cols(super::cpu32::bus_interactions().len()); let n_ecsm = aux_cols(super::ecsm::bus_interactions().len()); let n_ecdas = aux_cols(super::ecdas::bus_interactions().len()); + let n_hint = aux_cols(super::hint::bus_interactions().len()); let Traces { cpus, @@ -4029,6 +4242,7 @@ impl Traces { keccak_rc, ecsm, ecdas, + hint, memw_registers, eqs, bytewises, @@ -4096,6 +4310,7 @@ impl Traces { } total += (ecsm.num_rows() * n_ecsm) as u64; total += (ecdas.num_rows() * n_ecdas) as u64; + total += (hint.num_rows() * n_hint) as u64; total } @@ -4369,6 +4584,7 @@ impl Traces { cpu32_ops, ecsm_ops, ecdas_ops, + hint_ops, ) = collect_ops_from_cpu(&cpu_ops, &mut memory_state, &mut register_state); #[cfg(feature = "instruments")] drop(__sp); @@ -4387,6 +4603,7 @@ impl Traces { cpu32_ops, ecsm_ops, ecdas_ops, + hint_ops, &mut register_state, is_final, ); @@ -4480,6 +4697,7 @@ impl Traces { cpu32_ops, ecsm_ops, ecdas_ops, + hint_ops, ) = collect_ops_from_cpu(&cpu_ops, &mut memory_state, &mut register_state); let ops = collect_all_ops( @@ -4494,6 +4712,7 @@ impl Traces { cpu32_ops, ecsm_ops, ecdas_ops, + hint_ops, &mut register_state, true, ); diff --git a/prover/src/test_utils.rs b/prover/src/test_utils.rs index d7969612f..8d7960980 100644 --- a/prover/src/test_utils.rs +++ b/prover/src/test_utils.rs @@ -66,6 +66,9 @@ use crate::tables::ecsm::{ }; use crate::tables::eq::{EqConstraints, bus_interactions as eq_bus_interactions, cols as eq_cols}; use crate::tables::halt::{bus_interactions as halt_bus_interactions, cols as halt_cols}; +use crate::tables::hint::{ + HintConstraints, bus_interactions as hint_bus_interactions, cols as hint_cols, +}; use crate::tables::keccak::{ KeccakConstraints, bus_interactions as keccak_bus_interactions, cols as keccak_cols, }; @@ -894,6 +897,20 @@ pub fn create_halt_air(proof_options: &ProofOptions) -> ConcreteVmAir ConcreteVmAir { + build_air( + hint_cols::NUM_COLUMNS, + hint_bus_interactions(), + proof_options, + 1, + HintConstraints, + "HINT", + ) +} + /// Create COMMIT AIR with constraints and bus interactions. pub fn create_commit_air(proof_options: &ProofOptions) -> ConcreteVmAir { build_air( diff --git a/prover/src/tests/constraint_program_device_tests.rs b/prover/src/tests/constraint_program_device_tests.rs index a2863b2f0..a29a7cb49 100644 --- a/prover/src/tests/constraint_program_device_tests.rs +++ b/prover/src/tests/constraint_program_device_tests.rs @@ -181,4 +181,5 @@ fn all_table_programs_lower_and_match_folders() { check_air_device(&create_keccak_rc_air(&opts), "KECCAK_RC"); check_air_device(&create_ecsm_air(&opts), "ECSM"); check_air_device(&create_ecdas_air(&opts), "ECDAS"); + check_air_device(&create_hint_air(&opts), "HINT"); } diff --git a/prover/src/tests/constraint_program_tests.rs b/prover/src/tests/constraint_program_tests.rs index 3ae46494d..e227da53d 100644 --- a/prover/src/tests/constraint_program_tests.rs +++ b/prover/src/tests/constraint_program_tests.rs @@ -179,4 +179,5 @@ fn all_table_programs_match_folders() { check_air(&create_keccak_rc_air(&opts), "KECCAK_RC"); check_air(&create_ecsm_air(&opts), "ECSM"); check_air(&create_ecdas_air(&opts), "ECDAS"); + check_air(&create_hint_air(&opts), "HINT"); } diff --git a/prover/src/tests/constraint_set_tests_b.rs b/prover/src/tests/constraint_set_tests_b.rs index 0348c2b70..a7f68ecfd 100644 --- a/prover/src/tests/constraint_set_tests_b.rs +++ b/prover/src/tests/constraint_set_tests_b.rs @@ -299,3 +299,19 @@ mod cpu { check_table("cpu", &CpuConstraints, cols::NUM_COLUMNS); } } + +// ============================================================================= +// hint.rs +// ============================================================================= + +mod hint { + use super::*; + use crate::tables::hint::{HintConstraints, cols}; + + #[test] + fn hint_constraint_set_folder_capture_agree() { + // The one constraint is IS_BIT(mu): a single dense, idx-0, base-field root. + assert_eq!(HintConstraints.meta().len(), 1); + check_table("hint", &HintConstraints, cols::NUM_COLUMNS); + } +} diff --git a/prover/src/tests/count_table_lengths_drift_tests.rs b/prover/src/tests/count_table_lengths_drift_tests.rs index 6855fcb5b..7337f0790 100644 --- a/prover/src/tests/count_table_lengths_drift_tests.rs +++ b/prover/src/tests/count_table_lengths_drift_tests.rs @@ -3,16 +3,17 @@ use crate::tables::MaxRowsConfig; use crate::tables::trace_builder::{Traces, count_table_lengths}; use crate::test_utils::run_asm_elf; +use executor::elf::Elf; +use executor::vm::execution::Executor; +use executor::vm::logs::Log; -#[test] -fn count_table_lengths_matches_traces() { - let (elf, logs, _) = run_asm_elf("fib_iterative_372k"); +fn assert_count_table_lengths_matches(elf: &Elf, logs: &[Log]) { let max_rows = MaxRowsConfig::default(); let predicted = - count_table_lengths(&elf, &logs, &max_rows, &[]).expect("count_table_lengths succeeds"); - let traces = Traces::from_elf_and_logs_minimal(&elf, &logs, &max_rows, &[]) - .expect("trace build succeeds"); + count_table_lengths(elf, logs, &max_rows, &[]).expect("count_table_lengths succeeds"); + let traces = + Traces::from_elf_and_logs_minimal(elf, logs, &max_rows, &[]).expect("trace build succeeds"); let sum_heights = |tables: &[stark::trace::TraceTable<_, _>]| -> u64 { tables.iter().map(|t| t.main_table.height as u64).sum() @@ -91,3 +92,37 @@ fn count_table_lengths_matches_traces() { // Mirrors hardcoded `halt_rows = 1` in `auto_storage::table_specs`. assert_eq!(traces.halt.main_table.height, 1, "halt_rows"); } + +#[test] +fn count_table_lengths_matches_traces() { + let (elf, logs, _) = run_asm_elf("fib_iterative_372k"); + assert_count_table_lengths_matches(&elf, &logs); +} + +/// The `hint` ecall routes three register reads (`a0`/`a1`/`a2`) and four output +/// writes through the memory argument, plus two LT range-checks (selector, in_addr). +/// `count_table_lengths` must replay all of that exactly, or `memw_register` (an +/// exact-match table) drifts. Uses a real hint guest so the counts are non-trivial. +#[test] +fn count_table_lengths_matches_nonempty_hint_trace() { + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let elf_bytes = + std::fs::read(workspace_root.join("executor/program_artifacts/rust/hint_min.elf")) + .expect("hint_min.elf not found — run `make compile-programs-rust`"); + let elf = Elf::load(&elf_bytes).expect("valid hint guest ELF"); + let result = Executor::new(&elf, vec![]) + .expect("executor") + .run() + .expect("hint guest execution"); + + assert!( + result.logs.iter().any(|log| { + log.src1_val == executor::vm::instruction::execution::HINT_SYSCALL_NUMBER + }), + "fixture must contain a hint ecall" + ); + assert_count_table_lengths_matches(&elf, &result.logs); +} diff --git a/prover/src/tests/ecsm_tests.rs b/prover/src/tests/ecsm_tests.rs index 91956c5ad..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 { @@ -44,6 +60,21 @@ fn op_for(k: u64) -> EcsmOperation { addr_xg: 0x2000, addr_k: 0x3000, addr_xr: 0x1000, + is_affine: false, + witness, + } +} + +/// 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, } } @@ -94,7 +125,93 @@ fn constraints_hold_on_generated_trace() { #[test] fn constraint_set_count() { - assert_eq!(EcsmConstraints.meta().len(), 413); + 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 @@ -231,6 +348,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 +375,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/hint_tests.rs b/prover/src/tests/hint_tests.rs new file mode 100644 index 000000000..02f7b3761 --- /dev/null +++ b/prover/src/tests/hint_tests.rs @@ -0,0 +1,92 @@ +//! HINT constraint tests. + +use crate::tables::hint::{HintConstraints, HintOperation, cols, generate_hint_trace}; +use crate::tables::types::{FE, GoldilocksExtension, GoldilocksField}; +use math::field::element::FieldElement; +use stark::constraints::builder::{ConstraintSet, ProverEvalFolder}; +use stark::frame::Frame; +use stark::table::TableView; +use stark::traits::TransitionEvaluationContext; + +/// Evaluate the HINT constraint set on one main-trace row. +fn eval_main_row(main: Vec) -> Vec { + let n = HintConstraints.meta().len(); + let frame = Frame::::new(vec![TableView::new( + vec![main], + vec![vec![]], + )]); + let no_e: Vec> = vec![]; + let offset_e = FieldElement::::zero(); + let ctx = + TransitionEvaluationContext::new_prover(frame.as_row_frame(), &no_e, &no_e, &offset_e); + let mut base = vec![FE::zero(); n]; + let mut ext = vec![FieldElement::::zero(); n]; + let mut folder = ProverEvalFolder::new(&ctx, &mut base, &mut ext); + HintConstraints.eval(&mut folder); + base +} + +fn op(timestamp: u64, out_addr: u64) -> HintOperation { + HintOperation { + timestamp, + out_addr, + out_bytes: std::array::from_fn(|i| i as u8), + hint_id: 0, + in_addr: 0x3000, + } +} + +#[test] +fn constraint_set_count() { + assert_eq!(HintConstraints.meta().len(), 1); +} + +/// Every constraint holds on a generated trace — real rows (`mu = 1`) and the +/// all-zero padding rows (`mu = 0`) alike. +#[test] +fn constraints_hold_on_generated_trace() { + let trace = generate_hint_trace(&[op(4, 0x1000), op(8, 0x2000)]); + for row in 0..trace.num_rows() { + let main: Vec = (0..cols::NUM_COLUMNS) + .map(|c| *trace.main_table.get(row, c)) + .collect(); + for (i, v) in eval_main_row(main).iter().enumerate() { + assert_eq!(*v, FE::zero(), "constraint {i} must hold at row {row}"); + } + } +} + +/// `IS_BIT(mu)` rejects a row whose multiplicity is not a bit. +/// +/// The `Ecall` bus does not establish this on its own: its tuple carries a +/// per-instruction timestamp, so LogUp pins the *sum* of `mu` over the rows sharing a +/// tuple, which a witness can satisfy by spreading `mu` across rows with integer +/// weights summing to 1 (the real exploit uses a `+1`/`-1` pair, not a fractional +/// split; MEMW does not catch it — it only sees the legal `+1`, the `-1` cancelling an +/// honest STORE). This constraint rejects any non-boolean `mu` locally. The test below +/// tampers with a fractional `1/2`, which `IS_BIT` also rejects. +#[test] +fn is_bit_mu_rejects_non_boolean_multiplicity() { + let trace = generate_hint_trace(&[op(4, 0x1000)]); + let mut main: Vec = (0..cols::NUM_COLUMNS) + .map(|c| *trace.main_table.get(0, c)) + .collect(); + assert_eq!(main[cols::MU], FE::one(), "row 0 must be a real hint row"); + + // A halved multiplicity: 1/2 + 1/2 across two rows keeps the Ecall bus balanced. + let half = (FE::one() / (FE::one() + FE::one())).expect("2 is invertible"); + main[cols::MU] = half; + assert_ne!( + eval_main_row(main.clone())[0], + FE::zero(), + "IS_BIT(mu) must reject a fractional multiplicity" + ); + + // And any other non-bit value. + main[cols::MU] = FE::from(2u64); + assert_ne!( + eval_main_row(main)[0], + FE::zero(), + "IS_BIT(mu) must reject mu = 2" + ); +} diff --git a/prover/src/tests/mod.rs b/prover/src/tests/mod.rs index a3326bcd1..1dbebf9f5 100644 --- a/prover/src/tests/mod.rs +++ b/prover/src/tests/mod.rs @@ -47,6 +47,8 @@ pub mod ecsm_tests; #[cfg(test)] pub mod eq_tests; #[cfg(test)] +pub mod hint_tests; +#[cfg(test)] pub mod ir_stats_dump; #[cfg(test)] pub mod keccak_rnd_tests; diff --git a/prover/src/tests/ood_window_ir_tests.rs b/prover/src/tests/ood_window_ir_tests.rs index b4ff5766c..29d224627 100644 --- a/prover/src/tests/ood_window_ir_tests.rs +++ b/prover/src/tests/ood_window_ir_tests.rs @@ -114,4 +114,5 @@ fn all_table_windows_match_captured_ir() { assert_ood_window_matches_ir(&create_keccak_rc_air(&opts), true, "KECCAK_RC"); assert_ood_window_matches_ir(&create_ecsm_air(&opts), true, "ECSM"); assert_ood_window_matches_ir(&create_ecdas_air(&opts), true, "ECDAS"); + assert_ood_window_matches_ir(&create_hint_air(&opts), true, "HINT"); } diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index ffe9071b2..d294e3283 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -1210,6 +1210,324 @@ fn test_prove_ecsm_rust_guest() { ); } +/// End-to-end prove→verify for the non-constraining `Hint` ecall: the minimal Rust +/// guest does one `hint` call (secp256k1 base-field inverse of 3) and commits the result. +/// This exercises exactly the HINT table's bus surface (Ecall receive + the four +/// 8-byte output MEMW writes) end-to-end through prove→verify, de-risking the bus +/// balance before scaling to real consumers. The committed output must equal the +/// value the executor's `compute_hint` produced (= 3^{-1} mod p). +#[test] +fn test_prove_hint_min_rust_guest() { + let _ = env_logger::builder().is_test(true).try_init(); + + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let elf_bytes = + std::fs::read(workspace_root.join("executor/program_artifacts/rust/hint_min.elf")) + .expect("hint_min.elf not found — run `make compile-programs-rust`"); + + let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); + assert!( + verify_vm_minimal(&proof, &elf_bytes), + "hint_min rust guest should verify" + ); + + // Committed output must equal the hinted value (field inverse of 3, 32-byte BE). + let mut input = [0u8; 32]; + input[31] = 3; + let expected = + executor::vm::instruction::execution::compute_hint(0 /* HINT_FIELD_INV */, &input); + assert_eq!(proof.public_output, expected.to_vec()); +} + +/// Multi-hint: three `hint` ecalls, each result read back with +/// ordinary `LOAD`s. Complements `test_prove_hint_min_rust_guest` by proving the +/// paths the ethrex consumer relies on that a single-call guest doesn't: **multiple +/// real HINT rows** (padded) and **read-back via normal LOAD** (MEMW reads chaining +/// to the HINT writes). Committed output = XOR of the three field inverses. +#[test] +fn test_prove_hint_multi_rust_guest() { + let _ = env_logger::builder().is_test(true).try_init(); + + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let elf_bytes = + std::fs::read(workspace_root.join("executor/program_artifacts/rust/hint_multi.elf")) + .expect("hint_multi.elf not found — run `make compile-programs-rust`"); + + let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); + assert!( + verify_vm_minimal(&proof, &elf_bytes), + "hint_multi rust guest should verify" + ); + + // Expected = XOR of field-inverses of 3, 5, 7 (32-byte BE), matching the guest. + let mut expected = [0u8; 32]; + for seed in [3u8, 5u8, 7u8] { + let mut input = [0u8; 32]; + input[31] = seed; + let inv = + executor::vm::instruction::execution::compute_hint(0 /* HINT_FIELD_INV */, &input); + for i in 0..32 { + expected[i] ^= inv[i]; + } + } + assert_eq!(proof.public_output, expected.to_vec()); +} + +/// Consistency: the verifier REJECTS a HINT row that disagrees with the +/// MEMW rows. +/// +/// The HINT table's `out_bytes` are unconstrained *by the table* — the point of a +/// non-constraining hint. Editing one output byte on the (single) real HINT row makes +/// the MEMW write it sends stop matching the write the MEMW table received (the honest +/// value `collect_hint_ops` derived), so the Memw LogUp bus unbalances and the proof +/// must fail to verify. +/// +/// What this covers is an *internally inconsistent* trace — the failure mode of a buggy +/// trace builder. It is **not** a forgery test: a prover that edits the HINT row and the +/// corresponding MEMW rows together satisfies every constraint, because nothing in the +/// AIR pins *which* value was hinted. That guarantee lives in the guest's verify +/// (`x·inv == 1`, `y² == x³+7`), which this minimal guest deliberately omits. What the +/// AIR does pin is *where* the value lands and that it is 32 bytes — see +/// `test_hint_binds_out_addr_to_x12` and `test_hint_range_checks_its_output_bytes`. +#[test] +fn test_prove_hint_min_inconsistent_output_rejected() { + use crate::tables::hint::cols as hint_cols; + + let _ = env_logger::builder().is_test(true).try_init(); + + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let elf_bytes = + std::fs::read(workspace_root.join("executor/program_artifacts/rust/hint_min.elf")) + .expect("hint_min.elf not found — run `make compile-programs-rust`"); + let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); + let executor = Executor::new(&elf, vec![]).expect("Failed to create executor"); + let result = executor.run().expect("Failed to run program"); + let mut traces = + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); + + // Forge the low byte of the output on the (single) real HINT row. + let orig = *traces.hint.main_table.get(0, hint_cols::out(0)); + let forged = orig + FieldElement::::one(); + traces.hint.main_table.set(0, hint_cols::out(0), forged); + + assert!( + !prove_and_verify_vm_minimal(&elf, &mut traces), + "Verifier must reject a forged hint output byte" + ); +} + +/// Load `hint_min` and build its minimal traces (for the operand-forgery tests below). +fn hint_min_traces() -> (Elf, Traces) { + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let elf_bytes = + std::fs::read(workspace_root.join("executor/program_artifacts/rust/hint_min.elf")) + .expect("hint_min.elf not found — run `make compile-programs-rust`"); + let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); + let result = Executor::new(&elf, vec![]) + .expect("Failed to create executor") + .run() + .expect("Failed to run program"); + let traces = + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); + (elf, traces) +} + +/// Soundness: the verifier REJECTS a HINT row whose selector is out of range. +/// +/// The executor rejects `hint_id ∉ {0,1,2}` up front (`HintUnknownSelector`). The AIR +/// now matches that: it binds the selector to `x10` and range-checks it `< 3`, so a +/// witness cannot prove a hint the executor would reject. Before `a0` was bound this +/// forgery verified. Forcing the selector to 3 (one past the valid set) unbalances both +/// the `x10` register read and the `LT(selector, 3)` interaction. +#[test] +fn test_prove_hint_min_forged_selector_rejected() { + use crate::tables::hint::cols as hint_cols; + let (elf, mut traces) = hint_min_traces(); + traces.hint.main_table.set( + 0, + hint_cols::SEL_0, + FieldElement::::from(3u64), + ); + assert!( + !prove_and_verify_vm_minimal(&elf, &mut traces), + "Verifier must reject a hint with an out-of-range selector" + ); +} + +/// Soundness: the verifier REJECTS a HINT row whose input address would straddle the +/// 32-bit limb boundary — the executor rejects it (`HintAddressOverflow`), and the AIR +/// now binds `in_addr` to `x11` and range-checks its low limb `< 2^32 - 31`. Forcing +/// the low limb to `2^32 - 1` unbalances the `x11` read and the `LT` interaction. +#[test] +fn test_prove_hint_min_forged_input_address_rejected() { + use crate::tables::hint::cols as hint_cols; + let (elf, mut traces) = hint_min_traces(); + traces.hint.main_table.set( + 0, + hint_cols::ADDR_IN_0, + FieldElement::::from(0xFFFF_FFFFu64), + ); + assert!( + !prove_and_verify_vm_minimal(&elf, &mut traces), + "Verifier must reject a hint whose input range crosses the limb boundary" + ); +} + +/// Column a bus value reads, for the structural HINT tests below. +fn hint_bus_column(v: &stark::lookup::BusValue) -> Option { + match v { + stark::lookup::BusValue::Packed { start_column, .. } => Some(*start_column), + stark::lookup::BusValue::Linear(_) => None, + } +} + +/// Constant a bus value holds, for the structural HINT tests below. +fn hint_bus_constant(v: &stark::lookup::BusValue) -> Option { + match v { + stark::lookup::BusValue::Linear(terms) => match terms.as_slice() { + [stark::lookup::LinearTerm::Constant(c)] => Some(*c), + _ => None, + }, + stark::lookup::BusValue::Packed { .. } => None, + } +} + +/// Soundness: the HINT table must bind its output address to `x12` (the ecall's `a2`). +/// +/// The four output writes take their base from `ADDR_OUT_0`, an ordinary column in a +/// table with no algebraic constraints, so the register read asserted here is the only +/// thing pinning that column to the register the CPU actually held. Without it the +/// witness chooses *where* the 32 hinted bytes land — an arbitrary memory write, which +/// is a strictly larger hole than the unconstrained value the table is designed around. +/// +/// Asserted structurally rather than by tampering: editing `ADDR_OUT_0` in a trace also +/// unbalances the honest MEMW rows, so a tamper test passes either way and would not +/// notice this interaction being dropped. +#[test] +fn test_hint_binds_out_addr_to_x12() { + use crate::tables::hint::{bus_interactions, cols as hint_cols}; + use crate::tables::types::BusId; + use stark::lookup::Multiplicity; + + let memw_id = u64::from(BusId::Memw); + let reads: Vec<_> = bus_interactions() + .into_iter() + .filter(|i| i.bus_id == memw_id && i.is_sender && i.values.len() == 24) + .collect(); + assert_eq!( + reads.len(), + 3, + "HINT must send three MEMW register reads (a0 → x10, a1 → x11, a2 → x12)" + ); + // The out_addr binding is the x12 read (base address 2*12); the a0/a1 reads bind + // the selector and input address, checked by the range-check interactions. + let out_read = reads + .iter() + .find(|r| hint_bus_constant(&r.values[9]) == Some(2 * 12)) + .expect("HINT must send a MEMW register read for x12 (out_addr)"); + let v = &out_read.values; + + // CO24 read layout: old[8], is_register, base_lo, base_hi, value[8], ts_lo, ts_hi, + // w2, w4, w8. + assert_eq!(hint_bus_constant(&v[8]), Some(1), "is_register must be 1"); + assert_eq!( + hint_bus_constant(&v[9]), + Some(2 * 12), + "register address must be x12 (the ecall's a2)" + ); + assert_eq!(hint_bus_constant(&v[10]), Some(0), "address hi must be 0"); + assert_eq!( + hint_bus_constant(&v[21]), + Some(1), + "w2 must be 1 for a 2-word register access" + ); + for (slot, col) in [(0, hint_cols::ADDR_OUT_0), (1, hint_cols::ADDR_OUT_1)] { + assert_eq!( + hint_bus_column(&v[slot]), + Some(col), + "old[{slot}] must carry out_addr" + ); + assert_eq!( + hint_bus_column(&v[11 + slot]), + Some(col), + "value[{slot}] must carry out_addr (a read leaves the register unchanged)" + ); + } + // The read must happen at THE ecall's timestamp (ts_lo/ts_hi = slots 19/20). A + // register read bound to x12 but at some other timestamp would pin out_addr to + // whatever x12 held then, not at the ecall — the writes below all use the same + // TIMESTAMP columns, so the binding is only meaningful if it reads x12 at T. + assert_eq!( + hint_bus_column(&v[19]), + Some(hint_cols::TIMESTAMP_0), + "ts_lo must be the ecall timestamp (the read must occur at T)" + ); + assert_eq!( + hint_bus_column(&v[20]), + Some(hint_cols::TIMESTAMP_1), + "ts_hi must be the ecall timestamp (the read must occur at T)" + ); + assert!( + matches!(out_read.multiplicity, Multiplicity::Column(c) if c == hint_cols::MU), + "the register read must be gated by mu, like every other HINT interaction" + ); +} + +/// Soundness: the HINT table must range-check all 32 output cells as bytes. +/// +/// The cells are free columns that enter memory as MEMW write values, and MEMW +/// range-checks nothing it receives — every table that writes fresh values into memory +/// (STORE, KECCAK, ECSM, PAGE) checks its own cells for that reason. The hinted value is +/// allowed to be wrong; it is not allowed to be a field element outside `[0, 256)`, or +/// the witness can smuggle non-bytes into memory and break the byte decomposition that +/// loads and the ALU rely on. +#[test] +fn test_hint_range_checks_its_output_bytes() { + use crate::tables::hint::{bus_interactions, cols as hint_cols}; + use crate::tables::types::BusId; + use stark::lookup::Multiplicity; + + let are_bytes_id = u64::from(BusId::AreBytes); + let checks: Vec<_> = bus_interactions() + .into_iter() + .filter(|i| i.bus_id == are_bytes_id) + .collect(); + assert_eq!(checks.len(), 16, "32 output cells, paired two per lookup"); + + let mut covered = std::collections::BTreeSet::new(); + for check in &checks { + assert!(check.is_sender, "range checks are sends; BITWISE receives"); + assert_eq!(check.values.len(), 2, "ARE_BYTES takes exactly two values"); + assert!( + matches!(check.multiplicity, Multiplicity::Column(c) if c == hint_cols::MU), + "range checks must be gated by mu, or padding rows unbalance BITWISE" + ); + for v in &check.values { + covered + .insert(hint_bus_column(v).expect("a range check must reference an output column")); + } + } + + // 16 lookups × 2 slots = 32 slots; 32 distinct columns means each cell exactly once. + let expected: std::collections::BTreeSet = (0..32).map(hint_cols::out).collect(); + assert_eq!( + covered, expected, + "every output cell must be range-checked exactly once" + ); +} + /// Soundness: the verifier REJECTS a forged ECSM result. /// /// A malicious prover must not be able to claim a wrong `k·G`. We tamper the result @@ -1272,6 +1590,120 @@ 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" + ); +} + +/// 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. /// diff --git a/prover/tests/gpu_constraint_interp_real.rs b/prover/tests/gpu_constraint_interp_real.rs index 2cea4be1b..4446fb446 100644 --- a/prover/tests/gpu_constraint_interp_real.rs +++ b/prover/tests/gpu_constraint_interp_real.rs @@ -271,4 +271,5 @@ fn all_table_programs_gpu_match_cpu_oracle() { check_air(&create_keccak_rc_air(&opts), "KECCAK_RC"); check_air(&create_ecsm_air(&opts), "ECSM"); check_air(&create_ecdas_air(&opts), "ECDAS"); + check_air(&create_hint_air(&opts), "HINT"); } diff --git a/syscalls/src/syscalls.rs b/syscalls/src/syscalls.rs index 7165dff81..7c3f470a1 100644 --- a/syscalls/src/syscalls.rs +++ b/syscalls/src/syscalls.rs @@ -29,10 +29,25 @@ 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. +/// Must match `executor::...::execution::HINT_SYSCALL_NUMBER` (u64::MAX - 30). +#[cfg(target_arch = "riscv64")] +const HINT_SYSCALL_NUMBER: usize = usize::MAX - 30; + +/// Hint selectors passed in `a0` (must match the executor's `HINT_*`). +pub const HINT_FIELD_INV: usize = 0; +pub const HINT_SCALAR_INV: usize = 1; +pub const HINT_FIELD_SQRT: usize = 2; + /// No-op. The `Print` ecall (a7=1) has no receiver on the Ecall bus, so emitting /// it makes the LogUp bus unbalance and the proof fail to verify. Printing isn't /// needed in provable programs, so `print_string` does nothing on every target. @@ -181,12 +196,63 @@ pub fn ecsm_mul(xr: &mut [u8; 32], xg: &[u8; 32], k: &[u8; 32]) { } } +/// 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], 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") input.as_ptr(), // x11 = address of [xG‖yG] (64 bytes) + in("a2") k.as_ptr(), // x12 = address of k + in("a7") ECSM_AFFINE_SYSCALL_NUMBER, + ) + } +} + +#[cfg(not(target_arch = "riscv64"))] +pub fn ecsm_mul_affine(_out: &mut [u8; 64], _input: &[u8; 64], _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]) { unimplemented!("syscalls are only implemented for riscv64 targets"); } +/// Ask the host for a non-constraining hint (modular inverse/sqrt). +/// `hint_id` selects the operation ([`HINT_FIELD_INV`]/[`HINT_SCALAR_INV`]/ +/// [`HINT_FIELD_SQRT`]); `input`/`out` are 32-byte **big-endian** field/scalar +/// elements — k256's own serialization, so consumers pass `to_bytes()` straight +/// through. Note this differs from [`ecsm_mul`], which is little-endian. +/// The result is UNTRUSTED — the caller MUST verify it in-guest (e.g. `x·inv == 1`) +/// AND recompute in software on failure, since this ecall adds no correctness +/// constraint and the prover chooses the returned bytes. +#[cfg(target_arch = "riscv64")] +pub fn hint(hint_id: usize, out: &mut [u8; 32], input: &[u8; 32]) { + unsafe { + asm!( + "ecall", + in("a0") hint_id, // x10 = hint selector + in("a1") input.as_ptr(), // x11 = input address (32-byte BE) + in("a2") out.as_mut_ptr(), // x12 = output address (32-byte BE) + in("a7") HINT_SYSCALL_NUMBER, + ) + } +} + +#[cfg(not(target_arch = "riscv64"))] +pub fn hint(_hint_id: usize, _out: &mut [u8; 32], _input: &[u8; 32]) { + unimplemented!("syscalls are only implemented for riscv64 targets"); +} + // ============================================================================= // Stub implementations for unsupported std functions // These functions are required by Rust's std zkvm module but are not supported diff --git a/tooling/ethrex-tests/Cargo.lock b/tooling/ethrex-tests/Cargo.lock index 250e2411f..4295b4402 100644 --- a/tooling/ethrex-tests/Cargo.lock +++ b/tooling/ethrex-tests/Cargo.lock @@ -875,6 +875,7 @@ name = "executor" version = "0.1.0" dependencies = [ "ecsm", + "k256", "rustc-demangle", "thiserror 1.0.69", ]