diff --git a/.github/workflows/pr_main.yaml b/.github/workflows/pr_main.yaml index 1ff124048..2d7c1723b 100644 --- a/.github/workflows/pr_main.yaml +++ b/.github/workflows/pr_main.yaml @@ -117,6 +117,15 @@ jobs: run: | cargo test --release -p executor --test flamegraph + # The unit tests under `executor/src/tests/` are a *lib* target (`pub mod tests;` + # in lib.rs), which none of the `--test ` steps above select — and the + # `test_ckzg` step below filters by name, so it doesn't run them either. Without + # this step they never run in CI. It shares the lib test binary with that step, + # so it costs a test run, not an extra compile. + - name: Run executor lib unit tests + run: | + cargo test --release -p executor --lib + - name: Run ignored executor tests run: | cargo test --release -p executor test_ckzg -- --ignored @@ -169,6 +178,9 @@ jobs: - name: Run syscalls host tests (keccak differential vs sha3) run: make test-syscalls + - name: Run ethrex-crypto host tests (hint verify-then-fallback + ecrecover) + run: make test-ethrex-crypto + # "Test" is a required check — keep this name to avoid branch protection changes. # This gate job passes only when CLI, executor, disk-spill, and prover tests succeed. test: diff --git a/Cargo.lock b/Cargo.lock index 261e07ed5..d197e2070 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -584,6 +584,8 @@ name = "executor" version = "0.1.0" dependencies = [ "ecsm", + "k256", + "lambda-vm-syscalls", "proptest", "rustc-demangle", "serde", diff --git a/Makefile b/Makefile index 25dce43de..a4b05b507 100644 --- a/Makefile +++ b/Makefile @@ -93,7 +93,7 @@ ASM_LDFLAGS ?= -fuse-ld=lld -nostdlib -Wl,-e,main # Custom RV64IM target spec location RV64_TARGET_SPEC=$(CURDIR)/executor/programs/riscv64im-lambda-vm-elf.json -.PHONY: test prepare-sysroot +.PHONY: test test-syscalls test-ethrex-crypto prepare-sysroot # The guard checks for include/stdlib.h (not just the include/ dir) so that a PARTIAL # sysroot — directories present but missing the C standard library headers — is detected @@ -517,7 +517,20 @@ check-ethrex-fixture-checksums: test-syscalls: cd syscalls && cargo test -test: compile-programs test-syscalls +# ethrex-crypto is a detached workspace (excluded from the root members), so a +# root `cargo test` never runs it. Run it explicitly, like test-syscalls. +# Run BOTH profiles deliberately. k256 swaps its FieldElement implementation on +# `debug_assertions` (k256 0.13.4 arithmetic/field.rs): debug uses the +# magnitude-tracking `field_impl` wrapper, release uses the raw FieldElement5x52. +# The guest ELF is built with --release, so a release run is the only one that +# exercises the implementation that actually ships; the debug run is kept because +# its magnitude debug_asserts turn a contract violation into a loud panic instead +# of a silently wrong value. +test-ethrex-crypto: + cd crypto/ethrex-crypto && cargo test + cd crypto/ethrex-crypto && cargo test --release + +test: compile-programs test-syscalls test-ethrex-crypto cargo test # === Quick test shortcuts === diff --git a/bench_vs/lambda/recursion/Cargo.lock b/bench_vs/lambda/recursion/Cargo.lock index 3e7f8e9a5..d90d9dbcf 100644 --- a/bench_vs/lambda/recursion/Cargo.lock +++ b/bench_vs/lambda/recursion/Cargo.lock @@ -129,8 +129,6 @@ dependencies = [ "digest", "lambda-vm-syscalls", "math", - "rand 0.8.6", - "rand_chacha 0.3.1", "rkyv", "serde", "sha3", @@ -234,6 +232,7 @@ name = "executor" version = "0.1.0" dependencies = [ "ecsm", + "k256", "rustc-demangle", "thiserror", ] @@ -399,7 +398,7 @@ dependencies = [ "getrandom 0.2.17", "getrandom 0.3.4", "lazy_static", - "rand 0.9.4", + "rand", "riscv", "thiserror", ] @@ -435,7 +434,6 @@ dependencies = [ "getrandom 0.2.17", "num-bigint", "num-traits", - "rand 0.8.6", "rayon", "rkyv", "serde", @@ -585,35 +583,16 @@ dependencies = [ "ptr_meta", ] -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha 0.9.0", + "rand_chacha", "rand_core 0.9.5", ] -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - [[package]] name = "rand_chacha" version = "0.9.0" diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index 0336ff821..aa47b70e0 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -1132,6 +1132,7 @@ mod tests { (SyscallNumbers::KeccakPermute, Some(Accelerator::Keccak)), (SyscallNumbers::Ecsm, Some(Accelerator::Ecsm)), (SyscallNumbers::DmaMemcpy, Some(Accelerator::Dma)), + (SyscallNumbers::DmaMemset, Some(Accelerator::Dma)), (SyscallNumbers::Print, None), (SyscallNumbers::Panic, None), (SyscallNumbers::Commit, None), diff --git a/crypto/ethrex-crypto/src/lib.rs b/crypto/ethrex-crypto/src/lib.rs index c1e5d8446..ec36b0831 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,158 @@ impl Crypto for LambdaVmEcsmCrypto { // ── ECDSA secp256k1 recovery via the ECSM precompile ──────────────────────── +/// Obtain a 32-byte big-endian hint for `x_be` via the executor `hint` ecall +/// (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)`, whose debug assert requires magnitude <= 1. `square()` + // always returns magnitude 1, whereas `rhs` is a sum carrying magnitude 2, so + // negating it would trip that assert and panic in debug builds. (The value would + // still come out right — `negate(m)` computes `2*(m+1)*P_limb - self`, which for a + // magnitude-2 operand stays non-negative — so this is a build-configuration + // hazard, not a wrong answer.) + // (`ct_eq` is unusable here for the same reason as in `field_inv`.) + if !bool::from((rhs + y2.negate(1)).normalizes_to_zero()) { + return None; + } + // Select the root whose canonical LSB matches the requested parity. + let y_odd = (y.to_bytes()[31] & 1) == 1; + if y_odd != y_is_odd { + y = -y; + } + // Build the affine point; `from_encoded_point` re-checks it's on-curve. + let ep = EncodedPoint::from_affine_coordinates(&x.to_bytes(), &y.to_bytes(), false); + Option::from(AffinePoint::from_encoded_point(&ep)) +} + /// Recover the uncompressed public key bytes (X‖Y, 64 bytes) from a 64-byte /// signature, recovery id, and 32-byte message hash. Used by the ECRECOVER /// precompile (0x01). @@ -96,15 +252,14 @@ fn ecsm_ecrecover(sig: &[u8; 64], recid: u8, msg: &[u8; 32]) -> Result<[u8; 64], // precompile; we don't handle it (decompression simply fails), matching the // trait default. let y_is_odd = (recid & 1) != 0; - let r_point: Option = - AffinePoint::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 +335,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 +436,7 @@ where // One shared inversion for the two λ denominators and the final chord. let den1 = y1.double() * dx1; let den2 = y2.double() * dx2; - let inv = Option::::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..ace59f208 --- /dev/null +++ b/crypto/ethrex-crypto/src/tests/hint_tests.rs @@ -0,0 +1,270 @@ +//! 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 the whole hint design rests on: because the +//! prover chooses the hinted bytes and the ecall adds no correctness constraint, a +//! bad hint must only be able to 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 scalar_inv_canonical_but_wrong_hint_falls_back_to_software() { + // The `[0; 32]` / `[0xFF; 32]` lies above both die in `Scalar::from_repr` — they + // never reach the verify predicate. These two are perfectly canonical scalars that + // simply aren't the inverse, so they exercise the rejecting branch of + // `(x * inv) == 1` itself, which is the check that actually has to hold. + for k in [1u64, 2, 12345] { + let x = Scalar::from(k); + let sw = x.invert_vartime().unwrap(); + for (name, lie) in [("inv + 1", sw + Scalar::ONE), ("-inv", -sw)] { + let lie_be: [u8; 32] = lie.to_bytes().into(); + let got = scalar_inv_with_oracle(&x, |_| lie_be).expect("fallback recomputes"); + assert_eq!( + got, sw, + "a canonical-but-wrong hint ({name}) must be rejected and recomputed (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})" + ); + } + } +} + +/// Sqrt oracle returning the *other* root (`−y`). Not a lie: `−y` is as valid a root +/// of `x³+7` as `y`, so the in-guest verify accepts it and the software fallback +/// never runs — fixing the sign is entirely on the parity-selection branch. +fn negated_field_sqrt(rhs_be: &[u8; 32]) -> [u8; 32] { + let honest = honest_field_sqrt(rhs_be); + let y = Option::::from(FieldElement::from_bytes(&honest.into())) + .expect("the honest root is canonical"); + (-y).normalize().to_bytes().into() +} + +#[test] +fn decompress_r_negated_sqrt_hint_recovers_the_point() { + // The hinted root's parity is the host's choice — `compute_hint` returns whichever + // root k256's `sqrt()` picks, so the caller must not depend on it. With the honest + // oracle the parity branch fires only for the `k` values whose root happens to have + // the wrong parity; forcing the negation exercises the *other* half of the branch + // for every `k`. A `Some` here comes from the hinted path, not the fallback, so a + // broken parity fix would return `-P` and fail the comparison. + 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, negated_field_sqrt) + .expect("the other root is still a root"); + assert_eq!( + sec1(&got), + sec1(&p), + "a negated (but valid) root must still recover the point (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})" + ); + } + } +} + +#[test] +fn field_inv_canonical_but_wrong_hint_falls_back_to_software() { + // As in the scalar case: the `[0; 32]` / `[0xFF; 32]` lies die in + // `FieldElement::from_bytes`, so they never reach the verify predicate. These two + // parse cleanly and are simply not the inverse, exercising the rejecting branch of + // `x·inv − 1 == 0` — the check the fast path's soundness actually rests on. + for k in [1u64, 2, 12345] { + let x = fe_from_u64(k); + let sw = Option::::from(x.invert()) + .unwrap() + .normalize(); + for (name, lie) in [ + ("inv + 1", (sw + FieldElement::ONE).normalize()), + ("-inv", -sw), + ] { + let lie_be: [u8; 32] = lie.normalize().to_bytes().into(); + let got = field_inv_with_oracle(&x, |_| lie_be).expect("fallback recomputes"); + assert_eq!( + got.normalize().to_bytes(), + sw.to_bytes(), + "a canonical-but-wrong hint ({name}) must be rejected and recomputed (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/crypto/stark/src/tests/aux_opening_width_tests.rs b/crypto/stark/src/tests/aux_opening_width_tests.rs new file mode 100644 index 000000000..925f8111c --- /dev/null +++ b/crypto/stark/src/tests/aux_opening_width_tests.rs @@ -0,0 +1,715 @@ +//! Regression tests for the **main↔aux** term of the opening-width pin +//! (`verifier::trace_opening_widths_well_formed`); the precomputed↔main term and +//! the direct guard tests live in `tests::opening_width_tests`. +//! +//! Everything here is attacker-side — a hostile AIR *declaration* plus the trace +//! it implies. Unlike the precomputed instance, this one needs **no prover +//! change at all**: both sides absorb main-root-then-aux-root either way, so the +//! transcripts agree and an untouched prover produces the forgery. +//! +//! Mechanism +//! --------- +//! `verify_trace_openings` only Merkle-checks each of the three trace openings +//! against its own root; it never compared the aux opening width against +//! `air.num_auxiliary_rap_columns()`. The only width constraint was, in +//! `reconstruct_deep_composition_poly_evaluation_pair`: +//! +//! num_base + num_aux == ood_width +//! +//! with `num_base` and `num_aux` read off the *prover-supplied openings*. The +//! **total** is pinned (`ood_blocks_well_formed`) but the **split** was not, so a +//! prover could commit the last `k` main columns in the AUXILIARY tree instead. +//! +//! Why that breaks LogUp: the main root is absorbed in round 1 phase A, the +//! shared LogUp challenges `z`/`alpha` are sampled immediately after, and the aux +//! root only in phase C. A column moved into the aux tree is therefore chosen +//! AFTER `z` and `alpha` are known, which collapses the multiset equality into a +//! single scalar equation the prover solves — no fingerprint collision needed. +//! +//! Vehicle: `LogReadOnlyRAP`, the in-repo continuous read-only-memory AIR whose +//! memory consistency rests entirely on LogUp. Honest layout (5, 1): +//! main = [a, v, a', v', m], aux = [s]. The attacker declares (4, 2): +//! main = [a, v, a', v'], aux = [m, s] — same global column order, same +//! constraints, same OOD width, so an unpinned verifier cannot tell. The +//! multiplicity column `m` is then picked after `z`/`alpha`. The moved column is +//! the multiplicity column on purpose: `traits.rs:182-188` documents the trailing +//! main columns of every preprocessed table as exactly the multiplicities. +//! +//! On stock `main` the two break tests below are ACCEPTED, including over the +//! rkyv wire through `multi_verify_archived` (the recursion-guest path). The +//! three controls are rejected on both, and discriminate the harness. + +use std::marker::PhantomData; + +use crate::constraints::{ + boundary::{BoundaryConstraint, BoundaryConstraints}, + builder::{ + ConstraintBuilder, ConstraintMeta, ConstraintSet, RowDomain, num_base_from_meta, + run_transition_prover, run_transition_verifier, + }, +}; +use crate::context::AirContext; +use crate::examples::read_only_memory_logup::{ + LogReadOnlyPublicInputs, LogReadOnlyRAP, read_only_logup_trace, +}; +use crate::proof::options::ProofOptions; +use crate::proof::view::StarkProofView; +use crate::prover::{IsStarkProver, Prover}; +use crate::trace::TraceTable; +use crate::traits::{AIR, TransitionEvaluationContext}; +use crate::verifier::{IsStarkVerifier, Verifier}; +use crypto::fiat_shamir::default_transcript::DefaultTranscript; +use math::field::element::FieldElement; +use math::field::extensions_goldilocks::Degree3GoldilocksExtensionField; +use math::field::goldilocks::GoldilocksField; + +type F = GoldilocksField; +type E = Degree3GoldilocksExtensionField; +type Felt = FieldElement; +type Ext = FieldElement; + +// ============================================================================= +// The hostile constraint body: byte-for-byte `LogReadOnlyRAPConstraints` with +// the multiplicity column re-addressed from main[4] to aux[0] and the LogUp +// accumulator from aux[0] to aux[1]. Same values, same degrees, same meta. +// ============================================================================= + +pub struct SplitLogUpConstraints; + +impl ConstraintSet for SplitLogUpConstraints { + fn eval>(&self, b: &mut B) { + let a_sorted_0 = b.main(0, 2); + let a_sorted_1 = b.main(1, 2); + let v_sorted_0 = b.main(0, 3); + let v_sorted_1 = b.main(1, 3); + let one = b.one(); + let addr_diff = a_sorted_1 - a_sorted_0; + + b.emit_base_rows( + 0, + RowDomain::except_last(1), + addr_diff.clone() * (addr_diff.clone() - one.clone()), + ); + b.emit_base_rows( + 1, + RowDomain::except_last(1), + (v_sorted_1 - v_sorted_0) * (addr_diff - one), + ); + + // ---- the only difference: s is aux[1], m is aux[0] (was main[4]) ---- + let s0 = b.aux(0, 1); + let s1 = b.aux(1, 1); + let z = b.challenge(0); + let alpha = b.challenge(1); + let a1 = b.main(1, 0); + let v1 = b.main(1, 1); + let a_sorted_1 = b.main(1, 2); + let v_sorted_1 = b.main(1, 3); + let m = b.aux(1, 0); + let unsorted_term = -(a1 + v1 * alpha.clone()) + z.clone(); + let sorted_term = -(a_sorted_1 + v_sorted_1 * alpha) + z; + b.emit_ext_rows( + 2, + RowDomain::except_last(1), + s0 * unsorted_term.clone() * sorted_term.clone() + m * unsorted_term.clone() + - sorted_term.clone() + - s1 * unsorted_term * sorted_term, + ); + } +} + +/// How the attacker fills the moved multiplicity column. +#[derive(Clone)] +pub enum MPlan { + /// Honest multiplicities, merely committed in the wrong tree. + Honest(Vec), + /// Honest multiplicities except index `idx`, which is SOLVED after `z`, + /// `alpha` are known so the LogUp accumulator still lands on zero. + Forge { base: Vec, idx: usize }, +} + +pub struct SplitLogUpAIR { + context: AirContext, + meta: Vec, + plan: MPlan, + /// Records the challenge-dependent multiplicity the attack solved for. + pub forged_value: std::sync::Mutex>, + /// Records the committed multiplicity column and the (z, alpha) it was + /// solved against, so a test can replay the LogUp identity off-protocol. + pub committed_m: std::sync::Mutex, Ext, Ext)>>, + phantom: PhantomData<(F, E)>, +} + +impl SplitLogUpAIR { + pub fn with_plan(proof_options: &ProofOptions, plan: MPlan) -> Self { + let mut air = ::new(proof_options); + air.plan = plan; + air + } +} + +impl AIR for SplitLogUpAIR { + type Field = F; + type FieldExtension = E; + type PublicInputs = LogReadOnlyPublicInputs; + + fn step_size(&self) -> usize { + 1 + } + + fn new(proof_options: &ProofOptions) -> Self { + let meta = ConstraintSet::::meta(&SplitLogUpConstraints); + let context = AirContext { + proof_options: proof_options.clone(), + trace_columns: 6, + transition_offsets: vec![0, 1], + num_transition_constraints: meta.len(), + }; + Self { + context, + meta, + plan: MPlan::Honest(Vec::new()), + forged_value: std::sync::Mutex::new(None), + committed_m: std::sync::Mutex::new(None), + phantom: PhantomData, + } + } + + /// Runs AFTER the main root is absorbed and AFTER `z`, `alpha` are sampled. + /// Fills aux[0] = m (the moved main column) and aux[1] = s. + fn build_auxiliary_trace( + &self, + trace: &mut TraceTable, + challenges: &[Ext], + ) -> Option> { + let cols = trace.columns_main(); + let (a, v, a_sorted, v_sorted) = (&cols[0], &cols[1], &cols[2], &cols[3]); + let z = &challenges[0]; + let alpha = &challenges[1]; + let n = trace.num_rows(); + + // u_i = 1/(z - (a_i + alpha*v_i)) ; t_i = 1/(z - (a'_i + alpha*v'_i)) + let u: Vec = (0..n) + .map(|i| (-(&a[i] + &v[i] * alpha) + z).inv().unwrap()) + .collect(); + let t: Vec = (0..n) + .map(|i| (-(&a_sorted[i] + &v_sorted[i] * alpha) + z).inv().unwrap()) + .collect(); + + let m: Vec = match &self.plan { + MPlan::Honest(base) => base.iter().map(|x| x.to_extension()).collect(), + MPlan::Forge { base, idx } => { + let mut m: Vec = base.iter().map(|x| x.to_extension()).collect(); + // Solve sum_i m_i t_i = sum_i u_i for m_idx. + let mut rhs = u.iter().fold(Ext::zero(), |acc, x| acc + x); + for i in 0..n { + if i != *idx { + rhs = rhs - &m[i] * &t[i]; + } + } + let solved = rhs * t[*idx].inv().unwrap(); + *self.forged_value.lock().unwrap() = Some(solved); + m[*idx] = solved; + m + } + }; + + *self.committed_m.lock().unwrap() = Some((m.clone(), *z, *alpha)); + + let mut s = Vec::with_capacity(n); + s.push(&m[0] * &t[0] - &u[0]); + for i in 0..n - 1 { + let next = &s[i] + &m[i + 1] * &t[i + 1] - &u[i + 1]; + s.push(next); + } + + for i in 0..n { + trace.set_aux(i, 0, m[i]); + trace.set_aux(i, 1, s[i]); + } + None + } + + /// The lie: 4 main columns, 2 aux columns (honest AIR says 5 and 1). + fn trace_layout(&self) -> (usize, usize) { + (4, 2) + } + + fn boundary_constraints( + &self, + pub_inputs: &Self::PublicInputs, + rap_challenges: &[Ext], + _bus_public_inputs: Option<&crate::lookup::BusPublicInputs>, + trace_length: usize, + ) -> BoundaryConstraints { + let a0 = &pub_inputs.a0; + let v0 = &pub_inputs.v0; + let a_sorted_0 = &pub_inputs.a_sorted_0; + let v_sorted_0 = &pub_inputs.v_sorted_0; + let m0 = &pub_inputs.m0; + let z = &rap_challenges[0]; + let alpha = &rap_challenges[1]; + + let c1 = BoundaryConstraint::new_main(0, 0, a0.to_extension()); + let c2 = BoundaryConstraint::new_main(1, 0, v0.to_extension()); + let c3 = BoundaryConstraint::new_main(2, 0, a_sorted_0.to_extension()); + let c4 = BoundaryConstraint::new_main(3, 0, v_sorted_0.to_extension()); + // main[4] under the honest layout -> aux[0] here. Same GLOBAL index 4, + // which is all the verifier's `main_trace_width + col` mapping sees. + let c5 = BoundaryConstraint::new_aux(0, 0, m0.to_extension()); + + let unsorted_term = (-(a0 + v0 * alpha) + z).inv().unwrap(); + let sorted_term = (-(a_sorted_0 + v_sorted_0 * alpha) + z).inv().unwrap(); + let p0_value = m0 * sorted_term - unsorted_term; + + let c_aux1 = BoundaryConstraint::new_aux(1, 0, p0_value); + let c_aux2 = BoundaryConstraint::new_aux(1, trace_length - 1, Ext::zero()); + + BoundaryConstraints::from_constraints(vec![c1, c2, c3, c4, c5, c_aux1, c_aux2]) + } + + fn constraints_meta(&self) -> &[ConstraintMeta] { + &self.meta + } + + fn compute_transition_prover( + &self, + evaluation_context: &TransitionEvaluationContext, + base_evals: &mut [Felt], + ext_evals: &mut [Ext], + ) { + run_transition_prover( + &SplitLogUpConstraints, + evaluation_context, + base_evals, + ext_evals, + ); + } + + fn compute_transition( + &self, + evaluation_context: &TransitionEvaluationContext, + ) -> Vec { + run_transition_verifier( + &SplitLogUpConstraints, + evaluation_context, + self.num_base_transition_constraints(), + self.num_transition_constraints(), + ) + } + + fn num_base_transition_constraints(&self) -> usize { + num_base_from_meta(&ConstraintSet::::meta(&SplitLogUpConstraints)) + } + + fn context(&self) -> &AirContext { + &self.context + } + + fn composition_poly_degree_bound(&self, trace_length: usize) -> usize { + trace_length * 2 + } +} + +// ============================================================================= +// Fixtures +// ============================================================================= + +/// The exact data of the in-repo happy-path test +/// (`air_tests.rs::test_prove_read_only_memory_logup`): a continuous read-only +/// memory over addresses 1..=5. +fn honest_reads() -> (Vec, Vec) { + ( + vec![3, 2, 2, 3, 4, 5, 1, 3] + .into_iter() + .map(Felt::from) + .collect(), + vec![30, 20, 20, 30, 40, 50, 10, 30] + .into_iter() + .map(Felt::from) + .collect(), + ) +} + +fn public_inputs() -> LogReadOnlyPublicInputs { + LogReadOnlyPublicInputs { + a0: Felt::from(3), + v0: Felt::from(30), + a_sorted_0: Felt::from(1), + v_sorted_0: Felt::from(10), + m0: Felt::from(1), + } +} + +/// Split an honest 5-main-column LogUp trace into the attacker's shape: +/// 4 main columns + 2 (zeroed) aux columns. Returns the m column separately. +fn split_trace(addresses: Vec, values: Vec) -> (TraceTable, Vec) { + let honest: TraceTable = read_only_logup_trace(addresses, values); + let cols = honest.columns_main(); + let n = cols[0].len(); + let m = cols[4].clone(); + let main = vec![ + cols[0].clone(), + cols[1].clone(), + cols[2].clone(), + cols[3].clone(), + ]; + let aux = vec![vec![Ext::zero(); n], vec![Ext::zero(); n]]; + (TraceTable::from_columns(main, aux, 1), m) +} + +fn opts() -> ProofOptions { + ProofOptions::default_test_options() +} + +fn honest_air() -> LogReadOnlyRAP { + LogReadOnlyRAP::::new(&opts()) +} + +fn tr() -> DefaultTranscript { + DefaultTranscript::::new(&[]) +} + +// ============================================================================= +// The two AIRs are indistinguishable to the verifier except for the split, so +// nothing but an explicit width pin can tell them apart. +// ============================================================================= + +#[test_log::test] +fn split_declaration_differs_from_the_honest_air_only_in_the_layout() { + let h = honest_air(); + let a = SplitLogUpAIR::with_plan(&opts(), MPlan::Honest(Vec::new())); + assert_eq!( + format!("{:?}", h.constraints_meta()), + format!("{:?}", a.constraints_meta()), + "meta must match" + ); + assert_eq!(h.context().trace_columns, a.context().trace_columns); + assert_eq!( + h.context().transition_offsets, + a.context().transition_offsets + ); + assert_eq!( + h.num_transition_constraints(), + a.num_transition_constraints() + ); + assert_eq!( + h.num_base_transition_constraints(), + a.num_base_transition_constraints() + ); + assert_eq!( + h.trace_ood_next_row_columns(), + a.trace_ood_next_row_columns() + ); + assert_eq!( + h.composition_poly_degree_bound(8), + a.composition_poly_degree_bound(8) + ); + assert_eq!(h.has_aux_trace(), a.has_aux_trace()); + assert_eq!(h.has_trace_interaction(), a.has_trace_interaction()); + // The ONLY divergence: + assert_eq!(h.trace_layout(), (5, 1)); + assert_eq!(a.trace_layout(), (4, 2)); + assert_eq!(h.num_auxiliary_rap_columns(), 1); + assert_eq!(a.num_auxiliary_rap_columns(), 2); + println!("AUXSPLIT/0 honest layout (5,1) attacker layout (4,2) — everything else identical"); +} + +// ============================================================================= +// The structural case: a proof whose aux opening is 2 columns wide, verified +// against an AIR that declares exactly 1. Accepted on stock `main`, and it needs +// no forgery at all — the trace here is honest. +// ============================================================================= + +#[test_log::test] +fn mis_split_aux_opening_is_rejected() { + let (addr, val) = honest_reads(); + let (mut trace, m) = split_trace(addr, val); + let pi = public_inputs(); + let attack_air = SplitLogUpAIR::with_plan(&opts(), MPlan::Honest(m)); + + let proof = Prover::prove(&attack_air, &mut trace, &pi, &mut tr()).expect("prove"); + + let aux_w = proof.deep_poly_openings[0] + .aux_trace_polys + .as_ref() + .unwrap() + .evaluations + .len(); + let main_w = proof.deep_poly_openings[0] + .main_trace_polys + .evaluations + .len(); + let h = honest_air(); + println!( + "AUXSPLIT/1 opening widths: main={main_w} aux={aux_w} AIR declares main={} aux={}", + h.trace_layout().0, + h.num_auxiliary_rap_columns() + ); + assert_eq!(main_w, 4); + assert_eq!(aux_w, 2); + assert_ne!(aux_w, h.num_auxiliary_rap_columns()); + + let accepted = Verifier::verify(&proof, &h, &mut tr()); + println!("AUXSPLIT/1 STOCK VERIFIER ACCEPTED MIS-SPLIT PROOF = {accepted}"); + assert!( + !accepted, + "the verifier must reject an aux opening wider than the AIR declares", + ); + + // Attribution: the rejection is the width pin's, not an incidental failure + // elsewhere in verification. A "rejected" verdict is only evidence if it + // comes from the guard under test. + assert!( + !Verifier::trace_opening_widths_well_formed( + &h, + StarkProofView::Owned(&proof), + h.options().fri_number_of_queries, + ), + "the rejection above must come from the opening-width guard", + ); +} + +// ============================================================================= +// CONTROL — the harness discriminates: corrupting one value in the (wrongly +// wide) aux opening must be rejected. Passes on stock `main` too. +// ============================================================================= + +#[test_log::test] +fn corrupted_aux_opening_is_rejected() { + let (addr, val) = honest_reads(); + let (mut trace, m) = split_trace(addr, val); + let pi = public_inputs(); + let attack_air = SplitLogUpAIR::with_plan(&opts(), MPlan::Honest(m)); + let proof = Prover::prove(&attack_air, &mut trace, &pi, &mut tr()).expect("prove"); + + let mut corrupted = proof.clone(); + corrupted.deep_poly_openings[0] + .aux_trace_polys + .as_mut() + .unwrap() + .evaluations[0] += Ext::one(); + let accepted = Verifier::verify(&corrupted, &honest_air(), &mut tr()); + println!("AUXSPLIT/CONTROL-A corrupted aux opening accepted = {accepted}"); + assert!(!accepted, "harness must discriminate"); +} + +// ============================================================================= +// The break: a FALSE statement, accepted on stock `main`. +// +// The read column contains address 3 -> 30 (rows 0, 3) AND address 3 -> 999999 +// (row 7). No single-valued read-only memory can serve both, so the LogUp +// multiset equality that this AIR exists to enforce is FALSE. With `m` moved +// into the aux tree the prover solves for m[1] AFTER seeing z, alpha, and the +// stock verifier accepts. +// ============================================================================= + +const BOGUS: u64 = 999999; + +#[test_log::test] +fn false_memory_read_under_aux_split_is_rejected() { + let (addr, mut val) = honest_reads(); + // Honest sorted memory table, built from the HONEST reads. + let (_, honest_m) = split_trace(addr.clone(), val.clone()); + let honest_trace: TraceTable = read_only_logup_trace(addr.clone(), val.clone()); + let sorted_a = honest_trace.columns_main()[2].clone(); + let sorted_v = honest_trace.columns_main()[3].clone(); + + // The lie: read #7 (address 3) now claims value 999999. + val[7] = Felt::from(BOGUS); + + // Sanity: the read multiset is now impossible for a single-valued memory. + let mut same_addr_values: Vec = Vec::new(); + for i in 0..addr.len() { + if addr[i] == Felt::from(3) && !same_addr_values.contains(&val[i]) { + same_addr_values.push(val[i]); + } + } + println!( + "AUXSPLIT/2 reads at address 3 claim {} distinct values: {same_addr_values:?}", + same_addr_values.len() + ); + assert!( + same_addr_values.len() > 1, + "the statement must be false: address 3 must carry two different values" + ); + + let n = addr.len(); + let main = vec![addr.clone(), val.clone(), sorted_a, sorted_v]; + let aux = vec![vec![Ext::zero(); n], vec![Ext::zero(); n]]; + let mut trace = TraceTable::::from_columns(main, aux, 1); + + let pi = public_inputs(); + let attack_air = SplitLogUpAIR::with_plan( + &opts(), + MPlan::Forge { + base: honest_m, + idx: 1, + }, + ); + let proof = Prover::prove(&attack_air, &mut trace, &pi, &mut tr()).expect("prove"); + + let forged = attack_air.forged_value.lock().unwrap().unwrap(); + println!("AUXSPLIT/2 solved multiplicity m[1] (challenge-dependent) = {forged:?}"); + + let accepted = Verifier::verify(&proof, &honest_air(), &mut tr()); + println!("AUXSPLIT/2 FALSE STATEMENT ACCEPTED BY STOCK VERIFIER = {accepted}"); + assert!( + !accepted, + "the verifier must reject a false statement carried by an aux mis-split", + ); + + // Attribution: the rejection is the width pin's, not an incidental failure + // elsewhere in verification. A "rejected" verdict is only evidence if it + // comes from the guard under test. + assert!( + !Verifier::trace_opening_widths_well_formed( + &honest_air(), + StarkProofView::Owned(&proof), + honest_air().options().fri_number_of_queries, + ), + "the rejection above must come from the opening-width guard", + ); + + // -------- the same forgery over the WIRE: rkyv-serialize and verify + // through `multi_verify_archived`, the read-in-place path the recursion + // guest uses. Proves this is a transmissible proof, not an in-process + // artefact, and that the archived path shares the hole. ----------------- + let multi = crate::proof::stark::MultiProof { + proofs: vec![proof.clone()], + }; + let bytes = rkyv::to_bytes::(&multi).unwrap(); + println!("AUXSPLIT/2 serialized forged proof: {} bytes", bytes.len()); + let archived = rkyv::access::< + crate::proof::stark::ArchivedMultiProof>, + rkyv::rancor::Error, + >(&bytes) + .unwrap(); + let h = honest_air(); + let airs: Vec< + &dyn AIR>, + > = vec![&h]; + let accepted_archived = + Verifier::multi_verify_archived(&airs, archived, &mut tr(), &Ext::zero()); + println!("AUXSPLIT/2 ARCHIVED (wire) PATH ACCEPTED = {accepted_archived}"); + assert!( + !accepted_archived, + "the archived (recursion-guest) path must reject it too", + ); + + // -------- diagnostic: the accepted LogUp identity is NOT a multiset + // equality, it holds only at the protocol's own (z, alpha). ------------- + let (m_committed, z, alpha) = attack_air.committed_m.lock().unwrap().clone().unwrap(); + let cols = trace.columns_main(); + let logup_residual = |z: &Ext, alpha: &Ext| -> Ext { + let mut acc = Ext::zero(); + for i in 0..n { + let u = (-(&cols[0][i] + &cols[1][i] * alpha) + z).inv().unwrap(); + let t = (-(&cols[2][i] + &cols[3][i] * alpha) + z).inv().unwrap(); + acc = acc + &m_committed[i] * t - u; + } + acc + }; + let at_protocol = logup_residual(&z, &alpha); + let z2 = z + Ext::from(7u64); + let a2 = alpha + Ext::from(11u64); + let at_fresh = logup_residual(&z2, &a2); + println!("AUXSPLIT/2 LogUp residual at the protocol's (z,alpha) = {at_protocol:?}"); + println!("AUXSPLIT/2 LogUp residual at a FRESH (z',alpha') = {at_fresh:?}"); + assert_eq!( + at_protocol, + Ext::zero(), + "the attack balances the bus at the sampled challenges" + ); + assert_ne!( + at_fresh, + Ext::zero(), + "…but not as a rational identity: the two multisets genuinely differ" + ); +} + +// ============================================================================= +// CONTROL — the SAME false trace, proven WITHOUT the split (honest layout, +// honest multiplicities in the main tree). `m` is then bound before z/alpha and +// the bus cannot be made to balance: the proof must be rejected (or the prover +// must refuse). Shows the acceptance above comes from the split, not from a hole +// in the AIR. Passes on stock `main` too. +// ============================================================================= + +#[test_log::test] +fn same_false_read_without_the_split_is_rejected() { + let (addr, mut val) = honest_reads(); + let honest_trace: TraceTable = read_only_logup_trace(addr.clone(), val.clone()); + let sorted_a = honest_trace.columns_main()[2].clone(); + let sorted_v = honest_trace.columns_main()[3].clone(); + let m = honest_trace.columns_main()[4].clone(); + val[7] = Felt::from(BOGUS); + + let n = addr.len(); + let main = vec![addr, val, sorted_a, sorted_v, m]; + let aux = vec![vec![Ext::zero(); n]]; + let mut trace = TraceTable::::from_columns(main, aux, 1); + let pi = public_inputs(); + let h = honest_air(); + + match Prover::prove(&h, &mut trace, &pi, &mut tr()) { + Ok(proof) => { + let accepted = Verifier::verify(&proof, &h, &mut tr()); + println!("AUXSPLIT/CONTROL-B no-split false trace accepted = {accepted}"); + assert!(!accepted, "control must be rejected"); + } + Err(e) => println!("AUXSPLIT/CONTROL-B no-split prover refused: {e:?}"), + } +} + +// ============================================================================= +// CONTROL — the split path is not a free pass: the SAME split declaration with +// HONEST multiplicities over the FALSE read column must be rejected. Only the +// challenge-dependent solve makes the forgery go through. Passes on stock `main` +// too. +// ============================================================================= + +#[test_log::test] +fn aux_split_without_the_challenge_solve_is_rejected() { + let (addr, mut val) = honest_reads(); + let honest_trace: TraceTable = read_only_logup_trace(addr.clone(), val.clone()); + let sorted_a = honest_trace.columns_main()[2].clone(); + let sorted_v = honest_trace.columns_main()[3].clone(); + let m = honest_trace.columns_main()[4].clone(); + val[7] = Felt::from(BOGUS); + + let n = addr.len(); + let main = vec![addr, val, sorted_a, sorted_v]; + let aux = vec![vec![Ext::zero(); n], vec![Ext::zero(); n]]; + let mut trace = TraceTable::::from_columns(main, aux, 1); + let pi = public_inputs(); + let attack_air = SplitLogUpAIR::with_plan(&opts(), MPlan::Honest(m)); + + match Prover::prove(&attack_air, &mut trace, &pi, &mut tr()) { + Ok(proof) => { + let accepted = Verifier::verify(&proof, &honest_air(), &mut tr()); + println!("AUXSPLIT/CONTROL-C split + honest m over false reads accepted = {accepted}"); + assert!(!accepted, "control must be rejected"); + } + Err(e) => println!("AUXSPLIT/CONTROL-C prover refused: {e:?}"), + } +} + +// ============================================================================= +// NON-VACUITY — the honest `LogReadOnlyRAP` (layout (5, 1), aux width 1) must +// still verify. A pin that rejected every aux opening would satisfy every +// rejection test above. +// ============================================================================= + +#[test_log::test] +fn honest_logup_rap_proof_still_verifies() { + let (addr, val) = honest_reads(); + let mut trace: TraceTable = read_only_logup_trace(addr, val); + let air = honest_air(); + let proof = Prover::prove(&air, &mut trace, &public_inputs(), &mut tr()).expect("prove"); + + assert!( + Verifier::verify(&proof, &air, &mut tr()), + "an honest LogUp proof must verify", + ); +} diff --git a/crypto/stark/src/tests/mod.rs b/crypto/stark/src/tests/mod.rs index 15b64d45a..468a4cd3c 100644 --- a/crypto/stark/src/tests/mod.rs +++ b/crypto/stark/src/tests/mod.rs @@ -1,4 +1,5 @@ pub mod air_tests; +pub mod aux_opening_width_tests; #[cfg(feature = "debug-checks")] pub mod bus_debug_tests; pub mod bus_tests; @@ -6,6 +7,7 @@ pub mod commitment_tests; pub mod domain_cache_stats; pub mod fri_tests; pub mod grinding_tests; +pub mod opening_width_tests; pub mod proof_options_tests; pub mod prove_verify_roundtrip_tests; pub mod prover_tests; diff --git a/crypto/stark/src/tests/opening_width_tests.rs b/crypto/stark/src/tests/opening_width_tests.rs new file mode 100644 index 000000000..db5764220 --- /dev/null +++ b/crypto/stark/src/tests/opening_width_tests.rs @@ -0,0 +1,532 @@ +//! Negative tests for the trace-opening column split +//! (`verifier::trace_opening_widths_well_formed`). +//! +//! A query opening carries the trace row as three prover-supplied vectors — +//! `precomputed ‖ main` (base field) and `aux` (extension field) — which the +//! DEEP reconstruction consumes as one concatenated row. Only their *sum* used +//! to be pinned (against the AIR-pinned OOD width), and the Merkle leaf hash +//! pins neither split: `hash_data_from_slices` streams `evaluations ‖ +//! evaluations_sym` with no length prefix and no separator. +//! +//! That mattered because the three trees are transcript-bound at different +//! times. This file covers the **precomputed↔main** term; the main↔aux term — +//! the LogUp break, and the instance with an executed false statement — lives in +//! `tests::aux_opening_width_tests`. +//! +//! Two layers, both free of any prover modification: +//! +//! * `precomputed_opening_narrower_than_the_air_declares_is_rejected` — end to +//! end through `Verifier::verify`, accepted on stock `main`. The prover and +//! the verifier's AIR disagree about how many columns the precomputed +//! commitment pins, while both absorb the same constant, so the transcripts +//! agree and the honest in-repo prover builds the proof. +//! * `opening_widths_*` — the guard called directly on surgically re-split +//! openings. These reach what no end-to-end test can: the `evaluations_sym` +//! slot (a separate prover-supplied vector the leaf hash does not pin apart +//! from `evaluations`) and the "a non-preprocessed AIR must declare zero +//! precomputed columns" direction, whose end-to-end form is masked by +//! transcript divergence and so proves nothing on its own. + +use std::marker::PhantomData; + +use crate::config::Commitment; +use crate::constraints::{ + boundary::{BoundaryConstraint, BoundaryConstraints}, + builder::{ + ConstraintMeta, ConstraintSet, num_base_from_meta, run_transition_prover, + run_transition_verifier, + }, +}; +use crate::context::AirContext; +use crate::examples::fibonacci_2_columns::{Fibonacci2ColsConstraints, compute_trace}; +use crate::examples::fibonacci_rap::{FibonacciRAP, FibonacciRAPPublicInputs, fibonacci_rap_trace}; +use crate::examples::simple_fibonacci::FibonacciPublicInputs; +use crate::proof::options::ProofOptions; +use crate::proof::stark::StarkProof; +use crate::proof::view::StarkProofView; +use crate::prover::{IsStarkProver, Prover}; +use crate::traits::{AIR, TransitionEvaluationContext}; +use crate::verifier::{IsStarkVerifier, Verifier}; +use crypto::fiat_shamir::default_transcript::DefaultTranscript; +use math::field::element::FieldElement; +use math::field::goldilocks::GoldilocksField; +use math::field::traits::IsFFTField; + +type F = GoldilocksField; +type Felt = FieldElement; + +const TRACE_LEN: usize = 16; + +/// `Fibonacci2ColsAIR` with two declaration knobs: +/// +/// * `precomputed_columns` — how many leading columns the AIR claims live in the +/// precomputed tree (0 = not preprocessed). Prover and verifier are handed +/// instances that disagree about this, which is the whole point. +/// * `out`, when set, adds a public-output boundary on the last row of column 1. +/// Since `(a0, a1)` determine the whole trace, a wrong `out` would make the +/// claimed statement FALSE. +pub struct FibonacciSplitAIR { + context: AirContext, + meta: Vec, + out: Option>, + precomputed_columns: usize, + precomputed_commitment: Commitment, + phantom: PhantomData, +} + +impl FibonacciSplitAIR { + /// The AIR as the verifier sees it: plain, non-preprocessed. + fn honest(proof_options: &ProofOptions, out: Option>) -> Self { + let mut air = ::new(proof_options); + air.out = out; + air + } + + /// The AIR the hostile prover proves against: same width, same constraints, + /// same boundary constraints — only the precomputed declaration differs. + fn split( + proof_options: &ProofOptions, + out: Option>, + commitment: Commitment, + ) -> Self { + Self::preprocessed_declaring(proof_options, out, 1, commitment) + } + + /// A preprocessed declaration with an explicit precomputed-column count. + /// Handing the verifier a different count than the prover used is how the + /// hook-free test below reaches the precomputed term of the guard: both + /// sides still absorb the same commitment, so the transcripts agree. + fn preprocessed_declaring( + proof_options: &ProofOptions, + out: Option>, + precomputed_columns: usize, + commitment: Commitment, + ) -> Self { + let mut air = Self::honest(proof_options, out); + air.precomputed_columns = precomputed_columns; + air.precomputed_commitment = commitment; + air + } +} + +impl AIR for FibonacciSplitAIR +where + F: IsFFTField + Send + Sync + 'static, +{ + type Field = F; + type FieldExtension = F; + type PublicInputs = FibonacciPublicInputs; + + fn step_size(&self) -> usize { + 1 + } + + fn new(proof_options: &ProofOptions) -> Self { + let meta = Fibonacci2ColsConstraints::::default().meta(); + let context = AirContext { + proof_options: proof_options.clone(), + transition_offsets: vec![0, 1], + num_transition_constraints: meta.len(), + trace_columns: 2, + }; + Self { + context, + meta, + out: None, + precomputed_columns: 0, + precomputed_commitment: [0u8; 32], + phantom: PhantomData, + } + } + + fn boundary_constraints( + &self, + pub_inputs: &Self::PublicInputs, + _rap_challenges: &[FieldElement], + _bus_public_inputs: Option<&crate::lookup::BusPublicInputs>, + _trace_length: usize, + ) -> BoundaryConstraints { + let mut constraints = vec![ + BoundaryConstraint::new_main(0, 0, pub_inputs.a0.clone()), + BoundaryConstraint::new_main(1, 0, pub_inputs.a1.clone()), + ]; + if let Some(out) = &self.out { + constraints.push(BoundaryConstraint::new_main(1, TRACE_LEN - 1, out.clone())); + } + BoundaryConstraints::from_constraints(constraints) + } + + fn constraints_meta(&self) -> &[ConstraintMeta] { + &self.meta + } + + fn compute_transition_prover( + &self, + evaluation_context: &TransitionEvaluationContext, + base_evals: &mut [FieldElement], + ext_evals: &mut [FieldElement], + ) { + run_transition_prover( + &Fibonacci2ColsConstraints::default(), + evaluation_context, + base_evals, + ext_evals, + ); + } + + fn compute_transition( + &self, + evaluation_context: &TransitionEvaluationContext, + ) -> Vec> { + run_transition_verifier( + &Fibonacci2ColsConstraints::default(), + evaluation_context, + self.num_base_transition_constraints(), + self.num_transition_constraints(), + ) + } + + fn num_base_transition_constraints(&self) -> usize { + num_base_from_meta(&Fibonacci2ColsConstraints::::default().meta()) + } + + fn context(&self) -> &AirContext { + &self.context + } + + fn composition_poly_degree_bound(&self, trace_length: usize) -> usize { + trace_length + } + + fn trace_layout(&self) -> (usize, usize) { + (2, 0) + } + + fn is_preprocessed(&self) -> bool { + self.precomputed_columns > 0 + } + + fn num_precomputed_columns(&self) -> usize { + self.precomputed_columns + } + + fn precomputed_commitment(&self) -> Commitment { + self.precomputed_commitment + } +} + +fn pub_inputs() -> FibonacciPublicInputs { + FibonacciPublicInputs { + a0: Felt::one(), + a1: Felt::one(), + } +} + +/// Tripwire. Every break test in this file and in +/// `tests::aux_opening_width_tests` asserts a *rejection*, and a rejection is +/// only evidence if it comes from the width pin — a verifier that rejected +/// everything, or that rejected these proofs for some incidental reason, would +/// satisfy them just as well. A sibling PoC was once misread exactly that way, +/// off a worktree whose verifier was not the one being claimed about. +/// +/// So: the guard must be *defined and called*, not merely present. Deleting the +/// call site while keeping the function — the plausible bad refactor — fails +/// here rather than silently turning the whole file green for the wrong reason. +/// The break tests additionally assert attribution behaviourally, by calling the +/// guard on the very proof they reject. +/// +/// (The prosecution PoC pinned a hash of the whole verifier source. That is +/// right for a throwaway branch and wrong in-repo, where it would break on every +/// unrelated verifier edit.) +#[test_log::test] +fn precheck_the_width_pin_is_compiled_in() { + let src = include_str!("../verifier.rs"); + assert!( + src.contains("fn trace_opening_widths_well_formed("), + "the opening-width guard is gone from the verifier compiled into this binary", + ); + assert!( + src.contains("Self::trace_opening_widths_well_formed("), + "the opening-width guard is defined but never called: every rejection \ + asserted in this file would then be proving something else", + ); +} + +/// The precomputed term, end to end and **hook-free**: the prover commits ONE +/// column in the precomputed tree; the verifier's AIR declares TWO. Both sides +/// absorb the same commitment (the AIR's constant is the tree the prover built), +/// so the transcripts agree and the honest in-repo prover produces the proof — +/// no attacker-side prover switch involved. +/// +/// Stock `main` accepts it: the widths sum to the OOD width and the DEEP +/// reconstruction reads the same concatenated row either way. What the verifier +/// is wrong about is *which* columns the hardcoded commitment pins — it believes +/// two, and only one is in that tree, so the other is prover-supplied while the +/// verifier treats it as fixed. +/// +/// For a *real* preprocessed table (bitwise, decode, keccak_rc) the round-1 root +/// equality would also catch this, since an honest constant is a root over +/// exactly `num_precomputed_columns()` columns and a narrower tree hashes +/// differently. That defence is incidental: nothing states the invariant and +/// nothing checks it, and it does not exist at all for a non-preprocessed AIR, +/// where the root is never absorbed and the same re-split lets a prover choose +/// trace columns after the round-2 challenge. This test pins the width itself, +/// which is the property the reconstruction actually depends on. +#[test_log::test] +fn precomputed_opening_narrower_than_the_air_declares_is_rejected() { + let proof_options = ProofOptions::default_test_options(); + let mut trace = compute_trace([Felt::one(), Felt::one()], TRACE_LEN); + let reference = FibonacciSplitAIR::::honest(&proof_options, None); + let commitment = Prover::compute_precomputed_commitment_for_testing(&trace, &reference, 1) + .expect("precomputed commitment"); + + // Prover: one precomputed column, one main column. + let prover_air = + FibonacciSplitAIR::::preprocessed_declaring(&proof_options, None, 1, commitment); + let proof = Prover::prove( + &prover_air, + &mut trace, + &pub_inputs(), + &mut DefaultTranscript::::new(&[]), + ) + .expect("prove"); + assert_eq!( + proof.deep_poly_openings[0] + .precomputed_trace_polys + .as_ref() + .expect("preprocessed proof opens a precomputed tree") + .evaluations + .len(), + 1, + "test precondition: the proof serves one precomputed column", + ); + + // Verifier: same commitment constant, but the AIR declares two precomputed + // columns — so the second is served from the main tree, not the pinned one. + let verifier_air = + FibonacciSplitAIR::::preprocessed_declaring(&proof_options, None, 2, commitment); + assert!( + !Verifier::verify(&proof, &verifier_air, &mut DefaultTranscript::::new(&[])), + "Verifier must reject a precomputed opening narrower than the AIR declares", + ); + // Attribution: the rejection is the width pin's, not an incidental failure + // elsewhere in verification. + assert!( + !Verifier::trace_opening_widths_well_formed( + &verifier_air, + StarkProofView::Owned(&proof), + verifier_air.options().fri_number_of_queries, + ), + "the rejection above must come from the opening-width guard", + ); +} + +/// Non-vacuity, and the completeness case that matters: a table that genuinely +/// IS preprocessed has `num_precomputed_columns() > 0`, and its proof — with the +/// honest prover, verified against the same preprocessed AIR — must still be +/// accepted. A guard that rejected every split would pass every test above. +#[test_log::test] +fn honest_preprocessed_proof_still_verifies() { + let proof_options = ProofOptions::default_test_options(); + let mut trace = compute_trace([Felt::one(), Felt::one()], TRACE_LEN); + let reference = FibonacciSplitAIR::::honest(&proof_options, None); + let commitment = Prover::compute_precomputed_commitment_for_testing(&trace, &reference, 1) + .expect("precomputed commitment"); + let split_air = FibonacciSplitAIR::::split(&proof_options, None, commitment); + + let proof = Prover::prove( + &split_air, + &mut trace, + &pub_inputs(), + &mut DefaultTranscript::::new(&[]), + ) + .expect("prove"); + + assert!( + Verifier::verify(&proof, &split_air, &mut DefaultTranscript::::new(&[])), + "a genuinely preprocessed table must still verify", + ); +} + +/// Non-vacuity for the plain path: the same AIR without any split declaration. +#[test_log::test] +fn honest_non_preprocessed_proof_still_verifies() { + let proof_options = ProofOptions::default_test_options(); + let mut trace = compute_trace([Felt::one(), Felt::one()], TRACE_LEN); + let out = trace.columns_main()[1][TRACE_LEN - 1]; + let air = FibonacciSplitAIR::::honest(&proof_options, Some(out)); + + let proof = Prover::prove( + &air, + &mut trace, + &pub_inputs(), + &mut DefaultTranscript::::new(&[]), + ) + .expect("prove"); + + assert!( + Verifier::verify(&proof, &air, &mut DefaultTranscript::::new(&[])), + "an honest proof of a true statement must verify", + ); +} + +// --------------------------------------------------------------------------- +// Direct tests of the guard, on a RAP proof (2 main + 1 aux columns). +// +// These reach the cases no end-to-end test can: the `evaluations_sym` slot is a +// separate prover-supplied vector that the leaf hash does not pin apart from +// `evaluations` (`hash_data_from_slices` concatenates them), and the aux width +// has its own transcript-timing problem (the aux root is absorbed only after +// the shared LogUp challenges). +// --------------------------------------------------------------------------- + +type RapProof = StarkProof>; + +fn make_valid_rap_proof() -> (FibonacciRAP, RapProof) { + let mut trace = fibonacci_rap_trace([Felt::one(), Felt::one()], TRACE_LEN); + let proof_options = ProofOptions::default_test_options(); + let pub_inputs = FibonacciRAPPublicInputs { + steps: TRACE_LEN, + a0: Felt::one(), + a1: Felt::one(), + }; + let air = FibonacciRAP::::new(&proof_options); + let proof = Prover::prove( + &air, + &mut trace, + &pub_inputs, + &mut DefaultTranscript::::new(&[]), + ) + .expect("prove"); + (air, proof) +} + +fn widths_well_formed(air: &FibonacciRAP, proof: &RapProof) -> bool { + Verifier::trace_opening_widths_well_formed( + air, + StarkProofView::Owned(proof), + air.options().fri_number_of_queries, + ) +} + +/// Baseline: the honest proof's split is the AIR's split. +#[test_log::test] +fn opening_widths_accept_an_honest_rap_proof() { + let (air, proof) = make_valid_rap_proof(); + assert_eq!(air.trace_layout(), (2, 1)); + assert!(!air.is_preprocessed()); + assert!( + widths_well_formed(&air, &proof), + "the guard must accept an honest proof", + ); +} + +/// Each of the three widths, in each of the two slots, must be pinned. Every +/// mutation below keeps the *total* column count reachable by the old sum check +/// out of scope — the point is that the individual terms are now checked. +#[test_log::test] +fn opening_widths_reject_every_mismatched_term() { + let (air, proof) = make_valid_rap_proof(); + let extra = Felt::one(); + + let mut tampered = proof.clone(); + tampered.deep_poly_openings[0] + .main_trace_polys + .evaluations + .push(extra); + assert!( + !widths_well_formed(&air, &tampered), + "an over-wide main opening must be rejected", + ); + + let mut tampered = proof.clone(); + tampered.deep_poly_openings[0] + .main_trace_polys + .evaluations + .pop(); + assert!( + !widths_well_formed(&air, &tampered), + "an under-wide main opening must be rejected", + ); + + let mut tampered = proof.clone(); + tampered.deep_poly_openings[0] + .main_trace_polys + .evaluations_sym + .push(extra); + assert!( + !widths_well_formed(&air, &tampered), + "an over-wide symmetric main opening must be rejected", + ); + + let mut tampered = proof.clone(); + tampered.deep_poly_openings[0] + .aux_trace_polys + .as_mut() + .expect("the RAP AIR has an aux trace") + .evaluations + .push(extra); + assert!( + !widths_well_formed(&air, &tampered), + "an over-wide aux opening must be rejected", + ); + + let mut tampered = proof.clone(); + tampered.deep_poly_openings[0] + .aux_trace_polys + .as_mut() + .expect("the RAP AIR has an aux trace") + .evaluations_sym + .push(extra); + assert!( + !widths_well_formed(&air, &tampered), + "an over-wide symmetric aux opening must be rejected", + ); + + let mut tampered = proof.clone(); + tampered.deep_poly_openings[0].aux_trace_polys = None; + assert!( + !widths_well_formed(&air, &tampered), + "a missing aux opening must be rejected when the AIR declares aux columns", + ); + + let mut tampered = proof.clone(); + let mut precomputed = tampered.deep_poly_openings[0].main_trace_polys.clone(); + precomputed.evaluations.truncate(1); + precomputed.evaluations_sym.truncate(1); + tampered.deep_poly_openings[0].precomputed_trace_polys = Some(precomputed); + assert!( + !widths_well_formed(&air, &tampered), + "precomputed openings must be rejected for a non-preprocessed AIR", + ); +} + +/// The guard covers every query the FRI phase will read, not just the first. +#[test_log::test] +fn opening_widths_are_checked_for_every_query() { + let (air, proof) = make_valid_rap_proof(); + let last = air.options().fri_number_of_queries - 1; + assert!(last > 0, "test precondition: more than one query"); + + let mut tampered = proof.clone(); + tampered.deep_poly_openings[last] + .main_trace_polys + .evaluations + .push(Felt::one()); + assert!( + !widths_well_formed(&air, &tampered), + "a mismatched split in the last query's opening must be rejected", + ); +} + +/// Fewer openings than queries is rejected rather than indexed past the end. +#[test_log::test] +fn opening_widths_reject_a_truncated_opening_list() { + let (air, proof) = make_valid_rap_proof(); + let mut tampered = proof.clone(); + tampered.deep_poly_openings.pop(); + assert!( + !widths_well_formed(&air, &tampered), + "an opening list shorter than the query count must be rejected", + ); +} diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index 64ae24363..ca6f15152 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -196,6 +196,73 @@ pub trait IsStarkVerifier< && next.height() == expected_next_height } + /// Soundness (I3, opening side): every query opening's column counts are a + /// public function of the AIR, never of the (prover-controlled) proof. + /// + /// An opening splits the trace row into `precomputed ‖ main` (base) and `aux` + /// (extension), which the DEEP reconstruction consumes as one concatenated + /// row — so only their *sum* was pinned, against the AIR-pinned OOD width. + /// The leaf hash pins neither split either: `hash_data_from_slices` streams + /// `evaluations ‖ evaluations_sym` with no length prefix or separator. + /// + /// That is exploitable because the three trees are absorbed at different + /// times: the precomputed root not at all for a non-preprocessed AIR, and the + /// aux root only after the LogUp challenges. An unpinned split therefore lets + /// a prover pick columns *after* challenges they must precede. Both variants + /// accepted a false statement before this check; see `tests::opening_width_tests` + /// and `tests::aux_opening_width_tests`. + /// + /// Runs once per table, before any opening is read. Both slots are checked: + /// they are separate prover-supplied vectors. + fn trace_opening_widths_well_formed( + air: &dyn AIR, + proof: StarkProofView<'_, Field, FieldExtension, PI>, + num_queries: usize, + ) -> bool { + // A non-preprocessed AIR has no precomputed tree, so its openings must + // declare zero precomputed columns — `num_precomputed_columns()` is + // documented as meaningful only under `is_preprocessed()`. + let expected_precomputed = if air.is_preprocessed() { + air.num_precomputed_columns() + } else { + 0 + }; + // Preprocessed tables commit columns `0..n` in the precomputed tree and + // the remaining main columns (the multiplicities) in the main tree. + let expected_main = match air.trace_layout().0.checked_sub(expected_precomputed) { + Some(n) => n, + // An AIR declaring more precomputed columns than it has main columns + // is malformed; no proof can be well formed against it. + None => return false, + }; + let expected_aux = air.num_auxiliary_rap_columns(); + + if proof.deep_poly_openings_len() < num_queries { + return false; + } + (0..num_queries).all(|i| { + let opening = proof.deep_poly_opening(i); + // Absent optional openings count as zero columns, matching how the + // reconstruction reads them (`.unwrap_or(&[])`). + let (precomputed, precomputed_sym) = match opening.precomputed_trace_polys() { + Some(p) => (p.evaluations().len(), p.evaluations_sym().len()), + None => (0, 0), + }; + let (aux, aux_sym) = match opening.aux_trace_polys() { + Some(a) => (a.evaluations().len(), a.evaluations_sym().len()), + None => (0, 0), + }; + let main = opening.main_trace_polys(); + + precomputed == expected_precomputed + && precomputed_sym == expected_precomputed + && main.evaluations().len() == expected_main + && main.evaluations_sym().len() == expected_main + && aux == expected_aux + && aux_sym == expected_aux + }) + } + fn step_2_verify_claimed_composition_polynomial( air: &dyn AIR, proof: StarkProofView<'_, Field, FieldExtension, PI>, @@ -543,9 +610,16 @@ pub trait IsStarkVerifier< iota, ); - // Precomputed trace (preprocessed tables only). Mismatched presence is - // unreachable in practice (multi_verify rejects such proofs upstream), - // but a defensive check keeps this function self-contained. + // Precomputed trace (preprocessed tables only). Mismatched presence: + // `(Some(root), None)` and any `(None, Some(opening))` carrying at least + // one column are rejected upstream by `trace_opening_widths_well_formed` + // (which pins the precomputed opening width to the AIR — zero for a + // non-preprocessed AIR) and, for the missing-root case, by the round-1 + // preprocessed-commitment check. What is left for this arm is the + // degenerate `(None, Some(opening))` with a zero-width opening, which + // upstream cannot distinguish from an absent one. Keep it: this is the + // only site that rejects that shape, and the check keeps the function + // self-contained. ok &= match ( proof.lde_trace_precomputed_merkle_root(), deep_poly_openings.precomputed_trace_polys(), @@ -555,7 +629,13 @@ pub trait IsStarkVerifier< _ => false, }; - // Auxiliary trace. + // Auxiliary trace. This authenticates the opening against the aux root; + // it does NOT constrain how many columns that opening has. Nothing here + // did, and that was a live break: the aux root is absorbed only after the + // shared LogUp challenges, so a prover that moved main columns into the + // aux tree got to choose them after seeing `z`/`alpha` + // (`tests::aux_opening_width_tests`). The width is pinned upstream by + // `trace_opening_widths_well_formed`; do not re-derive it from the proof. ok &= match ( proof.lde_trace_aux_merkle_root(), deep_poly_openings.aux_trace_polys(), @@ -969,6 +1049,16 @@ pub trait IsStarkVerifier< // whose column count does not match the OOD table width, or whose // regular/symmetric base-column split disagree. Without these checks // the indexing below would panic in release builds. + // + // These are panic guards on the *sum* only, and are redundant for proofs + // that reached here through `verify_rounds_2_to_4`: + // `trace_opening_widths_well_formed` already pinned each of the three + // widths (precomputed, main, aux) to the AIR, for both the regular and + // the symmetric slot. That is the authoritative check — soundness must + // not be argued from the sum alone, since the precomputed↔main and + // main↔aux splits move columns between trees that are transcript-bound at + // different times. This function has no AIR, so it keeps the weaker + // guards to stay panic-free on its own. if num_base != num_base_sym { return None; } @@ -1535,6 +1625,21 @@ pub trait IsStarkVerifier< return false; } + // Pin every query opening's precomputed/main/aux column split to the AIR + // before anything reads an opening (step 3 is the first consumer). The + // sum of the three widths was already pinned downstream; the individual + // terms were not, and each tree is transcript-bound at a different time — + // see `trace_opening_widths_well_formed`. Checked over the openings the + // query phase will actually use, which is exactly what the adjacent + // `query_list_len` guard counts (`sample_query_indexes` draws + // `fri_number_of_queries` iotas). + if !Self::trace_opening_widths_well_formed(air, proof, air.options().fri_number_of_queries) + { + #[cfg(not(feature = "test_fiat_shamir"))] + error!("Trace opening column split does not match the AIR"); + return false; + } + // The pruned-OOD layout, read from the AIR once and shared by the round-4 // challenge replay, the block-shape guard, the single grid reconstruction, // and both verify steps below — one reconstruction instead of the previous diff --git a/executor/Cargo.toml b/executor/Cargo.toml index f258265f0..b37bdc081 100644 --- a/executor/Cargo.toml +++ b/executor/Cargo.toml @@ -8,9 +8,21 @@ 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. Production code: +# `compute_hint` runs in every proving execution of a hint-using guest. +k256 = { version = "0.13", default-features = false, features = ["arithmetic", "expose-field"] } [dev-dependencies] proptest = "1.9" +# Test-only: the guest-side syscall crate re-declares the `hint` selectors as `usize` +# and they must stay equal to the `u64` copies here (see `hint_selectors_match_the_guest`). +# Unlike `crypto/crypto`'s and `ethrex-crypto`'s copies of this dep, it is NOT +# target-gated, so it does build on the host — safe because the only guest-only items +# (the `#[global_allocator]` and the `_start`/`main` entrypoint) are already +# `cfg(target_arch = "riscv64")` in that crate, and `executor::tests` is itself +# `#[cfg(test)]`, so the non-test lib build never links it. +lambda-vm-syscalls = { path = "../syscalls" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tiny-keccak = { version = "2.0", features = ["keccak"] } diff --git a/executor/programs/asm/poc_rodata_commit.s b/executor/programs/asm/poc_rodata_commit.s new file mode 100644 index 000000000..b6e2a99ec --- /dev/null +++ b/executor/programs/asm/poc_rodata_commit.s @@ -0,0 +1,27 @@ + .data + .align 3 +secret: + .dword 0x8877665544332211 + + .text + .attribute 5, "rv64i2p1" + .globl main +main: + # Load 8 bytes out of the ELF's own .data section, spill them to the + # stack, and commit them. The committed public output is therefore a + # direct function of the ELF image bytes at `secret`, which the verifier + # binds through the PAGE preprocessed commitment of that data page. + la t0, secret + ld t1, 0(t0) # t1 = *secret + addi sp, sp, -16 + sd t1, 0(sp) # spill to stack + li a0, 1 # fd = 1 + mv a1, sp # buf = sp + li a2, 8 # count = 8 + li a7, 64 # syscall = Commit + ecall + + addi sp, sp, 16 + li a0, 0 + li a7, 93 # syscall = Halt + ecall diff --git a/executor/programs/rust/dma_memmove_cases/.cargo/config.toml b/executor/programs/rust/dma_memmove_cases/.cargo/config.toml new file mode 100644 index 000000000..8ef8239bb --- /dev/null +++ b/executor/programs/rust/dma_memmove_cases/.cargo/config.toml @@ -0,0 +1,9 @@ +[target.riscv64im-lambda-vm-elf] +rustflags = [ + "--cfg", "getrandom_backend=\"custom\"", + "-C", "passes=lower-atomic" +] + +[env] +CC_riscv64im_lambda_vm_elf = "clang" +CFLAGS_riscv64im_lambda_vm_elf = "--target=riscv64 -march=rv64im -mabi=lp64 --sysroot=/opt/lambda-vm-sysroot" diff --git a/executor/programs/rust/dma_memmove_cases/Cargo.lock b/executor/programs/rust/dma_memmove_cases/Cargo.lock new file mode 100644 index 000000000..04c10ccfe --- /dev/null +++ b/executor/programs/rust/dma_memmove_cases/Cargo.lock @@ -0,0 +1,294 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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 = "dma_memmove_cases" +version = "0.1.0" +dependencies = [ + "lambda-vm-syscalls", +] + +[[package]] +name = "embedded-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f2de9133f68db0d4627ad69db767726c99ff8585272716708227008d3f1bddd" +dependencies = [ + "const-default", + "critical-section", + "linked_list_allocator", + "rlsf", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "lambda-vm-syscalls" +version = "0.1.0" +dependencies = [ + "embedded-alloc", + "getrandom 0.2.17", + "getrandom 0.3.4", + "lazy_static", + "rand", + "riscv", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.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", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07393724337be2ee43a9d86164df4505746874a3fa65913374bc6d6a92314362" +dependencies = [ + "cfg-if", + "const-default", + "libc", + "rustversion", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[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", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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", +] diff --git a/executor/programs/rust/dma_memmove_cases/Cargo.toml b/executor/programs/rust/dma_memmove_cases/Cargo.toml new file mode 100644 index 000000000..b81ea25a9 --- /dev/null +++ b/executor/programs/rust/dma_memmove_cases/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] + +[package] +name = "dma_memmove_cases" +version = "0.1.0" +edition = "2024" + +[dependencies] +lambda-vm-syscalls = { path = "../../../../syscalls" } diff --git a/executor/programs/rust/dma_memmove_cases/src/main.rs b/executor/programs/rust/dma_memmove_cases/src/main.rs new file mode 100644 index 000000000..45ecdb0de --- /dev/null +++ b/executor/programs/rust/dma_memmove_cases/src/main.rs @@ -0,0 +1,70 @@ +use lambda_vm_syscalls as syscalls; + +unsafe extern "C" { + fn memmove(dst: *mut u8, src: *const u8, count: usize) -> *mut u8; +} + +#[inline(never)] +fn dma_move(dst: *mut u8, src: *const u8, count: usize) -> *mut u8 { + let count = core::hint::black_box(count); + unsafe { memmove(dst, src, count) } +} + +fn fill_pattern(bytes: &mut [u8], seed: u8) { + for (i, byte) in bytes.iter_mut().enumerate() { + *byte = (i as u8).wrapping_mul(37).wrapping_add(seed); + } +} + +pub fn main() { + // Disjoint regions behave like memcpy. + let mut source = [0u8; 777]; + let mut destination = [0xA5u8; 777]; + fill_pattern(&mut source, 11); + for count in [0usize, 1, 7, 8, 255, 256, 257, 777] { + destination.fill(0xA5); + let returned = dma_move(destination.as_mut_ptr(), source.as_ptr(), count); + assert_eq!(returned, destination.as_mut_ptr()); + assert_eq!(&destination[..count], &source[..count]); + assert!(destination[count..].iter().all(|&b| b == 0xA5)); + } + + // Forward overlap (dst inside [src, src+n)) is the case that needs BACKWARD + // chunking; a forward-chunked copy corrupts it once n exceeds one chunk. + // Offsets below and above 256 exercise both sides of the chunk boundary. + for (offset, count) in [(1usize, 600usize), (17, 600), (255, 600), (256, 600), (300, 700), (4, 8)] { + let mut buffer = [0u8; 1600]; + fill_pattern(&mut buffer, 23); + let before = buffer; + dma_move( + unsafe { buffer.as_mut_ptr().add(offset) }, + buffer.as_ptr(), + count, + ); + assert_eq!(&buffer[offset..offset + count], &before[..count]); + // Bytes below the destination must be untouched. + assert_eq!(&buffer[..offset], &before[..offset]); + } + + // Backward overlap (dst below src) stays forward-chunked. + for (offset, count) in [(1usize, 600usize), (17, 600), (300, 700)] { + let mut buffer = [0u8; 1600]; + fill_pattern(&mut buffer, 41); + let before = buffer; + dma_move( + buffer.as_mut_ptr(), + unsafe { buffer.as_ptr().add(offset) }, + count, + ); + assert_eq!(&buffer[..count], &before[offset..offset + count]); + } + + // Exact aliasing must be a no-op. + let mut same = [0u8; 300]; + fill_pattern(&mut same, 7); + let before = same; + dma_move(same.as_mut_ptr(), same.as_ptr(), 300); + assert_eq!(same, before); + + syscalls::syscalls::commit(b"dma-memmove-ok"); +} diff --git a/executor/programs/rust/dma_memset_cases/.cargo/config.toml b/executor/programs/rust/dma_memset_cases/.cargo/config.toml new file mode 100644 index 000000000..8ef8239bb --- /dev/null +++ b/executor/programs/rust/dma_memset_cases/.cargo/config.toml @@ -0,0 +1,9 @@ +[target.riscv64im-lambda-vm-elf] +rustflags = [ + "--cfg", "getrandom_backend=\"custom\"", + "-C", "passes=lower-atomic" +] + +[env] +CC_riscv64im_lambda_vm_elf = "clang" +CFLAGS_riscv64im_lambda_vm_elf = "--target=riscv64 -march=rv64im -mabi=lp64 --sysroot=/opt/lambda-vm-sysroot" diff --git a/executor/programs/rust/dma_memset_cases/Cargo.lock b/executor/programs/rust/dma_memset_cases/Cargo.lock new file mode 100644 index 000000000..22c1e11fe --- /dev/null +++ b/executor/programs/rust/dma_memset_cases/Cargo.lock @@ -0,0 +1,294 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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 = "dma_memset_cases" +version = "0.1.0" +dependencies = [ + "lambda-vm-syscalls", +] + +[[package]] +name = "embedded-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f2de9133f68db0d4627ad69db767726c99ff8585272716708227008d3f1bddd" +dependencies = [ + "const-default", + "critical-section", + "linked_list_allocator", + "rlsf", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "lambda-vm-syscalls" +version = "0.1.0" +dependencies = [ + "embedded-alloc", + "getrandom 0.2.17", + "getrandom 0.3.4", + "lazy_static", + "rand", + "riscv", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.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", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07393724337be2ee43a9d86164df4505746874a3fa65913374bc6d6a92314362" +dependencies = [ + "cfg-if", + "const-default", + "libc", + "rustversion", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[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", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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", +] diff --git a/executor/programs/rust/dma_memset_cases/Cargo.toml b/executor/programs/rust/dma_memset_cases/Cargo.toml new file mode 100644 index 000000000..de5dc5ede --- /dev/null +++ b/executor/programs/rust/dma_memset_cases/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] + +[package] +name = "dma_memset_cases" +version = "0.1.0" +edition = "2024" + +[dependencies] +lambda-vm-syscalls = { path = "../../../../syscalls" } diff --git a/executor/programs/rust/dma_memset_cases/src/main.rs b/executor/programs/rust/dma_memset_cases/src/main.rs new file mode 100644 index 000000000..318e2b0d1 --- /dev/null +++ b/executor/programs/rust/dma_memset_cases/src/main.rs @@ -0,0 +1,63 @@ +use lambda_vm_syscalls as syscalls; + +unsafe extern "C" { + fn memset(dst: *mut u8, fill: i32, count: usize) -> *mut u8; +} + +/// `black_box` on the count keeps LLVM from turning these into inline stores, +/// so every call really does reach the strong `memset` symbol and the DMA ecall. +#[inline(never)] +fn dma_set(dst: *mut u8, fill: i32, count: usize) -> *mut u8 { + let count = core::hint::black_box(count); + unsafe { memset(dst, fill, count) } +} + +pub fn main() { + let mut buffer = [0u8; 777]; + + // Every row-schedule boundary: empty, sub-tail, exact widths, the 256-byte + // per-ecall cap, and one length that forces several chunked ecalls. + for count in [0usize, 1, 7, 8, 9, 31, 32, 33, 127, 128, 255, 256] { + buffer.fill(0xA5); + let returned = dma_set(buffer.as_mut_ptr(), 0x3C, count); + assert_eq!(returned, buffer.as_mut_ptr()); + assert!(buffer[..count].iter().all(|&byte| byte == 0x3C)); + assert!(buffer[count..].iter().all(|&byte| byte == 0xA5)); + } + + // More than one chunk: 777 bytes becomes four bounded DMA ecalls. + buffer.fill(0); + dma_set(buffer.as_mut_ptr(), 0x5A, buffer.len()); + assert!(buffer.iter().all(|&byte| byte == 0x5A)); + + // Zero is the fill almost every real caller passes (`vec![0; n]` and the + // allocator's `alloc_zeroed`), and it is the one value a dropped write is + // indistinguishable from on a fresh buffer — so start from 0xA5. + buffer.fill(0xA5); + dma_set(buffer.as_mut_ptr(), 0, 100); + assert!(buffer[..100].iter().all(|&byte| byte == 0)); + assert!(buffer[100..].iter().all(|&byte| byte == 0xA5)); + + // The guest stub masks the fill to its low byte, matching C's + // `memset(void*, int, size_t)` writing `(unsigned char)c`. + buffer.fill(0); + dma_set(buffer.as_mut_ptr(), 0x1FF, 64); + assert!(buffer[..64].iter().all(|&byte| byte == 0xFF)); + + // A negative int sign-extends to 0xFFFF_FFFF_FFFF_FFFF under lp64; the + // `andi` is what keeps the executor from rejecting it as a wide fill. + buffer.fill(0); + dma_set(buffer.as_mut_ptr(), -1, 32); + assert!(buffer[..32].iter().all(|&byte| byte == 0xFF)); + + // Unaligned destination that also crosses a 4 KiB page boundary. + let mut page_buffer = [0u8; 8192]; + let to_boundary = 4096 - (page_buffer.as_ptr() as usize & 4095); + let offset = to_boundary.saturating_sub(5); + dma_set(unsafe { page_buffer.as_mut_ptr().add(offset) }, 0x77, 256); + assert!(page_buffer[offset..offset + 256] + .iter() + .all(|&byte| byte == 0x77)); + + syscalls::syscalls::commit(b"dma-memset-ok"); +} diff --git a/executor/programs/rust/dma_memset_min/.cargo/config.toml b/executor/programs/rust/dma_memset_min/.cargo/config.toml new file mode 100644 index 000000000..8ef8239bb --- /dev/null +++ b/executor/programs/rust/dma_memset_min/.cargo/config.toml @@ -0,0 +1,9 @@ +[target.riscv64im-lambda-vm-elf] +rustflags = [ + "--cfg", "getrandom_backend=\"custom\"", + "-C", "passes=lower-atomic" +] + +[env] +CC_riscv64im_lambda_vm_elf = "clang" +CFLAGS_riscv64im_lambda_vm_elf = "--target=riscv64 -march=rv64im -mabi=lp64 --sysroot=/opt/lambda-vm-sysroot" diff --git a/executor/programs/rust/dma_memset_min/Cargo.lock b/executor/programs/rust/dma_memset_min/Cargo.lock new file mode 100644 index 000000000..47f113220 --- /dev/null +++ b/executor/programs/rust/dma_memset_min/Cargo.lock @@ -0,0 +1,294 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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 = "dma_memset_min" +version = "0.1.0" +dependencies = [ + "lambda-vm-syscalls", +] + +[[package]] +name = "embedded-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f2de9133f68db0d4627ad69db767726c99ff8585272716708227008d3f1bddd" +dependencies = [ + "const-default", + "critical-section", + "linked_list_allocator", + "rlsf", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "lambda-vm-syscalls" +version = "0.1.0" +dependencies = [ + "embedded-alloc", + "getrandom 0.2.17", + "getrandom 0.3.4", + "lazy_static", + "rand", + "riscv", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.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", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07393724337be2ee43a9d86164df4505746874a3fa65913374bc6d6a92314362" +dependencies = [ + "cfg-if", + "const-default", + "libc", + "rustversion", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[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", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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", +] diff --git a/executor/programs/rust/dma_memset_min/Cargo.toml b/executor/programs/rust/dma_memset_min/Cargo.toml new file mode 100644 index 000000000..3a98a947c --- /dev/null +++ b/executor/programs/rust/dma_memset_min/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] + +[package] +name = "dma_memset_min" +version = "0.1.0" +edition = "2024" + +[dependencies] +lambda-vm-syscalls = { path = "../../../../syscalls" } diff --git a/executor/programs/rust/dma_memset_min/src/main.rs b/executor/programs/rust/dma_memset_min/src/main.rs new file mode 100644 index 000000000..1705064b6 --- /dev/null +++ b/executor/programs/rust/dma_memset_min/src/main.rs @@ -0,0 +1,17 @@ +use lambda_vm_syscalls as syscalls; + +unsafe extern "C" { + fn memset(dst: *mut u8, fill: i32, count: usize) -> *mut u8; +} + +pub fn main() { + // 43 bytes = five eight-byte rows plus a three-byte tail, so one call yields + // a first row, wide intermediate rows, tail rows and a terminal row. + let mut buffer = [0u8; 43]; + let count = core::hint::black_box(buffer.len()); + + unsafe { + memset(buffer.as_mut_ptr(), 0x3C, count); + } + syscalls::syscalls::commit(&buffer); +} diff --git a/executor/programs/rust/ethrex/src/main.rs b/executor/programs/rust/ethrex/src/main.rs index 30a39f4b5..8154978cf 100644 --- a/executor/programs/rust/ethrex/src/main.rs +++ b/executor/programs/rust/ethrex/src/main.rs @@ -5,8 +5,13 @@ use lambda_vm_ethrex_crypto::LambdaVmEcsmCrypto; use rkyv::rancor::Error; pub fn main() { - let input = lambda_vm_syscalls::syscalls::get_private_input(); - let input = rkyv::from_bytes::(&input).unwrap(); + // Zero-copy private input: borrow the memory-mapped input region in place + // (the host pre-loads it before execution) so rkyv deserializes straight + // out of it. `get_private_input()` is this same slice plus a `to_vec()` — + // a full extra copy and one large allocation (~50k cycles on a 20-tx + // block). + let input = lambda_vm_syscalls::syscalls::get_private_input_slice(); + let input = rkyv::from_bytes::(input).unwrap(); // LambdaVM crypto provider, defined in the lambda_vm repo and injected here // (so crypto changes don't require an ethrex PR — see `crypto/ethrex-crypto`). // It accelerates trait-routed `keccak256` (via the keccak_permute precompile) 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..833a01b8a --- /dev/null +++ b/executor/programs/rust/hint_min/src/main.rs @@ -0,0 +1,31 @@ +//! 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 — the same +//! choice the ethrex call site makes (`get_hint` in `crypto/ethrex-crypto` wraps its +//! output in an `align(8)` buffer). Alignment is a preference rather than a +//! requirement — `classify_memw` routes unaligned accesses to the general MEMW table. + +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..2a03a644d --- /dev/null +++ b/executor/programs/rust/hint_multi/src/main.rs @@ -0,0 +1,43 @@ +//! Multi-hint P0/P2 guest for the Hint prover table: THREE `hint` ecalls, one per +//! selector, 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), **all three selectors** +//! (`HINT_FIELD_INV` / `HINT_SCALAR_INV` / `HINT_FIELD_SQRT`, so the AIR's +//! `selector < 3` range-check is exercised at every accepted value rather than only +//! at 0) 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]); + + // One call per selector. 4 is a quadratic residue mod p, so the sqrt hint has a + // real root rather than the zeros `compute_hint` returns on a numeric failure. + for (hint_id, seed) in [ + (syscalls::syscalls::HINT_FIELD_INV, 3u8), + (syscalls::syscalls::HINT_SCALAR_INV, 5u8), + (syscalls::syscalls::HINT_FIELD_SQRT, 4u8), + ] { + let mut x = Aligned32([0u8; 32]); + x.0[31] = seed; + let mut out = Aligned32([0u8; 32]); + + syscalls::syscalls::hint(hint_id, &mut out.0, &x.0); + + // Read the hinted output back via ordinary loads and fold it in, so the + // MEMW reads of `out` must chain to the HINT table's writes. + for i in 0..32 { + acc.0[i] ^= out.0[i]; + } + } + + syscalls::syscalls::commit(&acc.0); +} diff --git a/executor/programs/rust/keccak_transcript_pattern/Cargo.lock b/executor/programs/rust/keccak_transcript_pattern/Cargo.lock index 4e5afb1bd..0b59195aa 100644 --- a/executor/programs/rust/keccak_transcript_pattern/Cargo.lock +++ b/executor/programs/rust/keccak_transcript_pattern/Cargo.lock @@ -88,8 +88,6 @@ dependencies = [ "digest", "lambda-vm-syscalls", "math", - "rand 0.8.7", - "rand_chacha 0.3.1", "serde", "sha3", ] @@ -240,7 +238,7 @@ dependencies = [ "getrandom 0.2.17", "getrandom 0.3.4", "lazy_static", - "rand 0.9.5", + "rand", "riscv", "thiserror", ] @@ -270,7 +268,6 @@ dependencies = [ "getrandom 0.2.17", "num-bigint", "num-traits", - "rand 0.8.7", "rayon", "serde", "serde_json", @@ -361,33 +358,14 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "rand" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" -dependencies = [ - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", + "rand_chacha", + "rand_core", ] [[package]] @@ -397,15 +375,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.5", + "rand_core", ] -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" - [[package]] name = "rand_core" version = "0.9.5" diff --git a/executor/src/tests/dma_tests.rs b/executor/src/tests/dma_tests.rs index 7965bfbdb..f85167984 100644 --- a/executor/src/tests/dma_tests.rs +++ b/executor/src/tests/dma_tests.rs @@ -1,6 +1,7 @@ use crate::vm::instruction::decoding::Instruction; use crate::vm::instruction::execution::{ - DMA_MEMCPY_MAX_BYTES, DMA_MEMCPY_SYSCALL_NUMBER, ExecutionError, + DMA_MEMCPY_MAX_BYTES, DMA_MEMCPY_SYSCALL_NUMBER, DMA_MEMSET_MAX_FILL, + DMA_MEMSET_SYSCALL_NUMBER, ExecutionError, }; use crate::vm::memory::Memory; use crate::vm::registers::Registers; @@ -65,7 +66,7 @@ fn dma_memcpy_rejects_oversized_direct_ecall() { 0x1000, DMA_MEMCPY_MAX_BYTES + 1 ), - Err(ExecutionError::DmaMemcpyChunkTooLarge(n)) + Err(ExecutionError::DmaChunkTooLarge(n)) if n == DMA_MEMCPY_MAX_BYTES + 1 )); } @@ -115,3 +116,81 @@ proptest! { prop_assert_eq!(actual, expected); } } + +fn run_memset(memory: &mut Memory, dst: u64, fill: u64, count: u64) -> Result<(), ExecutionError> { + let mut registers = Registers::default(); + let mut pc = 0; + registers.write(17, DMA_MEMSET_SYSCALL_NUMBER)?; + registers.write(10, dst)?; + registers.write(11, fill)?; + registers.write(12, count)?; + Instruction::EcallEbreak.run(&mut pc, &mut registers, memory)?; + Ok(()) +} + +#[test] +fn dma_memset_fills_unaligned_body_and_tail() { + let mut memory = Memory::default(); + // 27 bytes = three eight-byte rows plus a three-byte tail, at an unaligned base. + run_memset(&mut memory, 0x2005, 0x3C, 27).unwrap(); + + assert_eq!(memory.load_bytes(0x2005, 27).unwrap(), vec![0x3Cu8; 27]); + // Neighbours must be untouched. + assert_eq!(memory.load_byte(0x2004), 0); + assert_eq!(memory.load_byte(0x2005 + 27), 0); +} + +#[test] +fn dma_memset_zero_count_writes_nothing() { + let mut memory = Memory::default(); + memory.store_byte(0x3000, 0x11); + run_memset(&mut memory, 0x3000, 0xFF, 0).unwrap(); + assert_eq!(memory.load_byte(0x3000), 0x11); +} + +#[test] +fn dma_memset_rejects_wrapping_range() { + let mut memory = Memory::default(); + assert!(run_memset(&mut memory, u64::MAX - 3, 0x11, 8).is_err()); +} + +#[test] +fn dma_memset_rejects_oversized_chunk() { + let mut memory = Memory::default(); + assert!(matches!( + run_memset(&mut memory, 0x2000, 0x11, DMA_MEMCPY_MAX_BYTES + 1), + Err(ExecutionError::DmaChunkTooLarge(n)) if n == DMA_MEMCPY_MAX_BYTES + 1 + )); +} + +#[test] +fn dma_memset_rejects_fill_wider_than_a_byte() { + // The guest stub masks `a1` with `andi ..., 255`, so only a malformed call + // reaches here. Rejecting it is what lets the AIR prove the bound with one LT. + let mut memory = Memory::default(); + assert!(matches!( + run_memset(&mut memory, 0x2000, DMA_MEMSET_MAX_FILL + 1, 8), + Err(ExecutionError::DmaMemsetFillTooLarge(c)) if c == DMA_MEMSET_MAX_FILL + 1 + )); +} + +proptest! { + #[test] + fn dma_memset_matches_reference_fill( + dst_offset in 0usize..64, + count in 0usize..=DMA_MEMCPY_MAX_BYTES as usize, + fill in 0u8..=255, + ) { + const BASE: u64 = 0x9000; + const REGION: usize = 320; + + let mut expected = vec![0u8; REGION]; + expected[dst_offset..dst_offset + count].fill(fill); + + let mut memory = Memory::default(); + run_memset(&mut memory, BASE + dst_offset as u64, u64::from(fill), count as u64).unwrap(); + + let actual = memory.load_bytes(BASE, REGION as u64).unwrap(); + prop_assert_eq!(actual, expected); + } +} diff --git a/executor/src/tests/hint_tests.rs b/executor/src/tests/hint_tests.rs new file mode 100644 index 000000000..2ed8c096c --- /dev/null +++ b/executor/src/tests/hint_tests.rs @@ -0,0 +1,196 @@ +//! 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:?}" + ); + } +} + +/// The guest's `lambda-vm-syscalls` crate re-declares the selectors as `usize`, +/// linked to the `u64` copies here only by a comment. A divergence is **silent**: +/// the ecall would trap on an unknown selector, or — worse for the selectors that +/// stay in range — hand back the wrong function's answer, which the guest's +/// verify-then-fallback swallows as "the host lied" and quietly recomputes in +/// software. Nothing fails; the guest just runs ~2000× slower for the right result. +/// This test is the only thing that would notice. +/// +/// `is_valid_hint_selector`'s const-assert pins the AIR's range-check to this crate's +/// accepted set, but nothing ties the *guest's* copy of the selectors to it — that is +/// a third declaration, in a crate the workspace excludes, and this is what binds it. +/// +/// The syscall number itself is not asserted here: the guest's copy is +/// `#[cfg(target_arch = "riscv64")]` and private, so it does not exist in a host +/// build. It is covered indirectly — a wrong number makes every `hint` guest fail +/// to prove, which `test_prove_hint_min_rust_guest` catches loudly. +#[cfg(test)] +mod guest_constant_sync { + use super::{HINT_FIELD_INV, HINT_FIELD_SQRT, HINT_SCALAR_INV}; + use lambda_vm_syscalls::syscalls as guest; + + #[test] + fn hint_selectors_match_the_guest() { + assert_eq!(guest::HINT_FIELD_INV as u64, HINT_FIELD_INV); + assert_eq!(guest::HINT_SCALAR_INV as u64, HINT_SCALAR_INV); + assert_eq!(guest::HINT_FIELD_SQRT as u64, HINT_FIELD_SQRT); + } +} diff --git a/executor/src/tests/mod.rs b/executor/src/tests/mod.rs index 39a27bf46..6cb04db7c 100644 --- a/executor/src/tests/mod.rs +++ b/executor/src/tests/mod.rs @@ -1,6 +1,7 @@ pub mod dma_tests; pub mod ecsm_tests; pub mod flamegraph_tests; +pub mod hint_tests; pub mod keccak_tests; pub mod memory_tests; pub mod syscall_tests; diff --git a/executor/src/vm/instruction/execution.rs b/executor/src/vm/instruction/execution.rs index 7af76dd02..7259c2f8a 100644 --- a/executor/src/vm/instruction/execution.rs +++ b/executor/src/vm/instruction/execution.rs @@ -36,7 +36,13 @@ syscall_numbers! { /// Placeholder discriminant. The actual syscall value is /// `DMA_MEMCPY_SYSCALL_NUMBER`. DMA memcpy chunks are proven by the /// dedicated DMA table. - DmaMemcpy = 95, + DmaMemcpy = 96, + // Placeholder discriminant. The actual syscall value is DMA_MEMSET_SYSCALL_NUMBER. + // DMA memset chunks are proven by the dedicated DMA_SET table. + DmaMemset = 97, + // 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). @@ -58,6 +64,54 @@ pub const DMA_MEMCPY_SYSCALL_NUMBER: u64 = u64::MAX - 2; /// larger copies, and the prover enforces this bound on every first DMA row. pub const DMA_MEMCPY_MAX_BYTES: u64 = 256; +/// DMA memset syscall number. Must match `syscalls/src/syscalls.rs`. +pub const DMA_MEMSET_SYSCALL_NUMBER: u64 = u64::MAX - 3; +/// Largest fill value a DMA memset ecall accepts. C's `memset` writes +/// `(unsigned char)c`, so the guest stub masks `a1` down to this range; a wider +/// value is a malformed call. Bounding it here lets the DMA_SET AIR prove the +/// same bound with one ALU LT instead of decomposing the register. +pub const DMA_MEMSET_MAX_FILL: u64 = 255; + +/// 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 + +/// One past the largest valid hint selector. The prover's HINT table range-checks +/// `a0 < HINT_SELECTOR_BOUND` on the ALU bus to accept exactly the set +/// [`is_valid_hint_selector`] accepts, so both live here rather than being restated +/// independently in the AIR. +pub const HINT_SELECTOR_BOUND: u64 = 3; + +/// Whether `hint_id` names a hint [`compute_hint`] can produce. The ecall rejects +/// anything else up front with [`ExecutionError::HintUnknownSelector`]. +pub const fn is_valid_hint_selector(hint_id: u64) -> bool { + matches!(hint_id, HINT_FIELD_INV | HINT_SCALAR_INV | HINT_FIELD_SQRT) +} + +// The AIR's range-check and the executor's accepted set must denote the same set: every +// selector below the bound is valid, and the bound itself is not. Appending a selector +// without moving the bound (or vice versa) fails to compile here, instead of making the +// HINT table assert `LT(selector, bound) = 1` against an LT row the builder emits as 0 — +// an unbalanced ALU bus with no algebraic pointer to the cause. +const _: () = { + let mut id = 0; + while id < HINT_SELECTOR_BOUND { + assert!(is_valid_hint_selector(id)); + id += 1; + } + assert!(!is_valid_hint_selector(HINT_SELECTOR_BOUND)); +}; + /// `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; @@ -73,6 +127,8 @@ impl TryFrom for SyscallNumbers { v if v == KECCAK_SYSCALL_NUMBER => Ok(SyscallNumbers::KeccakPermute), v if v == ECSM_SYSCALL_NUMBER => Ok(SyscallNumbers::Ecsm), v if v == DMA_MEMCPY_SYSCALL_NUMBER => Ok(SyscallNumbers::DmaMemcpy), + v if v == DMA_MEMSET_SYSCALL_NUMBER => Ok(SyscallNumbers::DmaMemset), + v if v == HINT_SYSCALL_NUMBER => Ok(SyscallNumbers::Hint), _ => Err(()), } } @@ -94,6 +150,8 @@ impl SyscallNumbers { SyscallNumbers::KeccakPermute => KECCAK_SYSCALL_NUMBER, SyscallNumbers::Ecsm => ECSM_SYSCALL_NUMBER, SyscallNumbers::DmaMemcpy => DMA_MEMCPY_SYSCALL_NUMBER, + SyscallNumbers::DmaMemset => DMA_MEMSET_SYSCALL_NUMBER, + SyscallNumbers::Hint => HINT_SYSCALL_NUMBER, SyscallNumbers::Print => SyscallNumbers::Print as u64, SyscallNumbers::Panic => SyscallNumbers::Panic as u64, SyscallNumbers::Commit => SyscallNumbers::Commit as u64, @@ -108,11 +166,12 @@ impl SyscallNumbers { match self { SyscallNumbers::KeccakPermute => Some(Accelerator::Keccak), SyscallNumbers::Ecsm => Some(Accelerator::Ecsm), - SyscallNumbers::DmaMemcpy => Some(Accelerator::Dma), + SyscallNumbers::DmaMemcpy | SyscallNumbers::DmaMemset => Some(Accelerator::Dma), SyscallNumbers::Print | SyscallNumbers::Panic | SyscallNumbers::Commit - | SyscallNumbers::Halt => None, + | SyscallNumbers::Halt + | SyscallNumbers::Hint => None, } } } @@ -137,8 +196,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 } @@ -473,9 +583,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); } @@ -507,7 +617,7 @@ impl Instruction { let src = registers.read(11)?; let n = registers.read(12)?; if n > DMA_MEMCPY_MAX_BYTES { - return Err(ExecutionError::DmaMemcpyChunkTooLarge(n)); + return Err(ExecutionError::DmaChunkTooLarge(n)); } dst.checked_add(n).ok_or(MemoryError::AddressOverflow)?; src.checked_add(n).ok_or(MemoryError::AddressOverflow)?; @@ -524,6 +634,65 @@ impl Instruction { src2_val = src; dst_val = n; } + SyscallNumbers::DmaMemset => { + // memset(dst = x10, fill = x11, n = x12). No source range + // to snapshot: every byte written is the same constant, so + // the DMA_SET trace carries one fill column instead of the + // eight value columns memcpy needs. + let dst = registers.read(10)?; + let fill = registers.read(11)?; + let n = registers.read(12)?; + if n > DMA_MEMCPY_MAX_BYTES { + return Err(ExecutionError::DmaChunkTooLarge(n)); + } + if fill > DMA_MEMSET_MAX_FILL { + return Err(ExecutionError::DmaMemsetFillTooLarge(fill)); + } + dst.checked_add(n).ok_or(MemoryError::AddressOverflow)?; + + let byte = fill as u8; + for i in 0..n { + memory.store_byte(dst + i, byte); + } + src2_val = fill; + dst_val = n; + } + 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 !is_valid_hint_selector(hint_id) { + return Err(ExecutionError::HintUnknownSelector(hint_id)); + } + // Both operands are bounded so their 32-byte ranges cannot cross the + // 2^32 limb boundary, and the HINT table range-checks both low limbs + // against the same bound (`HINT_ADDR_LIMB_BOUND`) so the AIR accepts + // exactly what this rejects. The memory bus does not do that job on + // its own: it bounds `out_addr` only to 2^32 - 25, because the write + // bases are `out_addr_lo + 8i` and MEMW's carry columns resolve the + // bytes past the largest base. `in_addr` is not on the bus at all + // (the input read is not modeled). Bounding both also keeps + // `load_u256_le`/`store_u256_le` from overflowing their 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 { @@ -704,8 +873,14 @@ pub enum ExecutionError { EcsmAddressOverflow, #[error("ECSM xG and k operand ranges overlap")] EcsmOperandOverlap, - #[error("DMA memcpy chunk has {0} bytes; maximum per ecall is {DMA_MEMCPY_MAX_BYTES}")] - DmaMemcpyChunkTooLarge(u64), + #[error("DMA chunk has {0} bytes; maximum per ecall is {DMA_MEMCPY_MAX_BYTES}")] + DmaChunkTooLarge(u64), + #[error("DMA memset fill is {0}; maximum is {DMA_MEMSET_MAX_FILL}")] + DmaMemsetFillTooLarge(u64), + #[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/executor/tests/rust.rs b/executor/tests/rust.rs index 4eb3b32f9..037b64656 100644 --- a/executor/tests/rust.rs +++ b/executor/tests/rust.rs @@ -1,7 +1,10 @@ use executor::{ elf::Elf, vm::execution::{Executor, ReturnValues}, - vm::instruction::{decoding::Instruction, execution::DMA_MEMCPY_SYSCALL_NUMBER}, + vm::instruction::{ + decoding::Instruction, + execution::{DMA_MEMCPY_SYSCALL_NUMBER, DMA_MEMSET_SYSCALL_NUMBER}, + }, }; // NOTE: These tests require 64-bit RISC-V ELF files (RV64IM). @@ -149,6 +152,25 @@ fn test_dma_memcpy_cases() { ); } +#[test] +fn test_dma_memset_cases() { + let elf_data = std::fs::read("./program_artifacts/rust/dma_memset_cases.elf").unwrap(); + let program = Elf::load(&elf_data).unwrap(); + let result = Executor::new(&program, vec![]).unwrap().run().unwrap(); + + assert_eq!(result.return_values.memory_values, b"dma-memset-ok"); + assert!( + result.logs.iter().any(|log| { + log.src1_val == DMA_MEMSET_SYSCALL_NUMBER + && matches!( + result.instructions.get(&log.current_pc), + Some(Instruction::EcallEbreak) + ) + }), + "the strong memset symbol must execute at least one DMA ecall" + ); +} + #[test] fn test_hashmap() { run_program_and_check_output("./program_artifacts/rust/hashmap.elf", 3, vec![]); diff --git a/prover/src/auto_storage.rs b/prover/src/auto_storage.rs index 4c5e06783..ee13acb53 100644 --- a/prover/src/auto_storage.rs +++ b/prover/src/auto_storage.rs @@ -11,6 +11,9 @@ use crate::tables::commit::{bus_interactions as commit_buses, cols::NUM_COLUMNS use crate::tables::cpu::{bus_interactions as cpu_buses, cols::NUM_COLUMNS as CPU_COLS}; use crate::tables::decode::{bus_interactions as decode_buses, cols::NUM_COLUMNS as DECODE_COLS}; use crate::tables::dma::{bus_interactions as dma_buses, cols::NUM_COLUMNS as DMA_COLS}; +use crate::tables::dma_set::{ + bus_interactions as dma_set_buses, cols::NUM_COLUMNS as DMA_SET_COLS, +}; use crate::tables::dvrm::{bus_interactions as dvrm_buses, cols::NUM_COLUMNS as DVRM_COLS}; use crate::tables::halt::{bus_interactions as halt_buses, cols::NUM_COLUMNS as HALT_COLS}; use crate::tables::load::{bus_interactions as load_buses, cols::NUM_COLUMNS as LOAD_COLS}; @@ -185,6 +188,12 @@ fn table_specs(lengths: &TableLengths) -> Vec { aux_cols(dma_buses().len()), 1, ), + ( + lengths.dma_set_padded_rows, + DMA_SET_COLS as u64, + aux_cols(dma_set_buses().len()), + 1, + ), // BITWISE / DECODE / PAGE / REGISTER take the preprocessed-trace commit // path: it extracts ALL columns into the LDE and builds two Merkle trees // (precomputed_tree + mult_tree), so main_cols = full NUM_COLUMNS and diff --git a/prover/src/bin/compute_static_commitments.rs b/prover/src/bin/compute_static_commitments.rs index 045e15a4c..a4de1ddaa 100644 --- a/prover/src/bin/compute_static_commitments.rs +++ b/prover/src/bin/compute_static_commitments.rs @@ -54,6 +54,7 @@ fn main() { let bitwise = bitwise::compute_preprocessed_commitment(&options); let keccak_rc = keccak_rc::compute_preprocessed_commitment(&options); let zero_page = page::compute_precomputed_commitment(&zero_page_config, &options); + let private_page = page::compute_offset_only_commitment(&options); println!( "// blowup_factor = {blowup}\n\ @@ -62,10 +63,13 @@ fn main() { // ---- keccak_rc:\n \ {blowup} => Some({keccak_fmt}),\n\ // ---- zero_page:\n \ - {blowup} => Some({zero_page_fmt}),\n", + {blowup} => Some({zero_page_fmt}),\n\ + // ---- private_page (OFFSET only):\n \ + {blowup} => Some({private_page_fmt}),\n", bitwise_fmt = format_commitment(&bitwise), keccak_fmt = format_commitment(&keccak_rc), zero_page_fmt = format_commitment(&zero_page), + private_page_fmt = format_commitment(&private_page), ); } } diff --git a/prover/src/continuation.rs b/prover/src/continuation.rs index 0f8760ce0..6d800f2a2 100644 --- a/prover/src/continuation.rs +++ b/prover/src/continuation.rs @@ -211,12 +211,14 @@ fn l2g_memory_air( /// zero-init pages (stack/heap) via the static zero-page commitment. The prover /// cannot choose those genesis values. /// -/// Private-input pages are built NON-preprocessed (mirrors the monolithic PAGE in +/// Private-input pages preprocess OFFSET **only** (mirrors the monolithic PAGE in /// `VmAirs::new`): INIT is a committed main-trace column the verifier never recomputes /// from the ELF, so the raw private input is neither bundled nor reconstructed by the -/// verifier. Correctness is enforced by the GlobalMemory bus (the genesis token must -/// telescope into the epochs' reads), not by ELF recomputation. (Not a ZK/hiding claim — -/// the committed column is still opened at STARK query positions.) +/// verifier. (Not a ZK/hiding claim — the committed column is still opened at STARK +/// query positions.) OFFSET, by contrast, is preprocessed like everywhere else: it is +/// program- and input-independent, and it is the row's address, so the GlobalMemory bus +/// alone cannot police it. Leaving it free was a soundness hole — the genesis token +/// could name any address in the page's high-limb space. /// `preprocessed`, when `Some`, is used directly instead of recomputing the /// genesis commitment from `config.init_values` — the recursion guest's /// supplied roots skip the in-VM FFT + Merkle build (see `verify_global`). @@ -236,7 +238,15 @@ fn global_memory_air( EmptyConstraints, ); if config.is_private_input { - return air; + // OFFSET only — see the matching branch in `VmAirs::new`. INIT stays a + // main-trace column (it is the private input); OFFSET must be committed or + // `address_lo = page_base_lo + OFFSET` is prover-chosen and the genesis + // token can name an arbitrary address. GLOBAL_MEMORY's OFFSET column is + // identical to PAGE's, so the same commitment serves both. + return air.with_preprocessed( + page::private_page_preprocessed_commitment(opts), + page::NUM_PREPROCESSED_COLS_PRIVATE, + ); } let commitment = preprocessed.unwrap_or_else(|| { if config.init_values.is_some() { diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 26398acfa..298fbe8a1 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -52,11 +52,12 @@ use crate::tables::trace_builder::count_table_lengths; 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_dma_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_memw_aligned_air, create_memw_register_air, create_mul_air, create_page_air, - create_register_air, create_shift_air, create_store_air, + create_cpu_air, create_cpu32_air, create_decode_air, create_dma_air, create_dma_set_air, + create_dvrm_air, create_ecdas_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, }; // Re-exported for downstream hosts and verifier guests (e.g. the in-VM @@ -82,8 +83,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, dma. -pub const FIXED_TABLE_COUNT: usize = 11; +/// keccak_rc, register, ecsm, ecdas, dma, dma_set, hint. +pub const FIXED_TABLE_COUNT: usize = 13; /// Number of chunks for each split table. /// The verifier needs this to reconstruct matching AIRs. @@ -455,6 +456,10 @@ pub enum Error { /// Recursion host-side helper failed (guest-input encoding or /// commitment recompute — see the `recursion` module). Recursion(String), + /// The proof's `runtime_page_ranges` do not describe a well-formed page + /// layout: unaligned or overflowing base, zero count, more pages than the + /// proof can hold, or two pages covering the same address. + MalformedPageLayout(String), } impl fmt::Display for Error { @@ -484,6 +489,7 @@ impl fmt::Display for Error { ) } Error::Recursion(msg) => write!(f, "recursion helper error: {msg}"), + Error::MalformedPageLayout(msg) => write!(f, "malformed page layout: {msg}"), } } } @@ -518,6 +524,8 @@ pub(crate) struct VmAirs { pub ecsm: VmAir, pub ecdas: VmAir, pub dma: VmAir, + pub dma_set: VmAir, + pub hint: VmAir, pub register: VmAir, pub pages: Vec, pub memw_registers: Vec, @@ -544,6 +552,8 @@ impl VmAirs { (self.ecsm.as_ref(), &mut traces.ecsm, &()), (self.ecdas.as_ref(), &mut traces.ecdas, &()), (self.dma.as_ref(), &mut traces.dma, &()), + (self.dma_set.as_ref(), &mut traces.dma_set, &()), + (self.hint.as_ref(), &mut traces.hint, &()), (self.register.as_ref(), &mut traces.register, &()), ]; if self.include_halt { @@ -619,6 +629,8 @@ impl VmAirs { self.ecsm.as_ref(), self.ecdas.as_ref(), self.dma.as_ref(), + self.dma_set.as_ref(), + self.hint.as_ref(), self.register.as_ref(), ]; if self.include_halt { @@ -707,6 +719,20 @@ impl VmAirs { }) .collect(); let bitwise: VmAir = if minimal_bitwise { + // TEST-ONLY BRANCH — must never be reached in production. + // + // This BITWISE AIR carries NO preprocessed commitment, so its lookup + // table's contents are prover-chosen main trace. BITWISE backs + // `AreBytes` and the byte ALU, so an unpinned table lets a witness + // "prove" that an arbitrary field element is a byte — the same class of + // hole as the private-page `OFFSET` one, and a broader one. It is safe + // today only because every production caller passes `false` + // (`lib.rs` verify/prove paths and `continuation.rs`); the minimal + // BITWISE trace exists for unit tests that build the table by hand. + // + // A fourth call site passing `true` would reintroduce the hole silently, + // so if this branch ever needs to be live, give the minimal table its + // own preprocessed commitment first. Box::new(create_bitwise_air(proof_options)) } else { Box::new(create_bitwise_air(proof_options).with_preprocessed( @@ -777,6 +803,8 @@ impl VmAirs { let ecsm: VmAir = Box::new(create_ecsm_air(proof_options)); let ecdas: VmAir = Box::new(create_ecdas_air(proof_options)); let dma: VmAir = Box::new(create_dma_air(proof_options)); + let dma_set: VmAir = Box::new(create_dma_set_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( @@ -805,10 +833,24 @@ impl VmAirs { .map(|config| -> VmAir { let air = create_page_air(proof_options, config.page_base); if config.is_private_input { - // Private-input pages: all columns are main trace (not preprocessed). - // The verifier doesn't see the init values; correctness is enforced - // by the memory bus constraints. - Box::new(air) + // Private-input pages: INIT holds the private input, so it stays a + // main-trace column the verifier never recomputes. OFFSET does NOT + // get that treatment — it is the row's address + // (`address_lo = page_base_lo + OFFSET`), and nothing else in the + // system constrains it: PAGE has `EmptyConstraints` and no + // constraint references the column. Left uncommitted, a witness can + // point a row at any address sharing the page's high limb and mint a + // second, forged memory history for it — the init/final sets stop + // holding exactly one entry per address, which is the property the + // offline memory-checking argument rests on. + // + // Committing OFFSET alone publishes nothing: it is the dense + // `0..page_size-1` enumeration, byte-identical for every page + // regardless of program or input. + Box::new(air.with_preprocessed( + page::private_page_preprocessed_commitment(proof_options), + page::NUM_PREPROCESSED_COLS_PRIVATE, + )) } else if config.init_values.is_none() { // Zero-init pages: the shared commitment computed once above. Box::new( @@ -884,6 +926,8 @@ impl VmAirs { ecsm, ecdas, dma, + dma_set, + hint, register, pages, memw_registers, @@ -1343,11 +1387,17 @@ fn verify_proof_parts( } } + // `proofs.len()` is the cap: every page config needs its own sub-proof, so a + // layout wanting more pages than the proof carries can never verify. Passing it + // here makes the rejection happen before the configs are allocated — the + // `expected_proof_count` check below runs too late to stop a `count: u64::MAX` + // range from exhausting memory first. let page_configs = Traces::page_configs_from_elf_and_runtime( program, runtime_page_ranges, num_private_input_pages, - ); + proofs.len(), + )?; // Cross-check: table_counts must match the number of sub-proofs. // FIXED_TABLE_COUNT always-present tables, plus page tables. diff --git a/prover/src/tables/cpu.rs b/prover/src/tables/cpu.rs index 88d0bf041..e430d38fa 100644 --- a/prover/src/tables/cpu.rs +++ b/prover/src/tables/cpu.rs @@ -191,6 +191,14 @@ pub struct CpuOperation { /// Whether this ECALL is a DMA memcpy. Operands are recovered from x10/x11/x12. pub ecall_dma_memcpy: bool, + + /// Whether this ECALL is a DMA memset. Operands are recovered from x10/x11/x12. + pub ecall_dma_memset: 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 { @@ -240,6 +248,10 @@ impl CpuOperation { f.ecall && log.src1_val == executor::vm::instruction::execution::ECSM_SYSCALL_NUMBER; let ecall_dma_memcpy = f.ecall && log.src1_val == executor::vm::instruction::execution::DMA_MEMCPY_SYSCALL_NUMBER; + let ecall_dma_memset = f.ecall + && log.src1_val == executor::vm::instruction::execution::DMA_MEMSET_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 @@ -359,6 +371,8 @@ impl CpuOperation { keccak_state_addr, ecall_ecsm, ecall_dma_memcpy, + ecall_dma_memset, + ecall_hint, } } diff --git a/prover/src/tables/dma_set.rs b/prover/src/tables/dma_set.rs new file mode 100644 index 000000000..da30dc704 --- /dev/null +++ b/prover/src/tables/dma_set.rs @@ -0,0 +1,453 @@ +//! DMA memset table — proves a `memset(dst, fill, n)` off the CPU execution trace. +//! +//! The guest's strong `memset` symbol (see `syscalls/src/syscalls.rs`) dispatches +//! bulk fills to the DMA memset ecall (`DMA_MEMSET_SYSCALL_NUMBER`); this table +//! proves the fill so the per-byte store loop leaves the CPU trace. +//! +//! Same streaming shape as the memcpy table (`dma.rs`): a row writes eight bytes +//! while `count >= 8`, otherwise one byte, and rows chain through `DmaSetNext` +//! until a terminal row where `count == 0`. The LT table pins that choice, so the +//! prover cannot select a convenient partition. +//! +//! Two things make this cheaper than memcpy rather than a copy of it: +//! +//! * **No source.** There is nothing to read, so a row emits one MEMW *write* at +//! `T+1` and no read at all — half the memory traffic per byte. There is also +//! no `src`/`src_incr` pair to carry or range-check. +//! * **No value lanes.** Every byte written is the same constant, so one `fill` +//! column replaces memcpy's eight value columns. `fill_wide` is `fill` on +//! eight-byte rows and zero on one-byte tail rows, which is what lets the same +//! write tuple serve both widths without per-lane constraints. +//! +//! The result is 20 columns against memcpy's 32, and 19 bus interactions against +//! 23. `fill <= 255` is proven on the first row, mirroring how `dma.rs` proves +//! the per-ecall byte bound: the executor rejects a wider value, so an honest +//! guest (whose stub masks `a1`) never trips it. +//! +//! ## Columns (20 total) +//! - `timestamp`: DWordWL (2) — the ECALL timestamp +//! - `dst`: DWordWL (2) — current destination byte address +//! - `dst_incr`: DWordHL (4) — dst + selected width +//! - `count`: DWordWL (2) — remaining byte count (including this byte; 0 on the end row) +//! - `count_decr`: DWordHL (4) — count - width (all 0xFFFF when count == 0) +//! - `fill`: byte being written +//! - `fill_wide`: `fill` on eight-byte rows, 0 on one-byte tail rows +//! - `first`: Bit — first row of a fill +//! - `end`: Bit — last row (count was 0) +//! - `tail`: Bit — `count < 8`; selects a 1-byte rather than 8-byte row +//! - `mu`: Bit — multiplicity (1 real, 0 padding) +use stark::constraints::builder::{ConstraintBuilder, ConstraintSet}; +use stark::lookup::{BusInteraction, BusValue, LinearTerm, Multiplicity, Packing}; +use stark::trace::TraceTable; + +use crate::constraints::templates::{ + AddLinearTerm, AddOperand, emit_add_pair, emit_add_pair_no_overflow, emit_is_bit, +}; + +use executor::vm::instruction::execution::{ + DMA_MEMCPY_MAX_BYTES as EXECUTOR_DMA_MEMCPY_MAX_BYTES, + DMA_MEMSET_MAX_FILL as EXECUTOR_DMA_MEMSET_MAX_FILL, DMA_MEMSET_SYSCALL_NUMBER, +}; + +use super::types::{BusId, FE, GoldilocksExtension, GoldilocksField, VmTable, alu_op}; + +/// DMA memset syscall value, split into 32-bit limbs for the Ecall bus. +const DMA_MEMSET_LO32: u64 = DMA_MEMSET_SYSCALL_NUMBER & 0xFFFF_FFFF; +const DMA_MEMSET_HI32: u64 = DMA_MEMSET_SYSCALL_NUMBER >> 32; +/// Per-ecall byte bound, shared with memcpy so both stubs chunk identically. +pub const DMA_MEMSET_MAX_BYTES: u64 = EXECUTOR_DMA_MEMCPY_MAX_BYTES; +/// Largest accepted fill value, taken from the executor so the bound the AIR +/// proves cannot drift from the bound execution enforces. +pub const DMA_MEMSET_MAX_FILL: u64 = EXECUTOR_DMA_MEMSET_MAX_FILL; + +pub mod cols { + pub const TIMESTAMP_0: usize = 0; + pub const TIMESTAMP_1: usize = 1; + + pub const DST_0: usize = 2; + pub const DST_1: usize = 3; + + pub const DST_INCR_0: usize = 4; + pub const DST_INCR_1: usize = 5; + pub const DST_INCR_2: usize = 6; + pub const DST_INCR_3: usize = 7; + + pub const COUNT_0: usize = 8; + pub const COUNT_1: usize = 9; + + pub const COUNT_DECR_0: usize = 10; + pub const COUNT_DECR_1: usize = 11; + pub const COUNT_DECR_2: usize = 12; + pub const COUNT_DECR_3: usize = 13; + + pub const FILL: usize = 14; + pub const FILL_WIDE: usize = 15; + + pub const FIRST: usize = 16; + pub const END: usize = 17; + pub const TAIL: usize = 18; + pub const MU: usize = 19; + + pub const NUM_COLUMNS: usize = 20; +} + +/// One row of the DMA memset table: eight bytes, one tail byte, or the terminal row. +#[derive(Debug, Clone)] +pub struct DmaSetOperation { + pub timestamp: u64, + pub dst: u64, + /// Remaining byte count (including this byte; 0 on the end row). + pub count: u64, + pub fill: u8, + pub first: bool, + pub end: bool, +} + +/// Generates the DMA memset trace. One row per operation; padded to the next +/// power of two (min 4). Padding rows model an inactive one-byte step so the +/// unconditional `count_decr + step == count` relation still holds. +pub fn generate_dma_set_trace( + ops: &[DmaSetOperation], +) -> TraceTable { + let n = ops.len(); + let num_rows = n.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_idx, op) in ops.iter().enumerate() { + let tail = op.count < 8; + let width = if tail { 1 } else { 8 }; + table.set_dword_wl(row_idx, cols::TIMESTAMP_0, op.timestamp); + + table.set_dword_wl(row_idx, cols::DST_0, op.dst); + table.set_dword_hl(row_idx, cols::DST_INCR_0, op.dst.wrapping_add(width)); + + table.set_dword_wl(row_idx, cols::COUNT_0, op.count); + table.set_dword_hl(row_idx, cols::COUNT_DECR_0, op.count.wrapping_sub(width)); + + table.set_byte(row_idx, cols::FILL, op.fill); + // Zero on tail rows so the shared write tuple narrows to a single byte. + table.set_byte(row_idx, cols::FILL_WIDE, if tail { 0 } else { op.fill }); + + table.set_bool(row_idx, cols::FIRST, op.first); + table.set_bool(row_idx, cols::END, op.end); + table.set_bool(row_idx, cols::TAIL, tail); + table.set_fe(row_idx, cols::MU, FE::one()); + } + + for row_idx in n..num_rows { + table.set_fe(row_idx, cols::COUNT_0, FE::one()); + table.set_fe(row_idx, cols::DST_INCR_0, FE::one()); + table.set_fe(row_idx, cols::TAIL, FE::one()); + } + + trace +} + +/// Helper: a MEMW register read (CO24, is_register=1, width2), value == old == the +/// register's two 32-bit limbs. Binds `x{reg}` to `(lo_col, hi_col)` at the ecall ts. +fn memw_register_read(reg_addr: u64, lo_col: usize, hi_col: usize) -> Vec { + let limb = |c: usize| BusValue::Packed { + start_column: c, + packing: Packing::Direct, + }; + vec![ + limb(lo_col), + limb(hi_col), + BusValue::constant(0), + BusValue::constant(0), + BusValue::constant(0), + BusValue::constant(0), + BusValue::constant(0), + BusValue::constant(0), + BusValue::constant(1), // is_register = 1 + BusValue::constant(reg_addr), // base_address lo = 2*reg + BusValue::constant(0), // base_address hi + limb(lo_col), + limb(hi_col), + BusValue::constant(0), + BusValue::constant(0), + BusValue::constant(0), + BusValue::constant(0), + BusValue::constant(0), + BusValue::constant(0), + limb(cols::TIMESTAMP_0), + limb(cols::TIMESTAMP_1), + BusValue::constant(1), // w2 = 1 (register = 2 words) + BusValue::constant(0), + BusValue::constant(0), + ] +} + +/// An `IsHalfword` range-check sender for one halfword column (mult = mu). +fn halfword(column: usize) -> BusInteraction { + BusInteraction::sender( + BusId::IsHalfword, + Multiplicity::Column(cols::MU), + vec![BusValue::Packed { + start_column: column, + packing: Packing::Direct, + }], + ) +} + +/// DMA memset bus interactions (19 total). +pub fn bus_interactions() -> Vec { + let mu_minus_end = Multiplicity::Diff(cols::MU, cols::END); + let mu_minus_first = Multiplicity::Diff(cols::MU, cols::FIRST); + let direct = |c: usize| BusValue::Packed { + start_column: c, + packing: Packing::Direct, + }; + + vec![ + // 1. Receive ECALL from CPU (mult = first). + BusInteraction::receiver( + BusId::Ecall, + Multiplicity::Column(cols::FIRST), + vec![ + direct(cols::TIMESTAMP_0), + direct(cols::TIMESTAMP_1), + BusValue::constant(DMA_MEMSET_LO32), + BusValue::constant(DMA_MEMSET_HI32), + ], + ), + // 2. Send to DmaSetNext (mult = mu - end): [ts, dst_incr, count_decr, fill]. + // `fill` rides the chain so every row of one call writes the same byte. + BusInteraction::sender( + BusId::DmaSetNext, + mu_minus_end.clone(), + vec![ + direct(cols::TIMESTAMP_0), + direct(cols::TIMESTAMP_1), + BusValue::Packed { + start_column: cols::DST_INCR_0, + packing: Packing::DWordHL, + }, + BusValue::Packed { + start_column: cols::COUNT_DECR_0, + packing: Packing::DWordHL, + }, + direct(cols::FILL), + ], + ), + // 3. Receive from DmaSetNext (mult = mu - first): [ts, dst, count, fill]. + BusInteraction::receiver( + BusId::DmaSetNext, + mu_minus_first, + vec![ + direct(cols::TIMESTAMP_0), + direct(cols::TIMESTAMP_1), + BusValue::Packed { + start_column: cols::DST_0, + packing: Packing::DWordWL, + }, + BusValue::Packed { + start_column: cols::COUNT_0, + packing: Packing::DWordWL, + }, + direct(cols::FILL), + ], + ), + // 4-7. IsHalfword: count_decr (mult = mu). + halfword(cols::COUNT_DECR_0), + halfword(cols::COUNT_DECR_1), + halfword(cols::COUNT_DECR_2), + halfword(cols::COUNT_DECR_3), + // 8-11. IsHalfword: dst_incr (mult = mu). + halfword(cols::DST_INCR_0), + halfword(cols::DST_INCR_1), + halfword(cols::DST_INCR_2), + halfword(cols::DST_INCR_3), + // 12. ZERO bus end detection: end == 1 iff all count_decr halfwords are 0xFFFF. + BusInteraction::sender( + BusId::Zero, + Multiplicity::Column(cols::MU), + vec![ + BusValue::linear(vec![ + LinearTerm::Constant(4 * 65535), + LinearTerm::Column { + coefficient: -1, + column: cols::COUNT_DECR_0, + }, + LinearTerm::Column { + coefficient: -1, + column: cols::COUNT_DECR_1, + }, + LinearTerm::Column { + coefficient: -1, + column: cols::COUNT_DECR_2, + }, + LinearTerm::Column { + coefficient: -1, + column: cols::COUNT_DECR_3, + }, + ]), + direct(cols::END), + ], + ), + // 13-15. Register reads (mult = first): x10 = dst, x11 = fill, x12 = count. + // x11's high limb is pinned to 0 by the constant below, so a fill wider + // than 32 bits cannot be smuggled past the `fill <= 255` check. + BusInteraction::sender( + BusId::Memw, + Multiplicity::Column(cols::FIRST), + memw_register_read(20, cols::DST_0, cols::DST_1), + ), + BusInteraction::sender(BusId::Memw, Multiplicity::Column(cols::FIRST), { + let mut tuple = memw_register_read(22, cols::FILL, cols::FILL); + // x11 = (fill, 0): overwrite both high-limb slots with the constant 0. + tuple[1] = BusValue::constant(0); + tuple[12] = BusValue::constant(0); + tuple + }), + BusInteraction::sender( + BusId::Memw, + Multiplicity::Column(cols::FIRST), + memw_register_read(24, cols::COUNT_0, cols::COUNT_1), + ), + // 16. ALU LT pins `tail = (count < 8)`. + BusInteraction::sender( + BusId::Alu, + Multiplicity::Column(cols::MU), + vec![ + BusValue::Packed { + start_column: cols::COUNT_0, + packing: Packing::DWordWL, + }, + BusValue::constant(8), + BusValue::constant(0), + BusValue::constant(alu_op::LT as u64), + direct(cols::TAIL), + BusValue::constant(0), + ], + ), + // 17. The first row proves `count <= DMA_MEMSET_MAX_BYTES`. + BusInteraction::sender( + BusId::Alu, + Multiplicity::Column(cols::FIRST), + vec![ + BusValue::Packed { + start_column: cols::COUNT_0, + packing: Packing::DWordWL, + }, + BusValue::constant(DMA_MEMSET_MAX_BYTES + 1), + BusValue::constant(0), + BusValue::constant(alu_op::LT as u64), + BusValue::constant(1), + BusValue::constant(0), + ], + ), + // 18. The first row proves `fill <= DMA_MEMSET_MAX_FILL`, so the byte the + // write tuple broadcasts really is a byte. + BusInteraction::sender( + BusId::Alu, + Multiplicity::Column(cols::FIRST), + vec![ + // The ALU bus takes its left operand as two 32-bit limbs; `fill` + // is a single byte column, so the high limb is a literal zero. + direct(cols::FILL), + BusValue::constant(0), + BusValue::constant(DMA_MEMSET_MAX_FILL + 1), + BusValue::constant(0), + BusValue::constant(alu_op::LT as u64), + BusValue::constant(1), + BusValue::constant(0), + ], + ), + // 19. MEMW write to dst at T+1. `w8 = 1-tail`; lanes 1..7 carry `fill_wide`, + // which the constraints force to 0 exactly on one-byte tail rows. + BusInteraction::sender(BusId::Memw, mu_minus_end, { + let mut tuple = Vec::with_capacity(16); + tuple.push(BusValue::constant(0)); // is_register + tuple.push(direct(cols::DST_0)); + tuple.push(direct(cols::DST_1)); + tuple.push(direct(cols::FILL)); + for _ in 1..8 { + tuple.push(direct(cols::FILL_WIDE)); + } + tuple.push(BusValue::linear(vec![ + LinearTerm::Constant(1), + LinearTerm::Column { + coefficient: 1, + column: cols::TIMESTAMP_0, + }, + ])); + tuple.push(direct(cols::TIMESTAMP_1)); + tuple.push(BusValue::constant(0)); // w2 + tuple.push(BusValue::constant(0)); // w4 + tuple.push(BusValue::linear(vec![ + LinearTerm::Constant(1), + LinearTerm::Column { + coefficient: -1, + column: cols::TAIL, + }, + ])); // w8 = 1-tail + tuple + }), + ] +} + +/// The DMA memset constraints: +/// - bitness for `first`, `end`, `tail`, `mu`; +/// - active first/end rows; +/// - `step = 8 - 7*tail` address/count arithmetic; +/// - `fill_wide` equals `fill` on wide rows and 0 on tail rows. +#[derive(Clone, Copy)] +pub struct DmaSetConstraints; + +impl ConstraintSet for DmaSetConstraints { + fn eval>(&self, b: &mut B) { + emit_is_bit(b, 0, cols::FIRST, None); + emit_is_bit(b, 1, cols::END, None); + emit_is_bit(b, 2, cols::TAIL, None); + emit_is_bit(b, 3, cols::MU, None); + + let one = b.one(); + let first = b.main(0, cols::FIRST); + let end = b.main(0, cols::END); + let mu = b.main(0, cols::MU); + b.emit_base(4, (first + end) * (one.clone() - mu)); + + let step = AddOperand::linear( + &[ + AddLinearTerm::Constant(8), + AddLinearTerm::Column { + coefficient: -7, + column: cols::TAIL, + }, + ], + &[], + ); + + emit_add_pair_no_overflow( + b, + 5, + cols::MU, + cols::END, + &AddOperand::dword(cols::DST_0), + &step, + &AddOperand::from_dword_hl(cols::DST_INCR_0), + ); + emit_add_pair( + b, + 7, + &[], + &AddOperand::from_dword_hl(cols::COUNT_DECR_0), + &step, + &AddOperand::dword(cols::COUNT_0), + ); + + // fill_wide == (1 - tail) * fill, expressed as the two cases so the + // degree stays at 2: zero on tail rows, equal to fill otherwise. + let tail = b.main(0, cols::TAIL); + let fill = b.main(0, cols::FILL); + let fill_wide = b.main(0, cols::FILL_WIDE); + b.emit_base(9, tail.clone() * fill_wide.clone()); + b.emit_base(10, (one - tail) * (fill_wide - fill)); + } +} diff --git a/prover/src/tables/hint.rs b/prover/src/tables/hint.rs new file mode 100644 index 000000000..cb1dab9f3 --- /dev/null +++ b/prover/src/tables/hint.rs @@ -0,0 +1,373 @@ +//! 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 +//! +//! Both address low limbs are range-checked against [`HINT_ADDR_LIMB_BOUND`]; see that +//! constant for why the memory bus alone does not bound `out_addr` tightly enough. + +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). Re-exported from the executor, which const-asserts that the bound and its +/// `is_valid_hint_selector` set coincide — so the AIR's range-check cannot drift from the +/// set the executor accepts. +pub use executor::vm::instruction::execution::HINT_SELECTOR_BOUND; + +/// Bound the low 32-bit limb of `in_addr` and `out_addr` must stay under so the +/// ecall's 32-byte range (`+0..+31`) cannot straddle the 2^32 limb boundary. Mirrors +/// the executor's `addr_limb_ok(addr, 31)`: `(addr % 2^32) + 31 < 2^32`, i.e. the +/// largest accepted limb is `2^32 - 32`. +/// +/// Both operands need this explicitly. `in_addr` because it is not on the memory bus +/// at all (the input read is not modelled). `out_addr` because the bus bounds it only +/// to `2^32 - 25`: the write bases are `out_addr_lo + 8i`, so the largest one +/// (`+24`) stops being a canonical limb at `2^32 - 24`, while MEMW's `carry` +/// columns resolve the *bytes* past it correctly. That left a seven-value window +/// (`2^32-31 ..= 2^32-25`) the AIR accepted and the executor rejected with +/// `HintAddressOverflow` — a prover could prove a hint call the VM halts on. +pub const HINT_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`, ×3): assert `selector < 3` and that both +/// `in_addr`'s and `out_addr`'s low limbs are `< 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(27); + + // 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 and out_addr's low limbs < 2^32 - 31, matching addr_limb_ok(addr, 31). + // The lhs high word is a literal 0, so only the low limb is compared — exactly the + // executor's check, which ignores the high limb. `out_addr` needs its own check even + // though it is on the memory bus: the bus only bounds it to 2^32 - 25 (see + // HINT_ADDR_LIMB_BOUND), leaving a window the executor rejects. + for addr_lo in [cols::ADDR_IN_0, cols::ADDR_OUT_0] { + out.push(BusInteraction::sender( + BusId::Alu, + mu(), + vec![ + packed(addr_lo), + BusValue::constant(0), + BusValue::constant(HINT_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 three register reads, the three `LT` range-checks, 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. This is load-bearing, +/// not a redundant restatement of a bus check: the `Ecall` bus pins only the *sum* +/// of `mu` over the rows sharing a tuple — see the module-level docs for the +/// spread-multiplicity witness it rules out. +#[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 2f78ec872..ff367b853 100644 --- a/prover/src/tables/mod.rs +++ b/prover/src/tables/mod.rs @@ -29,12 +29,14 @@ pub mod cpu; pub mod cpu32; pub mod decode; pub mod dma; +pub mod dma_set; pub mod dvrm; pub mod ecdas; 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/page.rs b/prover/src/tables/page.rs index 059ffff3b..6788bee08 100644 --- a/prover/src/tables/page.rs +++ b/prover/src/tables/page.rs @@ -84,6 +84,16 @@ pub mod cols { /// For zero-init pages, INIT is also preprocessed (constant 0). pub const NUM_PREPROCESSED_COLS: usize = 2; +/// Number of preprocessed columns for a **private-input** page: OFFSET only. +/// +/// INIT holds the private input, so it stays a main-trace column the verifier +/// never recomputes. OFFSET must still be preprocessed — it is the row's +/// address (`address_lo = page_base_lo + OFFSET`), and leaving it prover-chosen +/// lets a witness point a row at any address in the page's high-limb space and +/// forge that address's memory history. Preprocessing covers columns `0..n`, and +/// OFFSET is column 0, so `n = 1` isolates exactly the right one. +pub const NUM_PREPROCESSED_COLS_PRIVATE: usize = 1; + // ========================================================================= // Types // ========================================================================= @@ -419,6 +429,32 @@ pub(crate) fn static_zero_page_commitment(blowup_factor: u8) -> Option Option { + match blowup_factor { + 2 => Some([ + 0x4a, 0x36, 0x1a, 0x29, 0x02, 0xc8, 0x21, 0x8e, 0xc0, 0xfd, 0x6d, 0xbe, 0xb3, 0x5f, + 0x70, 0x54, 0xcb, 0xa3, 0xa7, 0x8c, 0xa2, 0x37, 0xdc, 0xa3, 0x51, 0x29, 0xd8, 0xb8, + 0x94, 0x2d, 0x91, 0x3d, + ]), + 4 => Some([ + 0xa6, 0x53, 0x01, 0xd0, 0x2f, 0x47, 0xca, 0xe8, 0x7a, 0xbd, 0xb7, 0x14, 0x69, 0x28, + 0xaf, 0x67, 0xc9, 0xe5, 0x2d, 0xd6, 0x41, 0x5f, 0x76, 0xd8, 0xc4, 0x59, 0xdd, 0xaa, + 0xd2, 0x32, 0x1f, 0x6f, + ]), + 8 => Some([ + 0xe7, 0x13, 0xe3, 0x59, 0xd6, 0xa5, 0xb9, 0xd5, 0xfa, 0xcb, 0x51, 0x8a, 0x42, 0x52, + 0xaa, 0x25, 0xf9, 0x0d, 0x94, 0xf5, 0xdf, 0x93, 0x56, 0x63, 0x77, 0x2c, 0x08, 0x75, + 0xb7, 0x68, 0xb0, 0x57, + ]), + _ => None, + } +} + /// Computes the Merkle root commitment over the LDE of PAGE precomputed columns. /// /// The commitment covers OFFSET (0..page_size-1) and INIT (from config). @@ -454,8 +490,19 @@ pub fn compute_precomputed_commitment(config: &PageConfig, options: &ProofOption init_col[i] = FE::from(init_byte as u64); } - let columns = [offset_col, init_col]; + commit_preprocessed_columns(&[offset_col, init_col], num_rows, options) +} +/// LDE + Merkle-commit a set of preprocessed PAGE columns. Shared by +/// [`compute_precomputed_commitment`] (OFFSET+INIT) and +/// [`compute_offset_only_commitment`] (OFFSET alone) so both go through an +/// identical pipeline — the two commitments must be built the same way or the +/// verifier's recomputation would not match the prover's tree. +fn commit_preprocessed_columns( + columns: &[Vec], + num_rows: usize, + options: &ProofOptions, +) -> Commitment { let polys: Vec> = columns .iter() .map(|col| { @@ -479,6 +526,28 @@ pub fn compute_precomputed_commitment(config: &PageConfig, options: &ProofOption root } +/// Commitment over the OFFSET column **alone** — the preprocessed anchor for +/// private-input pages. +/// +/// A private page's INIT holds the private input, which the verifier must not +/// be able to recompute, so it cannot be preprocessed. OFFSET carries no such +/// constraint: it is the dense enumeration `0..page_size-1`, byte-identical for +/// every page of a given size regardless of program *or* input. Committing it +/// on its own binds the one column that must not be prover-chosen while +/// publishing nothing about the input. +/// +/// This is what stops a malicious prover repointing a private page's rows: the +/// Memory-bus address is `page_base_lo + OFFSET`, so a free OFFSET names an +/// arbitrary address and forges that address's memory history. +pub fn compute_offset_only_commitment(options: &ProofOptions) -> Commitment { + let num_rows = DEFAULT_PAGE_SIZE; + let mut offset_col = crate::tables::types::zeroed_fe_vec(num_rows); + for (i, cell) in offset_col.iter_mut().enumerate() { + *cell = FE::from(i as u64); + } + commit_preprocessed_columns(&[offset_col], num_rows, options) +} + /// Returns the zero-init PAGE preprocessed commitment. /// /// Looks up `blowup_factor` in [`static_zero_page_commitment`] when @@ -504,6 +573,29 @@ pub fn zero_init_preprocessed_commitment(options: &ProofOptions) -> Commitment { compute_precomputed_commitment(&PageConfig::zero_init(0), options) } +/// Returns the private-input PAGE preprocessed commitment (OFFSET only). +/// +/// Same static-then-recompute shape as [`zero_init_preprocessed_commitment`]. +/// Because OFFSET depends on neither the program nor the input, one value per +/// `blowup_factor` covers every private page in the system — and the same value +/// serves GLOBAL_MEMORY, whose OFFSET column is identical. +pub fn private_page_preprocessed_commitment(options: &ProofOptions) -> Commitment { + if options.coset_offset == 3 + && let Some(commitment) = static_private_page_commitment(options.blowup_factor) + { + return commitment; + } + log::warn!( + "private-input page preprocessed commitment not static for \ + (blowup={}, coset={}); falling back to recompute. Add a match \ + arm to `static_private_page_commitment` by running \ + `cargo run --bin compute_static_commitments --release`.", + options.blowup_factor, + options.coset_offset, + ); + compute_offset_only_commitment(options) +} + // ========================================================================= // Bus interactions // ========================================================================= diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index cc1484148..3c7a99328 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -47,11 +47,13 @@ use super::cpu::{self, CpuOperation}; use super::cpu32; use super::decode; use super::dma; +use super::dma_set; use super::dvrm::{self, DvrmOperation}; 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}; @@ -551,6 +553,8 @@ fn collect_ops_from_cpu( Vec, 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); @@ -563,6 +567,8 @@ fn collect_ops_from_cpu( let mut ecsm_ops = Vec::new(); let mut ecdas_ops = Vec::new(); let mut dma_ops = Vec::new(); + let mut dma_set_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 @@ -665,6 +671,22 @@ fn collect_ops_from_cpu( dma_ops.extend(rows); } + // DMA memset: authenticate x10/x11/x12, then write every destination byte + // at T+1. There is no source phase — every byte written is the same + // constant, so no snapshot is needed and overlap cannot arise. + if op.ecall_dma_memset { + let (memset_memw, rows) = collect_dma_memset_ops(op, memory_state, register_state); + memw.extend_ops(memset_memw); + dma_set_ops.extend(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 @@ -721,6 +743,8 @@ fn collect_ops_from_cpu( ecsm_ops, ecdas_ops, dma_ops, + dma_set_ops, + hint_ops, ) } @@ -1069,6 +1093,104 @@ fn collect_dma_memcpy_ops( (memw_ops, rows) } +/// Replays one DMA memset ecall. +/// +/// Register operands are read at `T`; every destination chunk is written at +/// `T+1`. Chunks are eight bytes while `remaining >= 8`, then one byte per tail +/// row, matching the row schedule the DMA_SET AIR pins through the LT table. +fn collect_dma_memset_ops( + op: &CpuOperation, + memory_state: &mut MemoryState, + register_state: &mut RegisterState, +) -> (Vec, Vec) { + let t = op.timestamp; + let dst = register_state.read(10).0; + let fill = register_state.read(11).0; + let count = register_state.read(12).0; + assert!( + count <= dma_set::DMA_MEMSET_MAX_BYTES, + "successful DMA memset ecall must respect the per-call chunk bound" + ); + assert!( + fill <= dma_set::DMA_MEMSET_MAX_FILL, + "successful DMA memset ecall must carry a byte-sized fill" + ); + let fill_byte = fill as u8; + + let data_rows = count / 8 + count % 8; + let capacity = usize::try_from(data_rows) + .ok() + .and_then(|n| n.checked_add(3)) + .expect("successful DMA memset execution must fit host address space"); + let mut memw_ops = Vec::with_capacity(capacity); + + // Bind the ecall's three argument registers to the first DMA_SET row. + for (reg, value) in [(10u8, dst), (11u8, fill), (12u8, count)] { + let packed = pack_register_value(value); + let (_old_value, old_ts) = register_state.read(reg); + memw_ops.push( + MemwOperation::new(true, 2 * reg as u64, packed, t, 2, true) + .with_old(packed, [old_ts, old_ts, 0, 0, 0, 0, 0, 0]), + ); + register_state.write(reg, value, t); + } + + let rows_capacity = usize::try_from(data_rows + 1) + .expect("successful DMA memset execution must fit host address space"); + let mut rows = Vec::with_capacity(rows_capacity); + let mut offset = 0u64; + let mut remaining = count; + let mut first = true; + + while remaining != 0 { + let width = if remaining >= 8 { 8u8 } else { 1u8 }; + let destination_addr = dst + .checked_add(offset) + .expect("DMA memset range was validated by executor"); + // Only the lanes actually written carry the fill; the rest stay zero so + // this matches the AIR, which sends `fill` in lane 0 and `fill_wide` + // (zero on one-byte tail rows) in lanes 1..7. + let mut value = [0u32; 8]; + for lane in value.iter_mut().take(width as usize) { + *lane = fill_byte as u32; + } + let (old_values, old_timestamps) = + memory_state.read_bytes(destination_addr, width as usize); + memw_ops.push( + MemwOperation::new(false, destination_addr, value, t + 1, width, false) + .with_old(old_values, old_timestamps), + ); + let dword = u64::from_le_bytes([fill_byte; 8]); + memory_state.write_bytes(destination_addr, dword, width as usize, t + 1); + + rows.push(dma_set::DmaSetOperation { + timestamp: t, + dst: destination_addr, + count: remaining, + fill: fill_byte, + first, + end: false, + }); + + first = false; + offset += u64::from(width); + remaining -= width as u64; + } + + rows.push(dma_set::DmaSetOperation { + timestamp: t, + dst: dst + .checked_add(count) + .expect("DMA memset range was validated by executor"), + count: 0, + fill: fill_byte, + first, + end: true, + }); + + (memw_ops, rows) +} + /// Sizing-pass replay of one bounded DMA ecall. /// /// This mirrors [`collect_dma_memcpy_ops`] but counts rows and routes each @@ -1166,6 +1288,148 @@ fn replay_dma_memcpy_for_sizing( snapshot_count + 1 } +/// Sizing-pass replay of one bounded DMA memset ecall. +/// +/// Mirrors [`collect_dma_memset_ops`] but counts rows and routes each +/// `MemwOperation` immediately instead of allocating vectors. No snapshot buffer +/// is needed: memset writes a constant, so there is no source to preserve. +#[cfg(feature = "disk-spill")] +fn replay_dma_memset_for_sizing( + op: &CpuOperation, + memory_state: &mut MemoryState, + register_state: &mut RegisterState, + mut visit_memw: impl FnMut(&MemwOperation), +) -> usize { + let t = op.timestamp; + let dst = register_state.read(10).0; + let fill = register_state.read(11).0; + let count = register_state.read(12).0; + assert!( + count <= dma_set::DMA_MEMSET_MAX_BYTES, + "successful DMA memset ecall must respect the per-call chunk bound" + ); + assert!( + fill <= dma_set::DMA_MEMSET_MAX_FILL, + "successful DMA memset ecall must carry a byte-sized fill" + ); + let fill_byte = fill as u8; + + for (reg, value) in [(10u8, dst), (11u8, fill), (12u8, count)] { + let packed = pack_register_value(value); + let (_old_value, old_ts) = register_state.read(reg); + let memw = MemwOperation::new(true, 2 * reg as u64, packed, t, 2, true) + .with_old(packed, [old_ts, old_ts, 0, 0, 0, 0, 0, 0]); + visit_memw(&memw); + register_state.write(reg, value, t); + } + + let mut rows = 0usize; + let mut offset = 0u64; + let mut remaining = count; + let dword = u64::from_le_bytes([fill_byte; 8]); + + while remaining != 0 { + let width = if remaining >= 8 { 8u8 } else { 1u8 }; + let destination_addr = dst + .checked_add(offset) + .expect("DMA memset range was validated by executor"); + // Only the lanes actually written carry the fill; the rest stay zero so + // this matches the AIR, which sends `fill` in lane 0 and `fill_wide` + // (zero on one-byte tail rows) in lanes 1..7. + let mut value = [0u32; 8]; + for lane in value.iter_mut().take(width as usize) { + *lane = fill_byte as u32; + } + let (old_values, old_timestamps) = + memory_state.read_bytes(destination_addr, width as usize); + let memw = MemwOperation::new(false, destination_addr, value, t + 1, width, false) + .with_old(old_values, old_timestamps); + visit_memw(&memw); + memory_state.write_bytes(destination_addr, dword, width as usize, t + 1); + + rows += 1; + offset += u64::from(width); + remaining -= u64::from(width); + } + + rows + 1 +} + +/// 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( @@ -2466,6 +2730,36 @@ fn collect_bitwise_from_commit(commit_ops: &[CommitOperation]) -> Vec Vec { + let mut lookups = Vec::with_capacity(ops.len() * 9); + for op in ops { + let width = if op.count < 8 { 1 } else { 8 }; + let count_decr = op.count.wrapping_sub(width); + let dst_incr = op.dst.wrapping_add(width); + + for value in [count_decr, dst_incr] { + for shift in [0, 16, 32, 48] { + let half = ((value >> shift) & 0xFFFF) as u16; + lookups.push(BitwiseOperation::halfword( + BitwiseOperationType::IsHalf, + (half & 0xFF) as u8, + (half >> 8) as u8, + )); + } + } + + let halves = [ + (count_decr & 0xFFFF) as u32, + ((count_decr >> 16) & 0xFFFF) as u32, + ((count_decr >> 32) & 0xFFFF) as u32, + ((count_decr >> 48) & 0xFFFF) as u32, + ]; + let zero_input = halves.into_iter().map(|half| 65535 - half).sum(); + lookups.push(BitwiseOperation::zero(zero_input)); + } + lookups +} + fn collect_bitwise_from_dma(dma_ops: &[dma::DmaOperation]) -> Vec { let mut lookups = Vec::with_capacity(dma_ops.len() * 13); for op in dma_ops { @@ -2497,6 +2791,23 @@ fn collect_bitwise_from_dma(dma_ops: &[dma::DmaOperation]) -> 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 // ============================================================================= @@ -3019,6 +3330,12 @@ pub struct Traces { /// DMA memcpy table (eight-byte body rows plus byte tail rows). pub dma: TraceTable, + /// DMA memset table (eight-byte body rows plus byte tail rows). + pub dma_set: 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 @@ -3063,6 +3380,10 @@ struct CollectedOps { ecdas_ops: Vec, // DMA memcpy rows (eight bytes per body row, byte tail, plus terminal rows). dma_ops: Vec, + // DMA memset rows (same schedule; one fill byte instead of eight value lanes). + dma_set_ops: Vec, + // Non-constraining hint ecall. + hint_ops: Vec, } /// Chunk raw ops and generate one trace table per chunk. When `storage_mode` @@ -3118,6 +3439,8 @@ fn collect_all_ops( ecsm_ops: Vec, ecdas_ops: Vec, dma_ops: Vec, + dma_set_ops: Vec, + hint_ops: Vec, register_state: &mut RegisterState, is_final: bool, ) -> CollectedOps { @@ -3261,6 +3584,8 @@ fn collect_all_ops( ecsm_ops, ecdas_ops, dma_ops, + dma_set_ops, + hint_ops, } } @@ -3305,6 +3630,8 @@ fn build_traces( ecsm_ops, ecdas_ops, dma_ops, + dma_set_ops, + hint_ops, } = ops; // ===================================================================== @@ -3323,6 +3650,27 @@ fn build_traces( .filter(|op| op.first) .map(|op| LtOperation::new(op.count, dma::DMA_MEMCPY_MAX_BYTES + 1, false)), ); + lt_ops.extend( + dma_set_ops + .iter() + .map(|op| LtOperation::new(op.count, 8, false)), + ); + lt_ops.extend(dma_set_ops.iter().filter(|op| op.first).flat_map(|op| { + [ + LtOperation::new(op.count, dma_set::DMA_MEMSET_MAX_BYTES + 1, false), + LtOperation::new(u64::from(op.fill), dma_set::DMA_MEMSET_MAX_FILL + 1, false), + ] + })); + // HINT range-checks: selector < 3 and both address low limbs < 2^32 - 31 (matching + // the executor's HintUnknownSelector / HintAddressOverflow rejections). Three 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_ADDR_LIMB_BOUND, false), + LtOperation::new(op.out_addr & 0xFFFF_FFFF, hint::HINT_ADDR_LIMB_BOUND, false), + ] + })); // ===================================================================== // PHASE 4: All → Bitwise lookups @@ -3352,7 +3700,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 @@ -3389,9 +3738,11 @@ fn build_traces( Box::new(|h| h.add_ops(&collect_bitwise_from_memw_aligned(&memw_aligned_ops))), Box::new(|h| h.add_ops(&collect_bitwise_from_commit(&commit_ops))), Box::new(|h| h.add_ops(&collect_bitwise_from_dma(&dma_ops))), + Box::new(|h| h.add_ops(&collect_bitwise_from_dma_set(&dma_set_ops))), 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 @@ -3679,6 +4030,9 @@ fn build_traces( let gen_ecsm = || ecsm::generate_ecsm_trace(&ecsm_ops); let gen_ecdas = || ecdas::generate_ecdas_trace(&ecdas_ops); let gen_dma = || dma::generate_dma_trace(&dma_ops); + let gen_dma_set = || dma_set::generate_dma_set_trace(&dma_set_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); @@ -3692,6 +4046,8 @@ fn build_traces( (None, None, None, None); let (mut ecsm_slot, mut ecdas_slot) = (None, None); let mut dma_slot = None; + let mut dma_set_slot = None; + let mut hint_slot = None; #[cfg(feature = "disk-spill")] let sequential = storage_mode == StorageMode::Disk || cfg!(not(feature = "parallel")); @@ -3734,6 +4090,8 @@ fn build_traces( spawn_into!(ecsm_slot, gen_ecsm); spawn_into!(ecdas_slot, gen_ecdas); spawn_into!(dma_slot, gen_dma); + spawn_into!(dma_set_slot, gen_dma_set); + spawn_into!(hint_slot, gen_hint); }); } else { cpus_slot = Some(gen_cpus()); @@ -3762,6 +4120,8 @@ fn build_traces( ecsm_slot = Some(gen_ecsm()); ecdas_slot = Some(gen_ecdas()); dma_slot = Some(gen_dma()); + dma_set_slot = Some(gen_dma_set()); + hint_slot = Some(gen_hint()); } const PHASE5_RAN: &str = "phase 5 generation ran in one of the branches above"; @@ -3798,6 +4158,9 @@ fn build_traces( let ecdas_trace = ecdas_slot.expect(PHASE5_RAN); #[allow(unused_mut)] let mut dma_trace = dma_slot.expect(PHASE5_RAN); + #[allow(unused_mut)] + let mut dma_set_trace = dma_set_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. @@ -3819,6 +4182,10 @@ fn build_traces( .main_table .spill_to_disk() .map_err(|e| Error::Prover(format!("disk-spill dma: {e}")))?; + dma_set_trace + .main_table + .spill_to_disk() + .map_err(|e| Error::Prover(format!("disk-spill dma_set: {e}")))?; register_trace .main_table .spill_to_disk() @@ -3870,6 +4237,8 @@ fn build_traces( ecsm: ecsm_trace, ecdas: ecdas_trace, dma: dma_trace, + dma_set: dma_set_trace, + hint: hint_trace, memw_registers, local_to_global, touched_memory_cells, @@ -3914,6 +4283,7 @@ pub struct TableLengths { pub branch_padded_rows: u64, pub commit_padded_rows: u64, pub dma_padded_rows: u64, + pub dma_set_padded_rows: u64, pub decode_rows: u64, pub unique_page_count: u64, pub cycle_count: u64, @@ -3954,6 +4324,7 @@ pub fn count_table_lengths( let mut branch_count = 0usize; let mut commit_count = 0usize; let mut dma_count = 0usize; + let mut dma_set_count = 0usize; let mut current_commit_index = 0u32; let partition_memw = |op: &MemwOperation, @@ -4065,6 +4436,45 @@ pub fn count_table_lengths( lt_count += dma_rows + 1; } + if cpu_op.ecall_dma_memset { + let rows = replay_dma_memset_for_sizing( + &cpu_op, + &mut memory_state, + &mut register_state, + |memw_op| { + partition_memw( + memw_op, + &mut memw_by_width, + &mut memw_aligned_count, + &mut memw_register_count, + ); + }, + ); + dma_set_count += rows; + // One LT per row pins the 1-vs-8-byte width; the first row adds two + // more (the chunk cap and the fill-byte bound). + lt_count += rows + 2; + } + + 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 three LT + // range-checks (selector < 3, in_addr and out_addr low limbs). 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 += 3; + } + // CPU-side per-instruction-kind counters (non-word; word → CPU32, B5b) let f = &cpu_op.decode.fields; if !f.word_instr && f.is_lt() { @@ -4128,6 +4538,10 @@ pub fn count_table_lengths( .checked_next_power_of_two() .unwrap_or(usize::MAX) .max(4) as u64, + dma_set_padded_rows: dma_set_count + .checked_next_power_of_two() + .unwrap_or(usize::MAX) + .max(4) as u64, decode_rows, unique_page_count, cycle_count, @@ -4154,11 +4568,13 @@ impl Traces { use super::decode::NUM_PRECOMPUTED_COLS as DECODE_PRECOMPUTED; use super::decode::cols::NUM_COLUMNS as DECODE_COLS; use super::dma::cols::NUM_COLUMNS as DMA_COLS; + use super::dma_set::cols::NUM_COLUMNS as DMA_SET_COLS; use super::dvrm::cols::NUM_COLUMNS as DVRM_COLS; use super::ecdas::cols::NUM_COLUMNS as ECDAS_COLS; 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; @@ -4198,6 +4614,8 @@ impl Traces { ecsm, ecdas, dma, + dma_set, + hint, memw_registers, eqs, bytewises, @@ -4266,6 +4684,8 @@ impl Traces { total += (ecsm.num_rows() * ECSM_COLS) as u64; total += (ecdas.num_rows() * ECDAS_COLS) as u64; total += (dma.num_rows() * DMA_COLS) as u64; + total += (dma_set.num_rows() * DMA_SET_COLS) as u64; + total += (hint.num_rows() * HINT_COLS) as u64; total } @@ -4308,6 +4728,8 @@ impl Traces { let n_ecsm = aux_cols(super::ecsm::bus_interactions().len()); let n_ecdas = aux_cols(super::ecdas::bus_interactions().len()); let n_dma = aux_cols(super::dma::bus_interactions().len()); + let n_dma_set = aux_cols(super::dma_set::bus_interactions().len()); + let n_hint = aux_cols(super::hint::bus_interactions().len()); let Traces { cpus, @@ -4331,6 +4753,8 @@ impl Traces { ecsm, ecdas, dma, + dma_set, + hint, memw_registers, eqs, bytewises, @@ -4399,6 +4823,8 @@ impl Traces { total += (ecsm.num_rows() * n_ecsm) as u64; total += (ecdas.num_rows() * n_ecdas) as u64; total += (dma.num_rows() * n_dma) as u64; + total += (dma_set.num_rows() * n_dma_set) as u64; + total += (hint.num_rows() * n_hint) as u64; total } @@ -4453,17 +4879,74 @@ impl Traces { /// - Deterministic ELF pages (preprocessed, init from binary) /// - Runtime pages from prover hints (preprocessed, zero-init) /// - Private-input pages (NOT preprocessed, verifier doesn't see init values) + /// + /// `max_pages` caps how many configs may be materialised. `runtime_page_ranges` + /// is a prover-chosen field of `VmProof` with a free `u64` count, and this + /// function is what turns it into allocations — so the cap must be enforced + /// *before* the loop, not by the `expected_proof_count` check downstream, which + /// only runs once the `Vec` already exists. The verifier passes the sub-proof + /// count: a layout needing more pages than the proof has sub-proofs can never + /// verify, so this rejects nothing an honest prover could produce. pub fn page_configs_from_elf_and_runtime( elf: &Elf, runtime_page_ranges: &[crate::RuntimePageRange], num_private_input_pages: usize, - ) -> Vec { + max_pages: usize, + ) -> Result, Error> { let mut configs = Self::page_configs_from_elf(elf); let page_size = page::DEFAULT_PAGE_SIZE; - // Add zero-init runtime pages (stack, heap) + let too_many = |have: usize| { + Error::MalformedPageLayout(format!( + "page layout needs more than {max_pages} pages (at least {have}); \ + the proof cannot contain that many sub-proofs", + )) + }; + if configs.len() > max_pages { + return Err(too_many(configs.len())); + } + + // Add zero-init runtime pages (stack, heap). for r in runtime_page_ranges { let (base, count) = (r.base, r.count); + if count == 0 { + return Err(Error::MalformedPageLayout(format!( + "runtime page range at 0x{base:x} has count 0; the honest \ + run-length encoding never emits an empty range", + ))); + } + // Alignment is what makes the duplicate-base check below a complete + // overlap check: page-aligned pages of one size either share a base or + // are disjoint, so there is no partial-overlap case to consider. + if base % page_size as u64 != 0 { + return Err(Error::MalformedPageLayout(format!( + "runtime page base 0x{base:x} is not {page_size}-byte aligned", + ))); + } + // Reject before allocating: `count` is untrusted, so both the running + // total and the address arithmetic have to be checked up front. + let projected = configs + .len() + .saturating_add(usize::try_from(count).unwrap_or(usize::MAX)); + if projected > max_pages { + return Err(too_many(projected)); + } + // Guards the `base + i * page_size` below for every `i < count`. + // + // Bound the range's LAST BYTE, not its exclusive end: the stack's top page + // legitimately sits at the very top of the address space, where the + // exclusive end is exactly 2^64 and only the last byte is representable. + // Checking the end instead rejects every honest proof (`count >= 1` is + // already established above, so `span - 1` cannot underflow). + count + .checked_mul(page_size as u64) + .and_then(|span| base.checked_add(span - 1)) + .ok_or_else(|| { + Error::MalformedPageLayout(format!( + "runtime page range at 0x{base:x} with count {count} overflows \ + the address space", + )) + })?; for i in 0..count { configs.push(PageConfig::zero_init(base + i * page_size as u64)); } @@ -4477,9 +4960,32 @@ impl Traces { is_private_input: true, }); } + if configs.len() > max_pages { + return Err(too_many(configs.len())); + } configs.sort_by_key(|c| c.page_base); - configs + + // Exactly one page per address. Two PAGE tables covering the same base each + // provide a genesis token for every address in it, and the memory argument's + // soundness rests on the init set holding exactly one entry per address: with + // two, a witness can have the real page's row consume the duplicate's token + // and vice versa, injecting a value the program never wrote. A duplicate is + // never legitimate — the honest builder derives ELF pages from a `BTreeSet` + // and run-length-encodes the rest — so reject rather than dedupe silently, + // which would mask a prover bug instead of surfacing it. + if let Some(w) = configs + .windows(2) + .find(|w| w[0].page_base == w[1].page_base) + { + return Err(Error::MalformedPageLayout(format!( + "two page tables cover base 0x{:x}; each address must have exactly \ + one genesis token", + w[0].page_base, + ))); + } + + Ok(configs) } /// Extracts runtime page ranges from the generated page configs. @@ -4673,6 +5179,8 @@ impl Traces { ecsm_ops, ecdas_ops, dma_ops, + dma_set_ops, + hint_ops, ) = collect_ops_from_cpu(&cpu_ops, &mut memory_state, &mut register_state); #[cfg(feature = "instruments")] drop(__sp); @@ -4692,6 +5200,8 @@ impl Traces { ecsm_ops, ecdas_ops, dma_ops, + dma_set_ops, + hint_ops, &mut register_state, is_final, ); @@ -4786,6 +5296,8 @@ impl Traces { ecsm_ops, ecdas_ops, dma_ops, + dma_set_ops, + hint_ops, ) = collect_ops_from_cpu(&cpu_ops, &mut memory_state, &mut register_state); let ops = collect_all_ops( @@ -4801,6 +5313,8 @@ impl Traces { ecsm_ops, ecdas_ops, dma_ops, + dma_set_ops, + hint_ops, &mut register_state, true, ); diff --git a/prover/src/tables/types.rs b/prover/src/tables/types.rs index 0d4a093ee..98c0910f5 100644 --- a/prover/src/tables/types.rs +++ b/prover/src/tables/types.rs @@ -362,6 +362,12 @@ pub enum BusId { /// copy. Only the first row receives the CPU's `Ecall`; the rest chain here. DmaNext = 29, + /// DMA memset streaming bus: each DMA_SET row sends + /// `(timestamp, dst_incr, count_decr, fill)` to the next row and receives + /// `(timestamp, dst, count, fill)` from the previous one. Separate from + /// [`BusId::DmaNext`] so a memcpy row can never consume a memset token. + DmaSetNext = 32, + // ========================================================================= // Continuations // ========================================================================= @@ -397,6 +403,7 @@ impl BusId { BusId::Ecdas => "Ecdas", BusId::Bit => "Bit", BusId::DmaNext => "DmaNext", + BusId::DmaSetNext => "DmaSetNext", BusId::GlobalMemory => "GlobalMemory", } } @@ -429,6 +436,7 @@ impl TryFrom for BusId { 27 => Ok(BusId::Cpu32), 28 => Ok(BusId::Ecdas), 29 => Ok(BusId::DmaNext), + 32 => Ok(BusId::DmaSetNext), 30 => Ok(BusId::Bit), 31 => Ok(BusId::GlobalMemory), other => Err(other), diff --git a/prover/src/test_utils.rs b/prover/src/test_utils.rs index dd7f97bc3..c5795b6d7 100644 --- a/prover/src/test_utils.rs +++ b/prover/src/test_utils.rs @@ -58,6 +58,9 @@ use crate::tables::decode::{bus_interactions as decode_bus_interactions, cols as use crate::tables::dma::{ DmaConstraints, bus_interactions as dma_bus_interactions, cols as dma_cols, }; +use crate::tables::dma_set::{ + DmaSetConstraints, bus_interactions as dma_set_bus_interactions, cols as dma_set_cols, +}; use crate::tables::dvrm::{ DvrmConstraints, bus_interactions as dvrm_bus_interactions, cols as dvrm_cols, }; @@ -69,6 +72,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, }; @@ -909,6 +915,33 @@ pub fn create_dma_air(proof_options: &ProofOptions) -> ConcreteVmAir ConcreteVmAir { + build_air( + dma_set_cols::NUM_COLUMNS, + dma_set_bus_interactions(), + proof_options, + 1, + DmaSetConstraints, + "DMA_SET", + ) +} + +/// Create HINT AIR: a receiver for the `hint` ecall (Ecall receive, the x10/x11/x12 +/// register reads, the three ALU `LT` operand range-checks, four output MEMW writes, +/// output byte range-checks) with a single boolean constraint on the multiplicity +/// column `mu`. +pub fn create_hint_air(proof_options: &ProofOptions) -> 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 050d7be80..eb259d961 100644 --- a/prover/src/tests/constraint_program_device_tests.rs +++ b/prover/src/tests/constraint_program_device_tests.rs @@ -158,6 +158,7 @@ fn all_table_programs_lower_and_match_folders() { check_air_device(&create_cpu_air(&opts), "CPU"); check_air_device(&create_dma_air(&opts), "DMA"); + check_air_device(&create_dma_set_air(&opts), "DMA_SET"); check_air_device(&create_bitwise_air(&opts), "BITWISE"); check_air_device(&create_lt_air(&opts), "LT"); check_air_device(&create_shift_air(&opts), "SHIFT"); @@ -182,4 +183,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 7a81dfbe1..a7f9415a3 100644 --- a/prover/src/tests/constraint_program_tests.rs +++ b/prover/src/tests/constraint_program_tests.rs @@ -156,6 +156,7 @@ fn all_table_programs_match_folders() { check_air(&create_cpu_air(&opts), "CPU"); check_air(&create_dma_air(&opts), "DMA"); + check_air(&create_dma_set_air(&opts), "DMA_SET"); check_air(&create_bitwise_air(&opts), "BITWISE"); check_air(&create_lt_air(&opts), "LT"); check_air(&create_shift_air(&opts), "SHIFT"); @@ -180,4 +181,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 e71f18573..f5d89282d 100644 --- a/prover/src/tests/constraint_set_tests_b.rs +++ b/prover/src/tests/constraint_set_tests_b.rs @@ -313,3 +313,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 f2cf4bd87..636f9917f 100644 --- a/prover/src/tests/count_table_lengths_drift_tests.rs +++ b/prover/src/tests/count_table_lengths_drift_tests.rs @@ -55,6 +55,10 @@ fn assert_count_table_lengths_matches(elf: &Elf, logs: &[Log]) { predicted.dma_padded_rows, traces.dma.main_table.height as u64, "dma" ); + assert_eq!( + predicted.dma_set_padded_rows, traces.dma_set.main_table.height as u64, + "dma_set" + ); assert_eq!( predicted.decode_rows, traces.decode.main_table.height as u64, "decode" @@ -105,11 +109,12 @@ fn count_table_lengths_matches_traces() { } /// Runs one Rust DMA guest and asserts the sizing pass matches the built traces. -/// The two replays of a DMA ecall (`collect_dma_memcpy_ops` for generation and -/// `replay_dma_memcpy_for_sizing` for counting) must agree, so the fixtures cover -/// both a single chunk and the multi-chunk / overlapping / near-`MAX_DATA_ROWS` -/// cases of `dma_memcpy_cases`. -fn assert_dma_fixture_counts(elf_name: &str) { +/// Each ecall has two hand-maintained replays — `collect_dma_*_ops` for +/// generation and `replay_dma_*_for_sizing` for counting — and they must agree, +/// so the fixtures cover a single chunk plus the multi-chunk / overlapping / +/// near-`MAX_DATA_ROWS` cases of `dma_memcpy_cases`, and the same schedule +/// driven through the memset table. +fn assert_dma_fixture_counts(elf_name: &str, syscall_number: u64) { let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() .expect("workspace root") @@ -125,7 +130,7 @@ fn assert_dma_fixture_counts(elf_name: &str) { assert!( result.logs.iter().any(|log| { - log.src1_val == executor::vm::instruction::execution::DMA_MEMCPY_SYSCALL_NUMBER + log.src1_val == syscall_number && matches!( result.instructions.get(&log.current_pc), Some(Instruction::EcallEbreak) @@ -138,6 +143,40 @@ fn assert_dma_fixture_counts(elf_name: &str) { #[test] fn count_table_lengths_matches_nonempty_dma_trace() { - assert_dma_fixture_counts("dma_memcpy_min.elf"); - assert_dma_fixture_counts("dma_memcpy_cases.elf"); + use executor::vm::instruction::execution::{ + DMA_MEMCPY_SYSCALL_NUMBER, DMA_MEMSET_SYSCALL_NUMBER, + }; + + assert_dma_fixture_counts("dma_memcpy_min.elf", DMA_MEMCPY_SYSCALL_NUMBER); + assert_dma_fixture_counts("dma_memcpy_cases.elf", DMA_MEMCPY_SYSCALL_NUMBER); + assert_dma_fixture_counts("dma_memset_min.elf", DMA_MEMSET_SYSCALL_NUMBER); + assert_dma_fixture_counts("dma_memset_cases.elf", DMA_MEMSET_SYSCALL_NUMBER); +} + +/// 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/dma_set_tests.rs b/prover/src/tests/dma_set_tests.rs new file mode 100644 index 000000000..cede12f20 --- /dev/null +++ b/prover/src/tests/dma_set_tests.rs @@ -0,0 +1,179 @@ +use crate::tables::dma_set::{DmaSetOperation, cols, generate_dma_set_trace}; +use crate::tables::types::FE; +use crate::test_utils::{busless_air, validate_busless}; + +fn row(count: u64, first: bool, end: bool) -> DmaSetOperation { + DmaSetOperation { + timestamp: 100, + dst: 0x2000, + count, + fill: 0x3C, + first, + end, + } +} + +#[test] +fn dma_set_trace_uses_eight_byte_rows_then_a_byte_tail() { + let trace = generate_dma_set_trace(&[ + row(10, true, false), + row(2, false, false), + row(1, false, false), + row(0, false, true), + ]); + + let wide = trace.main_table.get_row(0); + assert_eq!(wide[cols::TAIL], FE::zero()); + assert_eq!(wide[cols::DST_INCR_0], FE::from(0x2008u64)); + assert_eq!(wide[cols::COUNT_DECR_0], FE::from(2u64)); + assert_eq!(wide[cols::FILL], FE::from(0x3Cu64)); + assert_eq!(wide[cols::FILL_WIDE], FE::from(0x3Cu64)); + + let tail = trace.main_table.get_row(1); + assert_eq!(tail[cols::TAIL], FE::one()); + assert_eq!(tail[cols::DST_INCR_0], FE::from(0x2001u64)); + assert_eq!(tail[cols::COUNT_DECR_0], FE::one()); + assert_eq!(tail[cols::FILL], FE::from(0x3Cu64)); + // The write tuple broadcasts FILL_WIDE into lanes 1..7, so a one-byte row + // must zero it or the MEMW write widens past the byte it is allowed to touch. + assert_eq!(tail[cols::FILL_WIDE], FE::zero()); + + let terminal = trace.main_table.get_row(3); + assert_eq!(terminal[cols::END], FE::one()); + assert_eq!(terminal[cols::TAIL], FE::one()); + assert_eq!(terminal[cols::COUNT_DECR_0], FE::from(0xFFFFu64)); + assert_eq!(terminal[cols::COUNT_DECR_1], FE::from(0xFFFFu64)); + assert_eq!(terminal[cols::COUNT_DECR_2], FE::from(0xFFFFu64)); + assert_eq!(terminal[cols::COUNT_DECR_3], FE::from(0xFFFFu64)); +} + +#[test] +fn empty_dma_set_call_is_a_single_first_and_terminal_row() { + let trace = generate_dma_set_trace(&[row(0, true, true)]); + let first = trace.main_table.get_row(0); + assert_eq!(first[cols::FIRST], FE::one()); + assert_eq!(first[cols::END], FE::one()); + assert_eq!(first[cols::MU], FE::one()); +} + +#[test] +fn dma_set_constraints_accept_valid_rows_and_reject_a_wide_tail_fill() { + let mut trace = generate_dma_set_trace(&[ + row(2, true, false), + row(1, false, false), + row(0, false, true), + ]); + let air = busless_air(cols::NUM_COLUMNS, crate::tables::dma_set::DmaSetConstraints); + assert!(validate_busless(&air, &trace)); + + // Row 0 is a one-byte row (count = 2 < 8). Constraint 9 (`tail * fill_wide`) + // is the only thing stopping it from broadcasting the fill into lanes 1..7. + trace.main_table.set(0, cols::FILL_WIDE, FE::one()); + assert!( + !validate_busless(&air, &trace), + "a one-byte row must not smuggle a wide fill into lanes 1..7" + ); +} + +#[test] +fn dma_set_constraints_reject_a_wide_row_whose_fill_wide_disagrees_with_fill() { + let mut trace = generate_dma_set_trace(&[row(10, true, false), row(2, false, false)]); + let air = busless_air(cols::NUM_COLUMNS, crate::tables::dma_set::DmaSetConstraints); + assert!(validate_busless(&air, &trace)); + + // Row 0 is a wide row. Constraint 10 pins `fill_wide == fill`; without it the + // seven high lanes could carry a different byte than lane 0. + let fill = *trace.main_table.get(0, cols::FILL); + trace.main_table.set(0, cols::FILL_WIDE, fill + FE::one()); + assert!( + !validate_busless(&air, &trace), + "an eight-byte row must write the same byte in every lane" + ); +} + +#[test] +fn dma_set_constraints_reject_active_destination_wrap() { + let air = busless_air(cols::NUM_COLUMNS, crate::tables::dma_set::DmaSetConstraints); + + let destination_wrap = generate_dma_set_trace(&[DmaSetOperation { + timestamp: 100, + dst: u64::MAX - 3, + count: 8, + fill: 0x3C, + first: true, + end: false, + }]); + assert!( + !validate_busless(&air, &destination_wrap), + "an active destination increment must not wrap modulo 2^64" + ); +} + +#[test] +fn dma_set_terminal_row_may_wrap_unused_successor_columns() { + let trace = generate_dma_set_trace(&[DmaSetOperation { + timestamp: 100, + dst: u64::MAX, + count: 0, + fill: 0x3C, + first: true, + end: true, + }]); + let air = busless_air(cols::NUM_COLUMNS, crate::tables::dma_set::DmaSetConstraints); + assert!( + validate_busless(&air, &trace), + "terminal successors are not consumed and may wrap" + ); +} + +#[test] +fn dma_set_bus_interactions_count() { + use crate::tables::dma_set::bus_interactions; + assert_eq!(bus_interactions().len(), 19); +} + +#[test] +fn dma_set_constraints_count_and_indices() { + use crate::tables::dma_set::DmaSetConstraints; + use stark::constraints::builder::ConstraintSet; + let meta = DmaSetConstraints.meta(); + assert_eq!(meta.len(), 11); + // Dense, idx-ordered. + for (i, m) in meta.iter().enumerate() { + assert_eq!(m.constraint_idx, i); + } + // All constraints are degree 2 (no over-degree slips in a template change). + assert_eq!(DmaSetConstraints.max_degree(), 2); +} + +#[test] +fn dma_set_padding_row_cannot_claim_first_or_end() { + // Constraint 4, `(first + end) * (1 - mu) = 0`, is the sole guard that a + // padding row (mu = 0) cannot masquerade as the first or terminal row of a + // fill — bitness alone accepts first = 1 or end = 1. A padding row claiming + // `first` would forge an ECALL receive; claiming `end` would forge a + // terminal row. + let air = busless_air(cols::NUM_COLUMNS, crate::tables::dma_set::DmaSetConstraints); + let base = generate_dma_set_trace(&[ + row(2, true, false), + row(1, false, false), + row(0, false, true), + ]); + // Row 3 is padding: mu = 0, first = end = 0, and the trace validates. + assert_eq!(base.main_table.get_row(3)[cols::MU], FE::zero()); + assert!(validate_busless(&air, &base)); + + let mut forge_first = base.clone(); + forge_first.main_table.set(3, cols::FIRST, FE::one()); + assert!( + !validate_busless(&air, &forge_first), + "a padding row (mu = 0) must not claim to be a fill's first row" + ); + + let mut forge_end = base; + forge_end.main_table.set(3, cols::END, FE::one()); + assert!( + !validate_busless(&air, &forge_end), + "a padding row (mu = 0) must not claim to be a fill's terminal row" + ); +} diff --git a/prover/src/tests/hint_tests.rs b/prover/src/tests/hint_tests.rs new file mode 100644 index 000000000..479e7b001 --- /dev/null +++ b/prover/src/tests/hint_tests.rs @@ -0,0 +1,171 @@ +//! HINT constraint tests. + +use crate::tables::hint::{ + HINT_ADDR_LIMB_BOUND, HintConstraints, HintOperation, bus_interactions, cols, + generate_hint_trace, +}; +use crate::tables::types::{BusId, FE, GoldilocksExtension, GoldilocksField}; +use math::field::element::FieldElement; +use stark::constraints::builder::{ConstraintSet, ProverEvalFolder}; +use stark::frame::Frame; +use stark::lookup::{BusValue, LinearTerm}; +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" + ); +} + +/// The lhs column of an ALU `LT` sender, and the constant it is compared against. +fn alu_lt_senders() -> Vec<(usize, u64)> { + let id: u64 = BusId::Alu.into(); + bus_interactions() + .iter() + .filter(|i| i.is_sender && i.bus_id == id) + .map(|i| { + let lhs = match &i.values[0] { + BusValue::Packed { start_column, .. } => *start_column, + BusValue::Linear(_) => panic!("LT lhs must be a column, not a constant"), + }; + let bound = match &i.values[2] { + BusValue::Linear(terms) => match terms.as_slice() { + [LinearTerm::Constant(c)] => *c as u64, + _ => panic!("LT rhs must be a single constant"), + }, + BusValue::Packed { .. } => panic!("LT rhs must be a constant"), + }; + (lhs, bound) + }) + .collect() +} + +/// Both address low limbs are range-checked, not just `in_addr`. +/// +/// `out_addr` is on the memory bus, which is why it originally had no LT sender — but the +/// bus bounds it only to `2^32 - 25` (the largest write base is `out_addr_lo + 24`, and +/// MEMW's carry columns resolve the bytes past it), while the executor rejects anything +/// above `2^32 - 32`. Without this sender the AIR accepted the seven-value window in +/// [`addr_limb_bound_rejects_every_operand_the_executor_rejects`]. +#[test] +fn alu_lt_senders_range_check_selector_and_both_address_limbs() { + let senders = alu_lt_senders(); + assert_eq!(senders.len(), 3, "selector + in_addr + out_addr"); + + for col in [cols::ADDR_IN_0, cols::ADDR_OUT_0] { + let bound = senders + .iter() + .find_map(|(lhs, bound)| (*lhs == col).then_some(*bound)) + .unwrap_or_else(|| panic!("column {col} must have an ALU LT range-check")); + assert_eq!( + bound, HINT_ADDR_LIMB_BOUND, + "column {col} must be checked against the executor's bound" + ); + } +} + +/// The bound accepts exactly the operands `addr_limb_ok(addr, 31)` accepts. +/// +/// The seven values in `2^32-31 ..= 2^32-25` are the regression: the executor rejects +/// them with `HintAddressOverflow`, and before the `out_addr` sender existed the AIR +/// accepted them for the output address — a provable hint call the VM halts on. +#[test] +fn addr_limb_bound_rejects_every_operand_the_executor_rejects() { + // `addr_limb_ok(addr, 31)`: the 32-byte range must fit under 2^32. + let executor_accepts = |limb: u64| limb + 31 < (1 << 32); + // The AIR accepts iff the LT range-check passes. + let air_accepts = |limb: u64| limb < HINT_ADDR_LIMB_BOUND; + + for limb in (1u64 << 32) - 40..1u64 << 32 { + assert_eq!( + air_accepts(limb), + executor_accepts(limb), + "AIR and executor disagree on out_addr low limb {limb:#x}" + ); + } + + // The window that used to verify while the executor halted on it. + for limb in (1u64 << 32) - 31..=(1u64 << 32) - 25 { + assert!(!air_accepts(limb), "{limb:#x} must be rejected"); + } + // And the largest operand that must still run. + assert!(air_accepts((1 << 32) - 32)); +} diff --git a/prover/src/tests/mod.rs b/prover/src/tests/mod.rs index 5d0a88bdc..8b76f32f2 100644 --- a/prover/src/tests/mod.rs +++ b/prover/src/tests/mod.rs @@ -39,6 +39,8 @@ pub mod decode_tests; #[cfg(all(test, feature = "disk-spill"))] pub mod disk_spill_tests; #[cfg(test)] +pub mod dma_set_tests; +#[cfg(test)] pub mod dma_tests; #[cfg(test)] pub mod dvrm_tests; @@ -49,6 +51,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; @@ -71,6 +75,10 @@ pub mod mul_tests; #[cfg(test)] pub mod ood_window_ir_tests; #[cfg(test)] +pub mod page_layout_tests; +#[cfg(test)] +pub mod page_offset_forgery_poc; +#[cfg(test)] pub mod page_tests; #[cfg(test)] pub mod prove_elfs_tests; diff --git a/prover/src/tests/ood_window_ir_tests.rs b/prover/src/tests/ood_window_ir_tests.rs index a3c6e07a3..a2e001873 100644 --- a/prover/src/tests/ood_window_ir_tests.rs +++ b/prover/src/tests/ood_window_ir_tests.rs @@ -91,6 +91,7 @@ fn all_table_windows_match_captured_ir() { assert_ood_window_matches_ir(&create_cpu_air(&opts), true, "CPU"); assert_ood_window_matches_ir(&create_dma_air(&opts), true, "DMA"); + assert_ood_window_matches_ir(&create_dma_set_air(&opts), true, "DMA_SET"); assert_ood_window_matches_ir(&create_bitwise_air(&opts), true, "BITWISE"); assert_ood_window_matches_ir(&create_lt_air(&opts), true, "LT"); assert_ood_window_matches_ir(&create_shift_air(&opts), true, "SHIFT"); @@ -115,4 +116,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/page_layout_tests.rs b/prover/src/tests/page_layout_tests.rs new file mode 100644 index 000000000..eba8b220c --- /dev/null +++ b/prover/src/tests/page_layout_tests.rs @@ -0,0 +1,286 @@ +//! Regression tests for the verifier's PAGE-layout reconstruction. +//! +//! `runtime_page_ranges` is a prover-chosen field of `VmProof` carrying a free +//! `u64` base and a free `u64` count, and the verifier turns it into PAGE tables +//! with `Traces::page_configs_from_elf_and_runtime`. These tests pin the two +//! properties that reconstruction must enforce on untrusted input. +//! +//! **One page per address.** Two PAGE tables covering the same base each provide +//! a genesis token for every address in that page. The memory argument's +//! soundness rests on the init set holding exactly one entry per address: with +//! two, a witness can have the real page's row consume the duplicate's token and +//! the duplicate's row consume the real one, injecting a value the program never +//! wrote while the bus still balances. A prover reaches this with no private +//! input at all, by declaring a runtime range aliasing a real ELF data page — +//! and *both* pages then carry correct, verifier-recomputed preprocessed +//! commitments, so nothing is forged at the commitment layer. This is the +//! companion to `page_offset_forgery_poc`: pinning `OFFSET` restores one row per +//! address *within* a page, and this restores one page per address. +//! +//! **Bounded before allocation.** The `expected_proof_count` cross-check would +//! reject a wrong page count, but it runs after the configs are materialised, so +//! a `count: u64::MAX` range exhausts memory first — a verifier DoS on untrusted +//! input. +//! +//! These exercise the verifier's own reconstruction path (the same function +//! `verify_proof_parts` calls). They do not build a forged proof end to end; the +//! full attack demonstration for the duplication route lives with the PoC work. + +use crate::tables::page::DEFAULT_PAGE_SIZE; +use crate::tables::trace_builder::Traces; +use crate::test_utils::asm_elf_bytes; +use crate::{Error, RuntimePageRange}; + +use executor::elf::Elf; + +fn test_elf() -> Elf { + Elf::load(&asm_elf_bytes("poc_rodata_commit")).expect("ELF load") +} + +/// Base of some page the ELF itself already defines — the address a duplicate +/// range would alias. +fn an_elf_page_base(elf: &Elf) -> u64 { + Traces::page_configs_from_elf(elf) + .first() + .expect("the ELF must define at least one page") + .page_base +} + +fn layout( + elf: &Elf, + ranges: &[RuntimePageRange], + max_pages: usize, +) -> Result, Error> { + Traces::page_configs_from_elf_and_runtime(elf, ranges, 0, max_pages) +} + +/// Non-vacuity: the honest shape this all has to keep accepting. +#[test] +fn honest_page_layout_is_accepted() { + let elf = test_elf(); + let elf_pages = Traces::page_configs_from_elf(&elf).len(); + + // A runtime range that does not alias any ELF page: well past the ELF image. + let base = 0x8000_0000u64; + let configs = layout(&elf, &[RuntimePageRange { base, count: 3 }], usize::MAX) + .expect("an honest, non-overlapping layout must be accepted"); + assert_eq!(configs.len(), elf_pages + 3); + + // And the result stays sorted with no repeats — what the checks below defend. + assert!(configs.windows(2).all(|w| w[0].page_base < w[1].page_base)); +} + +/// A runtime range aliasing a real ELF page must be rejected: that is the exact +/// shape of the duplication attack, and the one a prover can mount with no +/// private input. +#[test] +fn runtime_range_aliasing_an_elf_page_is_rejected() { + let elf = test_elf(); + let base = an_elf_page_base(&elf); + + let err = layout(&elf, &[RuntimePageRange { base, count: 1 }], usize::MAX) + .expect_err("a runtime page aliasing an ELF page must be rejected"); + assert!( + matches!(&err, Error::MalformedPageLayout(m) if m.contains("exactly")), + "expected a duplicate-page rejection, got: {err}" + ); +} + +/// Two identical runtime ranges are the same violation without involving the ELF. +#[test] +fn duplicate_runtime_ranges_are_rejected() { + let elf = test_elf(); + let base = 0x8000_0000u64; + + let err = layout( + &elf, + &[ + RuntimePageRange { base, count: 1 }, + RuntimePageRange { base, count: 1 }, + ], + usize::MAX, + ) + .expect_err("two runtime ranges covering the same base must be rejected"); + assert!( + matches!(&err, Error::MalformedPageLayout(m) if m.contains("exactly")), + "expected a duplicate-page rejection, got: {err}" + ); +} + +/// Overlapping (not merely identical) ranges are caught by the same check, +/// because alignment makes same-size pages either equal or disjoint. +#[test] +fn overlapping_runtime_ranges_are_rejected() { + let elf = test_elf(); + let base = 0x8000_0000u64; + let page = DEFAULT_PAGE_SIZE as u64; + + let err = layout( + &elf, + &[ + RuntimePageRange { base, count: 4 }, + RuntimePageRange { + base: base + 2 * page, + count: 4, + }, + ], + usize::MAX, + ) + .expect_err("overlapping runtime ranges must be rejected"); + assert!( + matches!(&err, Error::MalformedPageLayout(m) if m.contains("exactly")), + "expected a duplicate-page rejection, got: {err}" + ); +} + +/// Unaligned bases are rejected. Beyond being malformed, this is what keeps "same +/// base" equivalent to "overlapping": page-aligned pages of one size either share a +/// base or are disjoint, with no partial-overlap case. +#[test] +fn unaligned_runtime_page_base_is_rejected() { + let elf = test_elf(); + + let err = layout( + &elf, + &[RuntimePageRange { + base: 0x8000_0000 + 1, + count: 1, + }], + usize::MAX, + ) + .expect_err("an unaligned runtime page base must be rejected"); + assert!( + matches!(&err, Error::MalformedPageLayout(m) if m.contains("aligned")), + "expected an alignment rejection, got: {err}" + ); +} + +/// DoS: a `u64::MAX` count must be refused up front, not after allocating. +/// +/// The assertion that matters is not just the `Err` but that this test *returns* +/// — before the bound, `for i in 0..count` would allocate `PageConfig`s until the +/// process died, so a regression here shows up as the suite being OOM-killed. +#[test] +fn unbounded_runtime_page_count_is_rejected_without_allocating() { + let elf = test_elf(); + + for count in [u64::MAX, u64::MAX / 2, 1 << 40] { + let err = layout(&elf, &[RuntimePageRange { base: 0, count }], 4096) + .expect_err("an absurd page count must be rejected"); + assert!( + matches!(&err, Error::MalformedPageLayout(m) if m.contains("more than")), + "expected a page-count rejection for count={count}, got: {err}" + ); + } +} + +/// The cap is the sub-proof count, so a layout one page over it is refused. +/// Nothing an honest prover produces can trip this: every page needs a sub-proof. +#[test] +fn page_count_above_the_cap_is_rejected() { + let elf = test_elf(); + let elf_pages = Traces::page_configs_from_elf(&elf).len(); + + let ranges = [RuntimePageRange { + base: 0x8000_0000, + count: 2, + }]; + // Exactly enough room: accepted. + layout(&elf, &ranges, elf_pages + 2).expect("a layout that fits the cap is fine"); + // One short: refused. + let err = layout(&elf, &ranges, elf_pages + 1) + .expect_err("a layout needing more pages than the proof has sub-proofs must be rejected"); + assert!( + matches!(&err, Error::MalformedPageLayout(m) if m.contains("more than")), + "expected a page-count rejection, got: {err}" + ); +} + +/// A zero-count range is meaningless — the honest run-length encoding never emits +/// one — so it is refused rather than silently skipped. +#[test] +fn zero_count_runtime_range_is_rejected() { + let elf = test_elf(); + + let err = layout( + &elf, + &[RuntimePageRange { + base: 0x8000_0000, + count: 0, + }], + usize::MAX, + ) + .expect_err("a zero-count runtime range must be rejected"); + assert!( + matches!(&err, Error::MalformedPageLayout(m) if m.contains("count 0")), + "expected a zero-count rejection, got: {err}" + ); +} + +/// The stack's top page must stay accepted. +/// +/// It sits at the very top of the address space, so its *exclusive* end is exactly +/// `2^64` and only its last byte is representable. An overflow guard written +/// against the exclusive end rejects it — and therefore rejects every honest proof, +/// since every program has a stack. This is a real regression that shipped in a +/// draft of the guard above and was caught by the PoC harness's honest control. +#[test] +fn the_top_page_of_the_address_space_is_accepted() { + let elf = test_elf(); + let page = DEFAULT_PAGE_SIZE as u64; + let top_page_base = u64::MAX - page + 1; + assert_eq!(top_page_base % page, 0, "the top page must be aligned"); + + layout( + &elf, + &[RuntimePageRange { + base: top_page_base, + count: 1, + }], + usize::MAX, + ) + .expect("the top page of the address space is where the stack lives"); +} + +/// A range whose span wraps the address space is refused before the arithmetic +/// that would wrap. Uses the highest page-aligned base so the alignment check +/// (which runs first) passes and the overflow guard is the one under test. +#[test] +fn overflowing_runtime_range_is_rejected() { + let elf = test_elf(); + let page = DEFAULT_PAGE_SIZE as u64; + let top_aligned_base = (u64::MAX / page) * page; + assert_eq!(top_aligned_base % page, 0, "the test base must be aligned"); + + // count * page_size overflows u64 outright, so the guard fires on the + // multiply rather than on the base + span add. + let err = layout( + &elf, + &[RuntimePageRange { + base: top_aligned_base, + count: 1 << 60, + }], + usize::MAX, + ) + .expect_err("an overflowing runtime range must be rejected"); + assert!( + matches!(&err, Error::MalformedPageLayout(m) if m.contains("overflows")), + "expected an overflow rejection, got: {err}" + ); + + // And the base + span add: a count that fits in u64 on its own but pushes + // the range past the top of the address space. + let err = layout( + &elf, + &[RuntimePageRange { + base: top_aligned_base, + count: 2, + }], + usize::MAX, + ) + .expect_err("a range running off the end of the address space must be rejected"); + assert!( + matches!(&err, Error::MalformedPageLayout(m) if m.contains("overflows")), + "expected an overflow rejection, got: {err}" + ); +} diff --git a/prover/src/tests/page_offset_forgery_poc.rs b/prover/src/tests/page_offset_forgery_poc.rs new file mode 100644 index 000000000..5e2e24d78 --- /dev/null +++ b/prover/src/tests/page_offset_forgery_poc.rs @@ -0,0 +1,808 @@ +//! End-to-end regression tests for two ways a prover could break the memory +//! argument's one-genesis-token-per-address invariant. Both were demonstrated as +//! working forgeries against `origin/main` (b082f9f6) and are now closed. +//! +//! **Route 1 — free `OFFSET` (arbitrary byte, arbitrary address).** A +//! private-input PAGE's `OFFSET` was a free main-trace column: `create_page_air` +//! builds PAGE with `EmptyConstraints`, no constraint references `cols::OFFSET`, +//! and `VmAirs::new` skipped `with_preprocessed` for `is_private_input` pages. The +//! Memory-bus address is `address_lo = page_base_lo + OFFSET`, so a row could be +//! pointed at any address sharing the page's high limb. Closed by preprocessing +//! `OFFSET` (only — `INIT` is the private input and stays main-trace). +//! +//! **Route 2 — duplicate page coverage (forces a chosen address to read `0`).** +//! Survived route 1's fix, and needs no private input at all. Nothing is forged at +//! the commitment layer: the prover declares a `runtime_page_ranges` entry over an +//! address the ELF already covers, and the injected zero-init page's `OFFSET` +//! *and* `INIT` match the shipped static zero-page commitment exactly. The address +//! then has two genesis tokens, and the two pages' rows swap which one each +//! consumes. Closed by rejecting duplicate page bases during the verifier's layout +//! reconstruction. +//! +//! The one-line distinction: preprocessing `OFFSET` restores "one row per address +//! *within* a page"; the duplicate-base check restores "one page per address". +//! Both are needed. +//! +//! The guest loads 8 bytes out of its own ELF `.data`, spills them to the stack +//! and commits them, so the proof's `public_output` is a direct function of the +//! ELF image — which the verifier binds via that data page's preprocessed +//! commitment. Each forgery's claim is that the proof verifies against the +//! *unmodified* ELF while reporting a different output. +//! +//! Run under **production** proof options, not `default_test_options()`, so none +//! of this can be written off as an artefact of a low-query configuration. + +use crypto::fiat_shamir::default_transcript::DefaultTranscript; +use stark::proof::options::ProofOptions; +use stark::prover::{IsStarkProver, Prover}; + +use crate::statement::{StatementKind, absorb_statement}; +use crate::tables::bitwise::{cols as bw_cols, row_index as bw_row_index}; +use crate::tables::page::cols as page_cols; +use crate::tables::trace_builder::Traces; +use crate::tables::types::{FE, VmTable}; +use crate::test_utils::{E, asm_elf_bytes}; +use crate::{MaxRowsConfig, VmAirs, VmProof}; + +use executor::elf::Elf; +use executor::vm::execution::Executor; + +/// The 8 bytes the PoC guest keeps in `.data` (little-endian `.dword`). +const SECRET: [u8; 8] = [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]; + +/// The byte we forge in its place. +const FORGED_BYTE: u8 = 0xEE; + +/// The PRODUCTION options: exactly what the public `crate::verify` uses +/// (`GoldilocksCubicProofOptions::with_blowup(2)`, 128-bit security target). +/// Deliberately not `default_test_options()` — nobody should be able to write +/// this off as an artefact of a 3-query toy configuration. +fn opts() -> ProofOptions { + crate::GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 is valid") +} + +/// Raw-file offset of `SECRET` inside the ELF, plus the virtual address that +/// offset maps to (via the containing PT_LOAD program header). +fn locate_secret(elf_bytes: &[u8]) -> (usize, u64) { + let file_off = elf_bytes + .windows(SECRET.len()) + .position(|w| w == SECRET) + .expect("SECRET pattern not found in ELF"); + + let rd_u16 = |o: usize| u16::from_le_bytes(elf_bytes[o..o + 2].try_into().unwrap()); + let rd_u32 = |o: usize| u32::from_le_bytes(elf_bytes[o..o + 4].try_into().unwrap()); + let rd_u64 = |o: usize| u64::from_le_bytes(elf_bytes[o..o + 8].try_into().unwrap()); + + let e_phoff = rd_u64(32) as usize; + let e_phentsize = rd_u16(54) as usize; + let e_phnum = rd_u16(56) as usize; + const PT_LOAD: u32 = 1; + + for i in 0..e_phnum { + let ph = e_phoff + i * e_phentsize; + if rd_u32(ph) != PT_LOAD { + continue; + } + let p_offset = rd_u64(ph + 8) as usize; + let p_vaddr = rd_u64(ph + 16); + let p_filesz = rd_u64(ph + 32) as usize; + if file_off >= p_offset && file_off + SECRET.len() <= p_offset + p_filesz { + return (file_off, p_vaddr + (file_off - p_offset) as u64); + } + } + panic!("SECRET is not inside any PT_LOAD segment"); +} + +/// One repointed private-input PAGE row. +struct Forge { + /// The address whose genesis byte we overwrite. + target_addr: u64, + /// The byte the forged init token carries. + forged: u8, + /// The byte the honest (preprocessed-bound) init token carries; the + /// repointed row's PAGE-C4 consumes it so the bus still balances. + real: u8, +} + +/// How the malicious prover deviates from an honest trace. +/// +/// **Which of these can be refused at prove time.** `commit_main_trace` rebuilds a +/// table's preprocessed Merkle tree and compares it to the AIR's commitment, so any +/// tamper touching a PREPROCESSED column may be rejected before a proof exists — +/// non-deterministically, because a warm tree cache skips that check (see +/// `proof_or_prover_refusal`). Tampers touching only main-trace columns cannot be. +/// +/// - `RepointPrivateRow` rewrites `OFFSET` — preprocessed since the fix. **At risk.** +/// - `DirectInitOnHonestPage` rewrites `INIT` on an ELF-data page, where the +/// preprocessed columns are `OFFSET` *and* `INIT`. **At risk.** +/// - Injecting a duplicate zero page writes only `FINI`. Not at risk. +/// - No tamper at all. Not at risk. +/// +/// Anything at risk must go through `proof_or_prover_refusal`, never `.expect(..)`. +enum Tamper { + /// Repoint one private-input PAGE row (the hole under test). + RepointPrivateRow(Forge), + /// Overwrite the target byte's INIT directly on its own ELF-data PAGE. + /// This is the "obvious" attack, and it is the CONTROL: that page IS + /// preprocessed, so its INIT column is pinned by a per-page Merkle root + /// recomputed by the verifier from the ELF. It must be rejected. + DirectInitOnHonestPage { target_addr: u64, forged: u8 }, +} + +/// A malicious prover. Everything is the production pipeline; the only +/// deviations are (a) the execution logs may come from a different ELF than +/// the one whose identity/preprocessed roots are used, and (b) `forge` +/// rewrites one PAGE row. +fn craft_proof( + honest_elf: &[u8], + run_elf: &[u8], + private_inputs: &[u8], + forge: Option, +) -> Result { + let options = opts(); + + // Identity + all preprocessed roots come from the HONEST ELF. + let program = Elf::load(honest_elf).expect("honest ELF load"); + + // Execution logs come from whatever `run_elf` is. + let run_program = Elf::load(run_elf).expect("run ELF load"); + let executor = + Executor::new(&run_program, private_inputs.to_vec()).expect("executor construction"); + let result = executor.run().expect("run"); + + let max_rows = MaxRowsConfig::default(); + let mut traces = Traces::from_elf_and_logs( + &program, + &result.logs, + &max_rows, + private_inputs, + #[cfg(feature = "disk-spill")] + stark::storage_mode::StorageMode::Ram, + ) + .expect("trace build"); + + match forge { + Some(Tamper::RepointPrivateRow(f)) => apply_forge(&mut traces, &f), + Some(Tamper::DirectInitOnHonestPage { + target_addr, + forged, + }) => apply_direct_init_tamper(&mut traces, target_addr, forged), + None => {} + } + + let table_counts = traces.table_counts(); + let airs = VmAirs::new( + &program, + &options, + false, + &traces.page_configs, + &table_counts, + None, + true, + None, + None, + None, + ); + + let runtime_page_ranges = traces.runtime_page_ranges(); + let num_private_input_pages = traces + .page_configs + .iter() + .filter(|c| c.is_private_input) + .count(); + + let mut transcript = DefaultTranscript::::new(&[]); + absorb_statement( + &mut transcript, + StatementKind::Monolithic, + honest_elf, + &traces.public_output_bytes, + &table_counts, + num_private_input_pages, + &runtime_page_ranges, + options.fri_final_poly_log_degree, + ); + + let proof = Prover::multi_prove( + airs.air_trace_pairs(&mut traces), + &mut transcript, + #[cfg(feature = "disk-spill")] + stark::storage_mode::StorageMode::Ram, + )?; + + Ok(VmProof { + proof, + runtime_page_ranges, + table_counts, + public_output: traces.public_output_bytes.clone(), + num_private_input_pages, + }) +} + +/// Repoint one unused private-input PAGE row at `f.target_addr` so that it +/// PROVIDES `(0, target, ts=0, forged)` on the Memory bus and CONSUMES the +/// honest `(0, target, ts=0, real)` token in its place. +fn apply_forge(traces: &mut Traces, f: &Forge) { + let (page_idx, page_base) = traces + .page_configs + .iter() + .enumerate() + .find(|(_, c)| c.is_private_input) + .map(|(i, c)| (i, c.page_base)) + .expect("a private-input page must exist"); + + assert_eq!( + page_base >> 32, + f.target_addr >> 32, + "address_hi is a constant per page, so the target must share it" + ); + + // Any private-input byte the guest never reads. Row 4096 is well past the + // 4-byte length prefix and the (tiny) input payload. + let row = 4096usize; + { + let page = &traces.pages[page_idx].main_table; + assert_eq!(*page.get(row, page_cols::INIT), FE::zero()); + assert_eq!(*page.get(row, page_cols::FINI), FE::zero()); + assert_eq!(*page.get(row, page_cols::TIMESTAMP_LO), FE::zero()); + assert_eq!(*page.get(row, page_cols::TIMESTAMP_HI), FE::zero()); + assert_eq!(*page.get(row, page_cols::OFFSET), FE::from(row as u64)); + } + + let page = &mut traces.pages[page_idx].main_table; + // address_lo = page_base_lo + OFFSET ⇒ OFFSET = target - page_base (in F_p). + page.set( + row, + page_cols::OFFSET, + FE::from(f.target_addr) - FE::from(page_base), + ); + page.set_byte(row, page_cols::INIT, f.forged); + page.set_byte(row, page_cols::FINI, f.real); + // TIMESTAMP stays 0: PAGE-C4 then consumes the honest genesis token, which + // PAGE-C3 hardcodes at ts = 0. + + // The row's ARE_BYTES[init, fini] send moved from (0, 0) to (forged, real); + // rebalance the BITWISE receiver multiplicities to match. + move_are_bytes_multiplicity(traces, (0, 0), (f.forged, f.real)); +} + +/// Move one unit of `MU_ARE_BYTES` from the pair `from` to the pair `to`, so +/// the ARE_BYTES bus stays balanced after a PAGE row's `(init, fini)` changed. +fn move_are_bytes_multiplicity(traces: &mut Traces, from: (u8, u8), to: (u8, u8)) { + let bw = &mut traces.bitwise.main_table; + let dec = bw_row_index(from.0, from.1, 0); + let inc = bw_row_index(to.0, to.1, 0); + assert_ne!(dec, inc); + let old_dec = *bw.get(dec, bw_cols::MU_ARE_BYTES); + assert_ne!(old_dec, FE::zero(), "source pair must have multiplicity"); + bw.set(dec, bw_cols::MU_ARE_BYTES, old_dec - FE::one()); + let old_inc = *bw.get(inc, bw_cols::MU_ARE_BYTES); + bw.set(inc, bw_cols::MU_ARE_BYTES, old_inc + FE::one()); +} + +/// CONTROL tamper: rewrite the target byte's INIT on its own (preprocessed) +/// ELF-data PAGE. The Memory bus balances perfectly afterwards — the page +/// simply provides the forged genesis token that MEMW consumes — so if this is +/// rejected, the rejection can only come from the preprocessed commitment. +fn apply_direct_init_tamper(traces: &mut Traces, target_addr: u64, forged: u8) { + use crate::tables::page::{offset_in_page, page_base_for_address}; + + let base = page_base_for_address(target_addr); + let offset = offset_in_page(target_addr); + let page_idx = traces + .page_configs + .iter() + .position(|c| c.page_base == base) + .expect("target page must exist"); + assert!( + !traces.page_configs[page_idx].is_private_input, + "the control must target an ELF-data page, not the private page" + ); + assert!( + traces.page_configs[page_idx].init_values.is_some(), + "the control must target a page whose INIT is ELF-derived and committed" + ); + + let (old_init, fini) = { + let page = &traces.pages[page_idx].main_table; + let byte_at = |col: usize| -> u8 { + u8::try_from(page.get(offset, col).to_raw()).expect("column holds a byte") + }; + (byte_at(page_cols::INIT), byte_at(page_cols::FINI)) + }; + traces.pages[page_idx] + .main_table + .set_byte(offset, page_cols::INIT, forged); + move_are_bytes_multiplicity(traces, (old_init, fini), (forged, fini)); +} + +/// Unwrap a crafted proof, or signal that the prover refused to build it. +/// +/// `None` means `multi_prove` rejected the trace outright. That is a legitimate +/// outcome for **any tamper that touches a PREPROCESSED column**, and which of the +/// two layers fires is not deterministic: `commit_main_trace` caches precomputed +/// Merkle trees keyed by *the expected root* and skips the rebuild check on a hit +/// (`crypto/stark/src/prover.rs:1161-1170`). Cold cache — a fresh CI runner — the +/// tree is rebuilt from the tampered column, the root disagrees, and the prover +/// refuses. Warm cache — a local run that already proved something honest — the +/// correct cached tree is substituted, the proof is built, and the verifier is left +/// to reject it. CI failed on exactly this asymmetry. +/// +/// So a rejection test must accept both. A caller may only `.expect(..)` success +/// when its tamper touches main-trace columns alone; see `Tamper`. +fn proof_or_prover_refusal( + crafted: Result, +) -> Option { + match crafted { + Ok(proof) => Some(proof), + Err(e) => { + assert!( + matches!( + e, + stark::prover::ProvingError::PrecomputedCommitmentMismatch + ), + "the tampered trace must be refused for its preprocessed commitment, \ + not for some unrelated proving error: {e:?}" + ); + None + } + } +} + +/// Did the verifier accept this proof? +/// +/// A rejection now arrives in two shapes: `Ok(false)` when a check inside the +/// STARK verification fails, and `Err(MalformedPageLayout)` when the page layout +/// is refused before any proof is checked at all. Both mean "not accepted", and +/// collapsing them here keeps the tests from having to care which fired. +fn verifier_accepts(proof: &VmProof, elf: &[u8]) -> bool { + match crate::verify_with_options(proof, elf, &opts(), None, None) { + Ok(accepted) => accepted, + Err(crate::Error::MalformedPageLayout(_)) => false, + Err(e) => panic!("verification failed for an unexpected reason: {e}"), + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +/// Sanity: the guest commits its own `.data` bytes, and the harness used +/// honestly produces a genuinely valid proof. Guards against a vacuous PoC. +#[test] +fn poc_control_honest_harness_verifies() { + let elf = asm_elf_bytes("poc_rodata_commit"); + let proof = craft_proof(&elf, &elf, &[0u8], None) + .expect("no tamper at all: every preprocessed column is honest, so proving cannot fail"); + assert_eq!( + proof.public_output, + SECRET.to_vec(), + "guest must commit its .data bytes" + ); + assert!( + verifier_accepts(&proof, &elf), + "honest use of the harness must verify" + ); + assert_eq!( + proof.num_private_input_pages, 1, + "one byte of private input must create exactly one private page" + ); +} + +/// NEGATIVE CONTROL: run the patched program but do NOT repoint a PAGE row. +/// The genesis token the MEMW chain consumes at `secret` then has no provider +/// (the honest page provides the real byte), so the bus must not balance. +#[test] +fn poc_negative_control_forged_run_without_repointed_row_fails() { + let honest = asm_elf_bytes("poc_rodata_commit"); + let (file_off, _addr) = locate_secret(&honest); + let mut patched = honest.clone(); + patched[file_off] = FORGED_BYTE; + + // No tamper: the forged *run* changes FINI/TIMESTAMP (main trace) but the page's + // OFFSET/INIT still come from the honest ELF, so proving cannot fail here. + let proof = craft_proof(&honest, &patched, &[0u8], None) + .expect("the patched run still proves; the verifier must be the one to reject it"); + assert_eq!( + proof.public_output[0], FORGED_BYTE, + "the patched run must commit the forged byte" + ); + assert!( + !verifier_accepts(&proof, &honest), + "without the repointed PAGE row this proof must be rejected" + ); +} + +/// REGRESSION (route 1 — free `OFFSET`): repointing a private-input PAGE row at +/// an arbitrary address must not produce a verifying proof. +/// +/// On `origin/main` this was ACCEPTED against the unmodified ELF while claiming a +/// `public_output` the program cannot produce. `VmAirs::new` now preprocesses +/// `OFFSET`, so the repointed column no longer matches the commitment. +/// +/// The forgery can die at either of two layers and which one fires depends on +/// process state, so both are accepted. `commit_main_trace` caches precomputed +/// Merkle trees keyed by *the expected root* and skips the rebuild check on a hit +/// (`crypto/stark/src/prover.rs:1161-1170`): with a cold cache the prover itself +/// refuses, with a warm one it substitutes the correct cached tree and leaves the +/// verifier to reject. Asserting only one would make this pass or fail on test +/// ordering. +#[test] +fn forged_private_page_offset_is_rejected() { + let honest = asm_elf_bytes("poc_rodata_commit"); + let (file_off, addr) = locate_secret(&honest); + let mut patched = honest.clone(); + patched[file_off] = FORGED_BYTE; + + let crafted = craft_proof( + &honest, + &patched, + &[0u8], + Some(Tamper::RepointPrivateRow(Forge { + target_addr: addr, + forged: FORGED_BYTE, + real: SECRET[0], + })), + ); + + // Repointing rewrites OFFSET, which is preprocessed after the fix, so the + // prover may refuse outright — that is a rejection too. + let Some(proof) = proof_or_prover_refusal(crafted) else { + return; + }; + + // Non-vacuity: the proof really does claim the forged byte. + assert_eq!( + proof.public_output[0], FORGED_BYTE, + "forged proof must claim the forged byte" + ); + assert_ne!(proof.public_output, SECRET.to_vec()); + + assert!( + !verifier_accepts(&proof, &honest), + "SOUNDNESS REGRESSION: the verifier accepted a proof whose public output the \ + program cannot produce — a private-input PAGE row was repointed via its \ + OFFSET column. OFFSET must stay preprocessed (see `VmAirs::new`)." + ); +} + +/// SECOND NEGATIVE CONTROL — isolates the defense being bypassed. +/// +/// Same forged execution, but instead of repointing a private-input row we +/// overwrite INIT directly on the target byte's own ELF-data PAGE. The Memory +/// bus balances perfectly this way (that page simply provides the forged +/// genesis token MEMW consumes), so the ONLY thing that can reject it is that +/// page's preprocessed commitment, which the verifier recomputes from the ELF. +/// +/// It is rejected — which is the point: the preprocessed commitment does its +/// job on ELF-data pages. The private-input page was the sole bypass, precisely +/// because `VmAirs::new` gave it no commitment at all. +/// +/// `INIT` is itself a preprocessed column on an ELF-data page (`OFFSET` *and* +/// `INIT`, `NUM_PREPROCESSED_COLS = 2`), so this tamper can be caught at either +/// layer — see `proof_or_prover_refusal`. Prover-side refusal is if anything the +/// cleaner outcome; what the test pins is that the commitment rejects the rewrite, +/// not which stage notices. +#[test] +fn poc_negative_control_direct_init_tamper_on_preprocessed_page_fails() { + let honest = asm_elf_bytes("poc_rodata_commit"); + let (file_off, addr) = locate_secret(&honest); + let mut patched = honest.clone(); + patched[file_off] = FORGED_BYTE; + + let crafted = craft_proof( + &honest, + &patched, + &[0u8], + Some(Tamper::DirectInitOnHonestPage { + target_addr: addr, + forged: FORGED_BYTE, + }), + ); + + let Some(proof) = proof_or_prover_refusal(crafted) else { + return; + }; + assert_eq!(proof.public_output[0], FORGED_BYTE); + + assert!( + !verifier_accepts(&proof, &honest), + "the preprocessed commitment must reject a direct INIT rewrite" + ); +} + +/// REACHABILITY on the workload that matters. +/// +/// The ethrex block guest reads its ENTIRE `ProgramInput` through +/// `get_private_input()` (`executor/programs/rust/ethrex/src/main.rs:8`), so +/// every real block proof carries private-input pages. This asserts it through +/// the production function itself — `private_input_page_count` is what the +/// trace builder uses to classify pages (`trace_builder.rs:2615`) and what the +/// verifier's `num_private_input_pages` is compared against. +/// +/// Each such page contributes 2^18 = 262,144 rows whose `OFFSET` is free. +#[test] +fn poc_real_ethrex_inputs_produce_private_input_pages() { + use crate::tables::page::{DEFAULT_PAGE_SIZE, private_input_page_count}; + + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + + let mut checked = 0usize; + for name in [ + "ethrex_empty_block", + "ethrex_5_transfers", + "ethrex_10_transfers", + "ethrex_bench_4", + ] { + let path = root.join(format!("executor/tests/{name}.bin")); + let Ok(bytes) = std::fs::read(&path) else { + continue; // fixture not present in this checkout + }; + let pages = private_input_page_count(&bytes); + println!( + "{name}: {} bytes -> {pages} private-input page(s) = {} free-OFFSET rows", + bytes.len(), + pages * DEFAULT_PAGE_SIZE + ); + assert!( + pages > 0, + "{name} must produce at least one private-input page" + ); + checked += 1; + } + assert!(checked > 0, "no ethrex fixture found to check"); + + // Sanity on the classifier: page 0 of that span is classified private. + assert!(crate::tables::page::is_private_input_page( + executor::vm::memory::PRIVATE_INPUT_START_INDEX, + 1 + )); +} + +// ============================================================================= +// SECOND ROUTE: duplicate page coverage — survives the OFFSET fix +// ============================================================================= +// +// Pinning OFFSET restores "one row per address WITHIN a page". It does not +// restore "one page per address". `page_configs_from_elf_and_runtime` +// (`trace_builder.rs:4149-4171`) builds a Vec, appends one zero-init config per +// entry of the prover-supplied `runtime_page_ranges`, sorts by page_base, and +// never dedupes; `verify_proof_parts` validates `table_counts` and +// `num_private_input_pages` and passes `runtime_page_ranges` through untouched. +// So a prover can declare a second, zero-init page over an address the ELF +// already covers. Nothing is forged at the commitment layer — the injected page +// is an ordinary zero page whose OFFSET *and* INIT match the shipped static +// zero-page commitment — yet the address now has two genesis tokens. + +/// Inject a duplicate zero-init PAGE over `base`, which an ELF-data page +/// already covers. When `consume` is `Some((offset, real))`, that row is set to +/// consume the ELF page's genesis token `(base+offset, ts=0, real)`; otherwise +/// every row self-cancels. +fn inject_duplicate_zero_page(traces: &mut Traces, base: u64, consume: Option<(usize, u8)>) { + use crate::tables::page::{DEFAULT_PAGE_SIZE, PageConfig, generate_page_trace_from_dense}; + + // Insert directly after the ELF config for `base`, matching the verifier's + // STABLE `sort_by_key(page_base)` — ELF configs are pushed before runtime + // ones, so the ELF page wins the tie. + let elf_idx = traces + .page_configs + .iter() + .position(|c| c.page_base == base) + .expect("an ELF page for this base must already exist"); + assert!( + traces.page_configs[elf_idx].init_values.is_some(), + "duplicate must shadow an ELF-data page" + ); + + let dup_cfg = PageConfig::zero_init(base); + let mut dup_trace = generate_page_trace_from_dense(&dup_cfg, None, false); + if let Some((offset, real)) = consume { + dup_trace.main_table.set_byte(offset, page_cols::FINI, real); + } + traces.page_configs.insert(elf_idx + 1, dup_cfg); + traces.pages.insert(elf_idx + 1, dup_trace); + + // The injected table sends ARE_BYTES[init, fini] on every row: (0,0) + // throughout, except the one compensating row (0, real). + let bw = &mut traces.bitwise.main_table; + let mut bump = |x: u8, y: u8, n: u64| { + let row = bw_row_index(x, y, 0); + let cur = *bw.get(row, bw_cols::MU_ARE_BYTES); + bw.set(row, bw_cols::MU_ARE_BYTES, cur + FE::from(n)); + }; + match consume { + Some((_, real)) => { + bump(0, 0, (DEFAULT_PAGE_SIZE - 1) as u64); + bump(0, real, 1); + } + None => bump(0, 0, DEFAULT_PAGE_SIZE as u64), + } +} + +/// Like `craft_proof`, but injects a duplicate zero page over `dup_base` after +/// the traces are built. Production prove path otherwise. +fn craft_proof_with_duplicate_page( + honest_elf: &[u8], + run_elf: &[u8], + dup_base: u64, + consume: Option<(usize, u8)>, +) -> VmProof { + let options = opts(); + let program = Elf::load(honest_elf).expect("honest ELF load"); + let run_program = Elf::load(run_elf).expect("run ELF load"); + let executor = Executor::new(&run_program, vec![]).expect("executor construction"); + let result = executor.run().expect("run"); + + let max_rows = MaxRowsConfig::default(); + let mut traces = Traces::from_elf_and_logs( + &program, + &result.logs, + &max_rows, + &[], + #[cfg(feature = "disk-spill")] + stark::storage_mode::StorageMode::Ram, + ) + .expect("trace build"); + + inject_duplicate_zero_page(&mut traces, dup_base, consume); + + let table_counts = traces.table_counts(); + let runtime_page_ranges = traces.runtime_page_ranges(); + let num_private_input_pages = traces + .page_configs + .iter() + .filter(|c| c.is_private_input) + .count(); + + // The verifier rebuilds the layout from `runtime_page_ranges`. Before the + // duplicate-page fix that rebuild reproduced our injected layout exactly, + // which is what made the attack work; now it REJECTS it. Assert that + // directly — it is the fix firing at the layer it should — and keep going so + // the test still exercises the full prove → verify path end to end. + match Traces::page_configs_from_elf_and_runtime( + &program, + &runtime_page_ranges, + num_private_input_pages, + usize::MAX, + ) { + Ok(rebuilt) => { + let ours: Vec = traces.page_configs.iter().map(|c| c.page_base).collect(); + let theirs: Vec = rebuilt.iter().map(|c| c.page_base).collect(); + assert_eq!(ours, theirs, "prover/verifier page layouts must agree"); + } + Err(crate::Error::MalformedPageLayout(msg)) => { + assert!( + msg.contains("exactly"), + "the rebuild must fail on duplicate coverage specifically: {msg}" + ); + } + Err(e) => panic!("unexpected page-layout error: {e}"), + } + + let airs = VmAirs::new( + &program, + &options, + false, + &traces.page_configs, + &table_counts, + None, + true, + None, + None, + None, + ); + + let mut transcript = DefaultTranscript::::new(&[]); + absorb_statement( + &mut transcript, + StatementKind::Monolithic, + honest_elf, + &traces.public_output_bytes, + &table_counts, + num_private_input_pages, + &runtime_page_ranges, + options.fri_final_poly_log_degree, + ); + + let proof = Prover::multi_prove( + airs.air_trace_pairs(&mut traces), + &mut transcript, + #[cfg(feature = "disk-spill")] + stark::storage_mode::StorageMode::Ram, + ) + // The injected duplicate page writes only FINI, a main-trace column, and every + // page's OFFSET/INIT stays honest — so the preprocessed check cannot fire and + // proving is guaranteed to succeed. The rejection is the verifier's to make. + .expect("duplicate-page injection touches no preprocessed column"); + + VmProof { + proof, + runtime_page_ranges, + table_counts, + public_output: traces.public_output_bytes.clone(), + num_private_input_pages, + } +} + +/// STRUCTURAL REGRESSION: one address range covered by TWO PAGE tables must be +/// refused, even when the execution is honest and every injected row +/// self-cancels. +/// +/// This is the invariant, isolated from any forgery: "one page per address". It +/// passed on the pre-fix branch — the layout was simply unvalidated — and is the +/// test that flips to a failure if the duplicate-base check is ever removed. The +/// forgery test below needs a compensating row and so could in principle be +/// blocked by something else; this one cannot. +#[test] +fn dup_structural_duplicate_page_coverage_is_rejected() { + let honest = asm_elf_bytes("poc_rodata_commit"); + let (_, addr) = locate_secret(&honest); + let base = crate::tables::page::page_base_for_address(addr); + + let proof = craft_proof_with_duplicate_page(&honest, &honest, base, None); + // The execution itself is honest, so the output is the real one; only the + // page layout is malformed. + assert_eq!(proof.public_output, SECRET.to_vec()); + assert!( + !verifier_accepts(&proof, &honest), + "SOUNDNESS REGRESSION: the verifier accepted a layout with two PAGE tables \ + over one address range. Each address must have exactly one genesis token, \ + or two rows can swap which token each consumes." + ); +} + +/// NEGATIVE CONTROL for the second route: forged run (target byte reads 0), +/// duplicate page present but every row self-cancelling, so the forged genesis +/// token has no provider. Must be rejected. +#[test] +fn dup_negative_control_without_compensating_row_fails() { + let honest = asm_elf_bytes("poc_rodata_commit"); + let (file_off, addr) = locate_secret(&honest); + let base = crate::tables::page::page_base_for_address(addr); + let mut patched = honest.clone(); + patched[file_off] = 0x00; + + let proof = craft_proof_with_duplicate_page(&honest, &patched, base, None); + assert_eq!(proof.public_output[0], 0x00); + assert!( + !verifier_accepts(&proof, &honest), + "without the compensating row this must be rejected" + ); +} + +/// REGRESSION (route 2 — duplicate page): the end-to-end forgery must not verify. +/// +/// Forged run plus the duplicate page's row for the target consuming the ELF +/// page's genesis token. On the pre-fix branch — including after the OFFSET fix — +/// ELF `.data` byte `0x11` was made to read as `0x00` and the proof was ACCEPTED +/// against the UNMODIFIED ELF. +/// +/// Strictly weaker than the OFFSET break: the injected value is always 0, because +/// a zero-init page is the only kind a prover can conjure at a chosen base. But it +/// needs no private input and no free OFFSET, which is why the OFFSET fix alone +/// did not stop it. +#[test] +fn dup_duplicate_page_forgery_is_rejected() { + let honest = asm_elf_bytes("poc_rodata_commit"); + let (file_off, addr) = locate_secret(&honest); + let base = crate::tables::page::page_base_for_address(addr); + let offset = crate::tables::page::offset_in_page(addr); + let mut patched = honest.clone(); + patched[file_off] = 0x00; + + let proof = craft_proof_with_duplicate_page(&honest, &patched, base, Some((offset, SECRET[0]))); + + // Non-vacuity: the proof really does report the zeroed byte. + assert_eq!(proof.public_output[0], 0x00, "forged output"); + assert_ne!(proof.public_output, SECRET.to_vec()); + + assert!( + !verifier_accepts(&proof, &honest), + "SOUNDNESS REGRESSION: an ELF .data byte was made to read as 0 and the proof \ + verified against the unmodified ELF, via a duplicate zero-init page over an \ + address the ELF already covers." + ); +} diff --git a/prover/src/tests/page_tests.rs b/prover/src/tests/page_tests.rs index fe0c534e8..1a223644d 100644 --- a/prover/src/tests/page_tests.rs +++ b/prover/src/tests/page_tests.rs @@ -164,7 +164,9 @@ fn elf_data_page_commitments( &elf, &vm_proof.runtime_page_ranges, vm_proof.num_private_input_pages, - ); + usize::MAX, + ) + .expect("honest page layout"); page_configs .iter() .filter(|c| !c.is_private_input && c.init_values.is_some()) diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index bdf94b65a..babb4fe05 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -155,7 +155,9 @@ fn verify_vm_minimal(vm_proof: &VmProof, elf_bytes: &[u8]) -> bool { &elf, &vm_proof.runtime_page_ranges, vm_proof.num_private_input_pages, - ); + usize::MAX, + ) + .expect("honest page layout"); let airs = VmAirs::new( &elf, &proof_options, @@ -1231,6 +1233,70 @@ fn test_prove_dma_memcpy_rust_guest() { ); } +/// Positive control for the fixture the memset forgery tests tamper with. Those +/// tests assert that verification FAILS, so without this they would also pass if +/// the untampered trace never verified in the first place. +#[test] +fn test_prove_dma_memset_min_rust_guest() { + 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/dma_memset_min.elf")) + .expect("dma_memset_min.elf not found — build its make target"); + + let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); + assert!( + verify_vm_minimal(&proof, &elf_bytes), + "DMA memset guest should verify" + ); + assert_eq!(proof.public_output, [0x3Cu8; 43]); +} + +/// End-to-end memset: the guest exercises every row-schedule boundary (empty, +/// sub-tail, exact widths, the per-ecall cap, multi-chunk, a masked wide fill, +/// and an unaligned page-crossing destination), so a passing proof covers the +/// DMA_SET trace, its bus balance, and the fill-byte bound together. +#[test] +fn test_prove_dma_memset_cases_rust_guest() { + 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/dma_memset_cases.elf")) + .expect("dma_memset_cases.elf not found — build its make target"); + + let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); + assert!( + verify_vm_minimal(&proof, &elf_bytes), + "DMA memset guest should verify" + ); + assert_eq!(proof.public_output, b"dma-memset-ok"); +} + +/// memmove rides the memcpy ecall unchanged. The interesting case is a forward +/// overlap longer than one 256-byte chunk: the stub must walk chunks backwards, +/// or an earlier chunk clobbers source bytes a later one still needs. +#[test] +fn test_prove_dma_memmove_cases_rust_guest() { + 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/dma_memmove_cases.elf")) + .expect("dma_memmove_cases.elf not found — build its make target"); + + let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); + assert!( + verify_vm_minimal(&proof, &elf_bytes), + "DMA memmove guest should verify" + ); + assert_eq!(proof.public_output, b"dma-memmove-ok"); +} + #[test] fn test_prove_dma_memcpy_cases_rust_guest() { let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -1327,6 +1393,184 @@ fn test_prove_dma_memcpy_forged_wide_tail_rejected() { assert_dma_forgery_rejected(&elf, &mut traces, "TAIL must equal count < 8"); } +/// Soundness: the seven high lanes of a wide DMA_SET write cannot carry a byte +/// other than `fill`. `fill_wide` has no counterpart in the memcpy table — it is +/// the column that lets one write tuple serve both widths — so it is the one +/// piece of this AIR with no already-tested ancestor. +#[test] +fn test_prove_dma_memset_forged_fill_wide_rejected() { + use crate::tables::dma_set::cols as dma_set_cols; + + let (elf, mut traces) = dma_memset_fixture(); + let forged_row = dma_set_row_matching(&traces, |_first, end, tail| !end && !tail); + let original = *traces + .dma_set + .main_table + .get(forged_row, dma_set_cols::FILL_WIDE); + traces.dma_set.main_table.set( + forged_row, + dma_set_cols::FILL_WIDE, + original + FieldElement::::one(), + ); + + assert_dma_forgery_rejected( + &elf, + &mut traces, + "lanes 1..7 of a wide fill must carry the same byte as lane 0", + ); +} + +/// Soundness: `fill` rides the DmaSetNext chain, so an intermediate row cannot +/// switch to a different byte mid-fill. This is the anchor that makes one +/// register read on the first row bind every subsequent write. +#[test] +fn test_prove_dma_memset_forged_intermediate_fill_rejected() { + use crate::tables::dma_set::cols as dma_set_cols; + + let (elf, mut traces) = dma_memset_fixture(); + // `!tail` matters: on a one-byte row `fill_wide` must stay zero, so shifting + // both lanes there would trip constraint 9 locally and the test would prove + // something else. + let forged_row = dma_set_row_matching(&traces, |first, end, tail| !first && !end && !tail); + // Shift both lanes so the row stays internally consistent (constraint 10 + // still holds); only the chain token and the MEMW write disagree. + for column in [dma_set_cols::FILL, dma_set_cols::FILL_WIDE] { + let original = *traces.dma_set.main_table.get(forged_row, column); + traces.dma_set.main_table.set( + forged_row, + column, + original + FieldElement::::one(), + ); + } + + assert_dma_forgery_rejected( + &elf, + &mut traces, + "an intermediate row must keep the fill byte its predecessor sent", + ); +} + +#[test] +fn test_prove_dma_memset_forged_early_end_rejected() { + use crate::tables::dma_set::cols as dma_set_cols; + + let (elf, mut traces) = dma_memset_fixture(); + let forged_row = dma_set_row_matching(&traces, |_first, end, _tail| !end); + traces + .dma_set + .main_table + .set(forged_row, dma_set_cols::END, FieldElement::one()); + + assert_dma_forgery_rejected(&elf, &mut traces, "END must be equivalent to count == 0"); +} + +/// Flipping `tail` rewrites `step` from 8 to 1, so the row's own address and +/// count arithmetic stop holding. Note this is rejected locally by the ADD +/// carries, NOT by the ALU LT that pins `tail = (count < 8)` — that bus has no +/// negative coverage here, the same gap the memcpy sibling has. +#[test] +fn test_prove_dma_memset_forged_wide_tail_rejected() { + use crate::tables::dma_set::cols as dma_set_cols; + + let (elf, mut traces) = dma_memset_fixture(); + let forged_row = dma_set_row_matching(&traces, |_first, end, tail| !end && !tail); + traces + .dma_set + .main_table + .set(forged_row, dma_set_cols::TAIL, FieldElement::one()); + + assert_dma_forgery_rejected(&elf, &mut traces, "a row's width must match its step"); +} + +/// Soundness: a one-byte row must not broadcast its fill into lanes 1..7. This +/// is the direction that matters — it is an eight-byte write where a single byte +/// was authorised. The wide-row test above covers the opposite, harmless case. +#[test] +fn test_prove_dma_memset_forged_tail_fill_wide_rejected() { + use crate::tables::dma_set::cols as dma_set_cols; + + let (elf, mut traces) = dma_memset_fixture(); + let forged_row = dma_set_row_matching(&traces, |_first, end, tail| !end && tail); + let fill = *traces + .dma_set + .main_table + .get(forged_row, dma_set_cols::FILL); + traces + .dma_set + .main_table + .set(forged_row, dma_set_cols::FILL_WIDE, fill); + + assert_dma_forgery_rejected( + &elf, + &mut traces, + "a one-byte row must not widen its write to eight lanes", + ); +} + +/// Soundness: the destination chain. The memcpy suite tampers `src`/`src_incr` +/// together; this is the memset analogue, and without it no test moves an +/// address at all. +#[test] +fn test_prove_dma_memset_forged_intermediate_destination_rejected() { + use crate::tables::dma_set::cols as dma_set_cols; + + let (elf, mut traces) = dma_memset_fixture(); + let forged_row = dma_set_row_matching(&traces, |first, end, tail| !first && !end && !tail); + + // Shift both the current destination and its locally-consistent successor. + // The row's ADD stays valid; the predecessor's DmaSetNext tuple and the + // memory write no longer match. + for column in [dma_set_cols::DST_0, dma_set_cols::DST_INCR_0] { + let original = *traces.dma_set.main_table.get(forged_row, column); + traces + .dma_set + .main_table + .set(forged_row, column, original + FieldElement::from(8u64)); + } + + assert_dma_forgery_rejected( + &elf, + &mut traces, + "an intermediate row must stay chained to its predecessor's address", + ); +} + +fn dma_memset_fixture() -> (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/dma_memset_min.elf")) + .expect("dma_memset_min.elf not found — build its make target"); + let elf = Elf::load(&elf_bytes).expect("ELF load"); + let result = Executor::new(&elf, vec![]) + .expect("executor") + .run() + .expect("execution"); + let traces = + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); + (elf, traces) +} + +fn dma_set_row_matching(traces: &Traces, predicate: impl Fn(bool, bool, bool) -> bool) -> usize { + use crate::tables::dma_set::cols as dma_set_cols; + + (0..traces.dma_set.num_rows()) + .find(|&row| { + let active = *traces.dma_set.main_table.get(row, dma_set_cols::MU) + == FieldElement::::one(); + let first = *traces.dma_set.main_table.get(row, dma_set_cols::FIRST) + == FieldElement::::one(); + let end = *traces.dma_set.main_table.get(row, dma_set_cols::END) + == FieldElement::::one(); + let tail = *traces.dma_set.main_table.get(row, dma_set_cols::TAIL) + == FieldElement::::one(); + active && predicate(first, end, tail) + }) + .expect("guest must contain the requested real DMA_SET row") +} + fn dma_memcpy_fixture() -> (Elf, Traces) { let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() @@ -1366,6 +1610,334 @@ fn dma_row_matching(traces: &Traces, predicate: impl Fn(bool, bool, bool) -> boo fn assert_dma_forgery_rejected(elf: &Elf, traces: &mut Traces, reason: &str) { assert!(!prove_and_verify_vm_minimal(elf, traces), "{reason}"); } +/// 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 the whole HINT table bus surface (Ecall receive, the x10/x11/x12 +/// register reads, the two ALU `LT` operand range-checks, the four 8-byte output MEMW +/// writes and the output byte range-checks) 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, one per selector, 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), **all three selectors** (so the AIR's `selector < 3` +/// range-check is exercised at every accepted value, not only at 0) and **read-back +/// via normal LOAD** (MEMW reads chaining to the HINT writes). Committed output = +/// XOR of the three hinted values. +#[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 inv(3) mod p, inv(5) mod n and sqrt(4) mod p (32-byte BE), + // matching the guest's one-call-per-selector loop. + use executor::vm::instruction::execution::{ + HINT_FIELD_INV, HINT_FIELD_SQRT, HINT_SCALAR_INV, compute_hint, + }; + let mut expected = [0u8; 32]; + for (hint_id, seed) in [ + (HINT_FIELD_INV, 3u8), + (HINT_SCALAR_INV, 5u8), + (HINT_FIELD_SQRT, 4u8), + ] { + let mut input = [0u8; 32]; + input[31] = seed; + let out = compute_hint(hint_id, &input); + for i in 0..32 { + expected[i] ^= out[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 @@ -1532,7 +2104,8 @@ fn test_prove_elfs_test_commit_4_wrong_pages_rejected() { .expect("Prover failed"); // Verifier uses EMPTY runtime pages → missing stack/public-output pages - let wrong_configs = Traces::page_configs_from_elf_and_runtime(&elf, &[], 0); + let wrong_configs = Traces::page_configs_from_elf_and_runtime(&elf, &[], 0, usize::MAX) + .expect("honest page layout"); let verifier_airs = crate::VmAirs::new( &elf, &proof_options, @@ -2289,7 +2862,9 @@ fn test_deep_stack_runtime_pages_roundtrip() { ) .expect("Prover failed"); // Verifier reconstructs from ELF + runtime_page_ranges hint - let verifier_configs = Traces::page_configs_from_elf_and_runtime(&elf, &runtime_page_ranges, 0); + let verifier_configs = + Traces::page_configs_from_elf_and_runtime(&elf, &runtime_page_ranges, 0, usize::MAX) + .expect("honest page layout"); let verifier_airs = crate::VmAirs::new( &elf, &proof_options, @@ -2364,7 +2939,8 @@ fn test_deep_stack_missing_pages_rejected() { ) .expect("Prover failed"); // Verifier uses EMPTY runtime_page_ranges → missing stack/heap pages - let wrong_configs = Traces::page_configs_from_elf_and_runtime(&elf, &[], 0); + let wrong_configs = Traces::page_configs_from_elf_and_runtime(&elf, &[], 0, usize::MAX) + .expect("honest page layout"); let verifier_airs = crate::VmAirs::new( &elf, &proof_options, @@ -2474,7 +3050,9 @@ fn test_heap_alloc_runtime_pages_roundtrip() { ) .expect("Prover failed"); // Verifier reconstructs from ELF + runtime hint (ranges decoded to pages) - let verifier_configs = Traces::page_configs_from_elf_and_runtime(&elf, &runtime_page_ranges, 0); + let verifier_configs = + Traces::page_configs_from_elf_and_runtime(&elf, &runtime_page_ranges, 0, usize::MAX) + .expect("honest page layout"); let verifier_airs = crate::VmAirs::new( &elf, &proof_options, diff --git a/prover/src/tests/static_commitments_tests.rs b/prover/src/tests/static_commitments_tests.rs index 01d9817e8..7b3d38e12 100644 --- a/prover/src/tests/static_commitments_tests.rs +++ b/prover/src/tests/static_commitments_tests.rs @@ -112,6 +112,40 @@ fn zero_page_static_matches_recompute_for_all_blowups() { } } +/// Same drift guard for the private-input page's OFFSET-only commitment — the +/// verifier's compiled-in anchor for every private page, and the thing that +/// stops a prover repointing those rows at arbitrary addresses. Also asserts it +/// DIFFERS from the zero-init commitment: the two cover different column sets +/// (OFFSET alone vs OFFSET+INIT), so equal bytes would mean one of the two +/// call sites is committing the wrong number of columns. +#[test] +fn private_page_static_matches_recompute_for_all_blowups() { + for &blowup in STATIC_BLOWUP_FACTORS { + let options = options_for(blowup); + let recomputed = page::compute_offset_only_commitment(&options); + let Some(static_bytes) = page::static_private_page_commitment(blowup) else { + panic!("no static private-page match arm shipped for blowup={blowup}"); + }; + assert_eq!( + static_bytes, recomputed, + "static private-page (OFFSET-only) commitment drifted for blowup={blowup}; \ + regenerate constants via \ + `cargo run --bin compute_static_commitments --release`", + ); + let from_wrapper = page::private_page_preprocessed_commitment(&options); + assert_eq!( + from_wrapper, recomputed, + "private_page_preprocessed_commitment returned a wrong value for blowup={blowup}", + ); + assert_ne!( + recomputed, + page::compute_precomputed_commitment(&page::PageConfig::zero_init(0), &options), + "OFFSET-only and OFFSET+INIT commitments must differ (blowup={blowup}); \ + equality would mean a call site commits the wrong column count", + ); + } +} + /// Asserts the page wrapper's fallback path (no static entry for this /// blowup) recomputes a commitment that matches the direct compute call. /// Ignored by default: at NON_STATIC_BLOWUP=16, the page LDE is 2^22 rows × diff --git a/prover/tests/gpu_constraint_interp_real.rs b/prover/tests/gpu_constraint_interp_real.rs index 14c75459b..38cbc2536 100644 --- a/prover/tests/gpu_constraint_interp_real.rs +++ b/prover/tests/gpu_constraint_interp_real.rs @@ -272,4 +272,6 @@ fn all_table_programs_gpu_match_cpu_oracle() { check_air(&create_ecsm_air(&opts), "ECSM"); check_air(&create_ecdas_air(&opts), "ECDAS"); check_air(&create_dma_air(&opts), "DMA"); + check_air(&create_dma_set_air(&opts), "DMA_SET"); + check_air(&create_hint_air(&opts), "HINT"); } diff --git a/syscalls/src/syscalls.rs b/syscalls/src/syscalls.rs index a8a5a3415..bd1280aa0 100644 --- a/syscalls/src/syscalls.rs +++ b/syscalls/src/syscalls.rs @@ -41,6 +41,20 @@ const DMA_MEMCPY_SYSCALL_NUMBER: usize = usize::MAX - 2; #[cfg(target_arch = "riscv64")] const DMA_MEMCPY_MAX_BYTES: usize = 256; +/// DMA memset syscall number. Must match the executor. +#[cfg(target_arch = "riscv64")] +const DMA_MEMSET_SYSCALL_NUMBER: usize = usize::MAX - 3; + +/// 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. @@ -241,6 +255,138 @@ memcpy: max_bytes = const DMA_MEMCPY_MAX_BYTES, ); +// --------------------------------------------------------------------------- +// DMA memmove symbol override +// +// Reuses the memcpy ecall unchanged — no new table, no new syscall. Each ecall +// already snapshots its whole source range before writing (all reads at T+1, +// all writes at T+2), so a single chunk has memmove semantics for free. +// +// Chunking is what breaks it: copying [0,256) -> [4,260) clobbers source bytes +// that a later forward chunk still needs. So when the destination starts inside +// the source range (src < dst < src+n) the chunks are walked from the END +// backwards; every chunk then reads bytes no earlier chunk has written yet. +// Otherwise (disjoint, or dst below src) forward chunking is already safe. +// --------------------------------------------------------------------------- + +#[cfg(target_arch = "riscv64")] +global_asm!( + r#" + .section .text.memmove,"ax",@progbits + .p2align 2 + .globl memmove + .type memmove,@function +memmove: + mv t0, a0 + beqz a2, .Ldma_memmove_done + bgeu a1, a0, .Ldma_memmove_fwd // src >= dst: forward is safe + add t2, a1, a2 + bgeu a0, t2, .Ldma_memmove_fwd // dst >= src+n: disjoint + // Overlapping with dst inside [src, src+n): walk chunks from the end. + add a0, a0, a2 + add a1, a1, a2 + mv t1, a2 +.Ldma_memmove_back_loop: + li a2, {max_bytes} + bgeu t1, a2, .Ldma_memmove_back_call + mv a2, t1 +.Ldma_memmove_back_call: + sub a0, a0, a2 + sub a1, a1, a2 + li a7, {syscall} + ecall + sub t1, t1, a2 + bnez t1, .Ldma_memmove_back_loop + j .Ldma_memmove_done +.Ldma_memmove_fwd: + mv t1, a2 +.Ldma_memmove_fwd_loop: + li a2, {max_bytes} + bgeu t1, a2, .Ldma_memmove_fwd_call + mv a2, t1 +.Ldma_memmove_fwd_call: + li a7, {syscall} + ecall + sub t1, t1, a2 + add a0, a0, a2 + add a1, a1, a2 + bnez t1, .Ldma_memmove_fwd_loop +.Ldma_memmove_done: + mv a0, t0 + ret + .size memmove, .-memmove +"#, + syscall = const DMA_MEMCPY_SYSCALL_NUMBER, + max_bytes = const DMA_MEMCPY_MAX_BYTES, +); + +// --------------------------------------------------------------------------- +// DMA memset symbol override +// +// Same shape as `memcpy` above: a strong assembly symbol that splits the fill +// into bounded DMA ecalls. `a1` carries the fill byte rather than a source +// address, so it is NOT advanced across chunks. The `andi` keeps only the low +// byte — C's `memset` takes an `int` but writes `(unsigned char)c`, and the +// executor rejects a wider value so the AIR can prove the byte bound. +// --------------------------------------------------------------------------- + +#[cfg(target_arch = "riscv64")] +global_asm!( + r#" + .section .text.memset,"ax",@progbits + .p2align 2 + .globl memset + .type memset,@function +memset: + mv t0, a0 + andi a1, a1, 255 + mv t1, a2 + beqz t1, .Ldma_memset_done +.Ldma_memset_loop: + li a2, {max_bytes} + bgeu t1, a2, .Ldma_memset_call + mv a2, t1 +.Ldma_memset_call: + li a7, {syscall} + ecall + sub t1, t1, a2 + add a0, a0, a2 + bnez t1, .Ldma_memset_loop +.Ldma_memset_done: + mv a0, t0 + ret + .size memset, .-memset +"#, + syscall = const DMA_MEMSET_SYSCALL_NUMBER, + max_bytes = const DMA_MEMCPY_MAX_BYTES, +); + +/// Ask the host for a non-constraining hint (modular inverse/sqrt). +/// `hint_id` selects the operation ([`HINT_FIELD_INV`]/[`HINT_SCALAR_INV`]/ +/// [`HINT_FIELD_SQRT`]); `input`/`out` are 32-byte **big-endian** field/scalar +/// elements — k256's own serialization, so consumers pass `to_bytes()` straight +/// through. Note this differs from [`ecsm_mul`], which is little-endian. +/// The result is UNTRUSTED — the caller MUST verify it in-guest (e.g. `x·inv == 1`) +/// AND recompute in software on failure, since this ecall adds no correctness +/// constraint and the prover chooses the returned bytes. +#[cfg(target_arch = "riscv64")] +pub fn hint(hint_id: usize, out: &mut [u8; 32], input: &[u8; 32]) { + unsafe { + asm!( + "ecall", + in("a0") hint_id, // x10 = hint selector + in("a1") input.as_ptr(), // x11 = input address (32-byte BE) + in("a2") out.as_mut_ptr(), // x12 = output address (32-byte BE) + in("a7") HINT_SYSCALL_NUMBER, + ) + } +} + +#[cfg(not(target_arch = "riscv64"))] +pub fn hint(_hint_id: usize, _out: &mut [u8; 32], _input: &[u8; 32]) { + unimplemented!("syscalls are only implemented for riscv64 targets"); +} + // ============================================================================= // Stub implementations for unsupported std functions // These functions are required by Rust's std zkvm module but are not supported diff --git a/tooling/ethrex-tests/Cargo.lock b/tooling/ethrex-tests/Cargo.lock index 250e2411f..4295b4402 100644 --- a/tooling/ethrex-tests/Cargo.lock +++ b/tooling/ethrex-tests/Cargo.lock @@ -875,6 +875,7 @@ name = "executor" version = "0.1.0" dependencies = [ "ecsm", + "k256", "rustc-demangle", "thiserror 1.0.69", ]