diff --git a/.github/workflows/pr_main.yaml b/.github/workflows/pr_main.yaml index a4554fda2..ad46ce289 100644 --- a/.github/workflows/pr_main.yaml +++ b/.github/workflows/pr_main.yaml @@ -134,6 +134,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 4abed4a90..b648157f3 100644 --- a/Makefile +++ b/Makefile @@ -90,7 +90,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 @@ -332,7 +332,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/ethrex-crypto/src/lib.rs b/crypto/ethrex-crypto/src/lib.rs index c1e5d8446..702416ea6 100644 --- a/crypto/ethrex-crypto/src/lib.rs +++ b/crypto/ethrex-crypto/src/lib.rs @@ -19,8 +19,12 @@ use ethrex_crypto::keccak::keccak_hash; use ethrex_crypto::{Crypto, CryptoError}; use k256::elliptic_curve::group::prime::PrimeCurveAffine; -use k256::elliptic_curve::ops::{Invert, LinearCombination, Reduce}; -use k256::elliptic_curve::point::DecompressPoint; +use k256::elliptic_curve::ops::{LinearCombination, Reduce}; +// `Invert` provides the software `x.invert()/invert_vartime()`. It is used by the +// host path AND, on the riscv64 guest, by the mandatory software fallback that +// runs whenever a hinted inverse fails to verify (a lying host). It is therefore +// needed in every build, not only off-target. +use k256::elliptic_curve::ops::Invert; use k256::elliptic_curve::sec1::ToEncodedPoint; use k256::elliptic_curve::PrimeField; use k256::{AffinePoint, FieldBytes, ProjectivePoint, Scalar, U256}; @@ -60,6 +64,155 @@ impl Crypto for LambdaVmEcsmCrypto { // ── ECDSA secp256k1 recovery via the ECSM precompile ──────────────────────── +/// Obtain a 32-byte big-endian hint for `x_be` via the executor `hint` ecall +/// (the host computes the modular inverse / sqrt; the value is provable via the +/// prover's HINT table). The result is UNTRUSTED — the ecall adds no correctness +/// constraint, so every caller MUST verify it in-guest (`x·inv == 1`, `y² == x³+7`) +/// AND recompute in software on any verification failure. The hint is only ever +/// allowed to save work, never to change the answer: because the prover chooses the +/// bytes, an unverified-or-rejected-outright hint would let it steer a caller's +/// accept/reject outcome (e.g. force a valid signature to look invalid). See +/// [`scalar_inv`] / [`decompress_r`] for the fallback that closes that hole. +#[cfg(target_arch = "riscv64")] +fn get_hint(hint_id: usize, x_be: &[u8; 32]) -> [u8; 32] { + // 8-byte-aligned output buffer so the HINT table's four 8-byte writes land on the + // aligned memory path (MEMW_A) instead of the general MEMW path. An `[u8; 32]` on + // the stack is only 1-aligned, which forces the four writes onto the unaligned + // path and inflates the trace. + #[repr(C, align(8))] + struct Aligned32([u8; 32]); + let mut out = Aligned32([0u8; 32]); + lambda_vm_syscalls::syscalls::hint(hint_id, &mut out.0, x_be); + out.0 +} + +/// Scalar-field inverse `x⁻¹ mod n`. +/// +/// On riscv64 the inverse is first requested from the untrusted `hint` ecall and +/// verified in-guest (`x·inv == 1`); **on any verification failure it is recomputed +/// in software.** `x⁻¹` exists for every `x` this is called with — the only caller, +/// `ecsm_ecrecover`, guarantees `r ≠ 0` before calling — so a failed verify can only +/// mean the host lied, and the software value is authoritative. This is what keeps +/// the result independent of the prover-chosen hint: a bad hint makes the guest do +/// more work, it can never change the answer, so it cannot turn a valid signature +/// into a recovery failure. Off-target (host) it inverts in software directly. +fn scalar_inv(x: &Scalar) -> Option { + #[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); }; @@ -180,6 +332,55 @@ fn ecsm_oracle(x: &FieldElement, k: &Scalar) -> Option { Option::from(FieldElement::from_bytes(&xr_le.into())) } +/// Base-field inverse `x⁻¹ mod p`. +/// +/// On riscv64 the inverse is first requested from the untrusted `hint` ecall and +/// verified in-guest (`x·inv == 1`); **on any verification failure it is recomputed +/// in software.** A bad hint can only cost the guest extra work, never change the +/// answer — it cannot steer a caller's accept/reject outcome. Off-target it inverts +/// in software directly. Returns `None` only for a genuinely non-invertible input +/// (`x = 0`), which the callers' degeneracy guards already exclude. +#[cfg(any(target_arch = "riscv64", test))] +fn field_inv(x: &FieldElement) -> Option { + #[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 four x-only oracle queries, or `None` if any /// degenerate-configuration guard trips. /// @@ -232,7 +433,7 @@ where // One shared inversion for the two λ denominators and the final chord. let den1 = y1.double() * dx1; let den2 = y2.double() * dx2; - let inv = Option::::from((den1 * den2 * dxq).invert())?; + let inv = field_inv(&(den1 * den2 * dxq))?; let inv_den1 = inv * den2 * dxq; let inv_den2 = inv * den1 * dxq; let inv_dxq = inv * den1 * den2; 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..42e80224b 100644 --- a/crypto/ethrex-crypto/src/tests/ecsm_tests.rs +++ b/crypto/ethrex-crypto/src/tests/ecsm_tests.rs @@ -61,8 +61,14 @@ fn edge_scalars_fall_back() { 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()); + 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() + ); } } 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/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/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..647dd0079 100644 --- a/executor/src/vm/instruction/execution.rs +++ b/executor/src/vm/instruction/execution.rs @@ -16,6 +16,9 @@ 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, } /// Syscall number for KeccakPermute (u64::MAX - 1 = 0xFFFF_FFFF_FFFF_FFFE). @@ -31,6 +34,20 @@ const KECCAK_STATE_BYTES: u64 = 25 * 8; /// bus as `[lo32, hi32] = [2^32 - 11, 2^32 - 1]`. pub const ECSM_SYSCALL_NUMBER: u64 = u64::MAX - 10; +/// 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 +62,7 @@ impl TryFrom for SyscallNumbers { 93 => Ok(SyscallNumbers::Halt), v if v == KECCAK_SYSCALL_NUMBER => Ok(SyscallNumbers::KeccakPermute), v if v == ECSM_SYSCALL_NUMBER => Ok(SyscallNumbers::Ecsm), + v if v == HINT_SYSCALL_NUMBER => Ok(SyscallNumbers::Hint), _ => Err(()), } } @@ -68,7 +86,8 @@ impl SyscallNumbers { SyscallNumbers::Print | SyscallNumbers::Panic | SyscallNumbers::Commit - | SyscallNumbers::Halt => None, + | SyscallNumbers::Halt + | SyscallNumbers::Hint => None, } } } @@ -93,8 +112,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 } @@ -429,9 +499,9 @@ impl Instruction { 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); } @@ -454,6 +524,38 @@ impl Instruction { 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 +736,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..fc4c2f976 100644 --- a/prover/src/tables/cpu.rs +++ b/prover/src/tables/cpu.rs @@ -188,6 +188,11 @@ pub struct CpuOperation { /// Whether this ECALL is an ECSM (elliptic-curve scalar multiply) syscall pub ecall_ecsm: 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 { @@ -235,6 +240,8 @@ impl CpuOperation { // in the trace builder. let ecall_ecsm = 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 +360,7 @@ impl CpuOperation { ecall_keccak, keccak_state_addr, ecall_ecsm, + ecall_hint, } } 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..ef6ade6d7 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, ) } @@ -948,6 +959,81 @@ fn collect_ecsm_ops( (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 +2334,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 // ============================================================================= @@ -2776,6 +2879,9 @@ pub struct Traces { /// ECDAS double/add table (variable rows per ecall) pub ecdas: TraceTable, + /// 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 +2924,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 +2980,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 +3123,7 @@ fn collect_all_ops( cpu32_ops, ecsm_ops, ecdas_ops, + hint_ops, } } @@ -3057,6 +3167,7 @@ fn build_traces( cpu32_ops, ecsm_ops, ecdas_ops, + hint_ops, } = ops; // ===================================================================== @@ -3064,6 +3175,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 +3217,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 +3257,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 +3544,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 +3558,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 +3600,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 +3628,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 +3663,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 +3731,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 +3905,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 +4012,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 +4051,7 @@ impl Traces { keccak_rc, ecsm, ecdas, + hint, memw_registers, eqs, bytewises, @@ -3966,6 +4119,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 +4161,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 +4184,7 @@ impl Traces { keccak_rc, ecsm, ecdas, + hint, memw_registers, eqs, bytewises, @@ -4096,6 +4252,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 +4526,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 +4545,7 @@ impl Traces { cpu32_ops, ecsm_ops, ecdas_ops, + hint_ops, &mut register_state, is_final, ); @@ -4480,6 +4639,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 +4654,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/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..c120650f5 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 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..5228455ea 100644 --- a/syscalls/src/syscalls.rs +++ b/syscalls/src/syscalls.rs @@ -33,6 +33,16 @@ const KECCAK_SYSCALL_NUMBER: usize = usize::MAX - 1; #[cfg(target_arch = "riscv64")] const ECSM_SYSCALL_NUMBER: usize = usize::MAX - 10; +/// 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. @@ -187,6 +197,32 @@ 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 26f991c7a..46b6e075d 100644 --- a/tooling/ethrex-tests/Cargo.lock +++ b/tooling/ethrex-tests/Cargo.lock @@ -874,6 +874,7 @@ name = "executor" version = "0.1.0" dependencies = [ "ecsm", + "k256", "rustc-demangle", "thiserror 1.0.69", ]