From 4da4606dd33cfb67a832a0c00dcbd5d99f81cc4b Mon Sep 17 00:00:00 2001 From: MauroFab Date: Thu, 16 Jul 2026 18:53:19 -0300 Subject: [PATCH 01/10] perf(syscalls): absorb keccak sponge input in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The state is the only buffer: input bytes XOR directly into the rate lanes at a running offset (tiny-keccak's design), padding lands in place, and the digest is read straight out of the state. This removes the staging buffer and its zeroing, the full-block copy, the 17-lane byte re-extraction, and the separate pad block — the largest slice of the ~1,322 cycles of software plumbing measured per 1-cycle keccak_permute ecall (measured for this commit alone: −1.545B cycles on the 219-query ethrex-block recursion benchmark; the residual is addressed by the follow-up commits). The whole-lane fast path gates on the input pointer's runtime alignment (the VM traps on unaligned doubleword loads); misaligned input falls back to byte-wise absorption, so correctness never depends on alignment. Digests are byte-identical to the previous implementation. --- syscalls/src/keccak.rs | 116 +++++++++++++++++++++++++---------------- 1 file changed, 72 insertions(+), 44 deletions(-) diff --git a/syscalls/src/keccak.rs b/syscalls/src/keccak.rs index 56408e339..154f1cc03 100644 --- a/syscalls/src/keccak.rs +++ b/syscalls/src/keccak.rs @@ -9,6 +9,16 @@ //! let digest = lambda_vm_syscalls::keccak::keccak256(b"hello"); //! ``` //! +//! The sponge absorbs IN PLACE: the `[u64; 25]` state is the only buffer, and +//! input bytes are XORed directly into its rate lanes at a running offset +//! (tiny-keccak's design). There is no staging block, no per-block copy, and +//! no lane re-extraction — the permutation cost is the ecall itself, so every +//! byte moved around it is pure overhead the VM retires as real cycles. +//! +//! The VM traps on unaligned doubleword loads, so the whole-lane fast path is +//! gated on the input pointer's runtime alignment; misaligned input falls back +//! to byte-wise absorption. Correctness never depends on alignment. +//! //! On a non-`riscv64` host, `keccak_permute` panics — this module is only //! meant to be used from guest programs (compiled to `riscv64im-lambda-vm-elf`). @@ -17,19 +27,25 @@ use crate::syscalls::keccak_permute; /// Keccak-256 sponge rate in bytes (1088 bits = 136 bytes; capacity = 512 bits). const RATE_BYTES: usize = 136; +/// Rate lanes (17 for r=1088). +const RATE_LANES: usize = RATE_BYTES / 8; + /// Keccak-256 domain-separator byte (per FIPS 202 / Ethereum convention). /// Note: this is plain Keccak (0x01), not SHA-3 (0x06). const DELIMITER: u8 = 0x01; -/// Last padding byte (set high bit of final rate byte). -const FINAL_PAD_BIT: u8 = 0x80; +/// Final padding bit (high bit of the last rate byte), pre-shifted to its +/// position in the last rate lane. When the delimiter also lands in byte 135 +/// the two XORs combine to the single-byte `0x81` pad, per pad10*1. +const FINAL_PAD_LANE_BIT: u64 = (0x80u64) << 56; -/// Incremental Keccak-256 hasher. +/// Incremental Keccak-256 hasher; the state doubles as the absorption buffer. #[derive(Clone)] pub struct Keccak256 { state: [u64; 25], - buf: [u8; RATE_BYTES], - buf_len: usize, + /// Byte offset into the rate region where the next input byte XORs in. + /// Invariant between calls: `offset < RATE_BYTES`. + offset: usize, } impl Default for Keccak256 { @@ -42,60 +58,72 @@ impl Keccak256 { pub fn new() -> Self { Self { state: [0; 25], - buf: [0; RATE_BYTES], - buf_len: 0, + offset: 0, + } + } + + /// XOR one byte into the rate region at `self.offset` (no offset advance). + #[inline(always)] + fn xor_byte_at_offset(&mut self, b: u8) { + self.state[self.offset / 8] ^= u64::from(b) << ((self.offset % 8) * 8); + } + + /// Absorb one byte, permuting when the rate fills. + #[inline(always)] + fn absorb_byte(&mut self, b: u8) { + self.xor_byte_at_offset(b); + self.offset += 1; + if self.offset == RATE_BYTES { + keccak_permute(&mut self.state); + self.offset = 0; } } /// Absorb more input into the sponge. pub fn update(&mut self, mut input: &[u8]) { - if self.buf_len > 0 { - let take = (RATE_BYTES - self.buf_len).min(input.len()); - self.buf[self.buf_len..self.buf_len + take].copy_from_slice(&input[..take]); - self.buf_len += take; - input = &input[take..]; - if self.buf_len == RATE_BYTES { - let block = self.buf; - self.absorb_block(&block); - self.buf_len = 0; + while !input.is_empty() { + // Whole-lane fast path: sponge offset on a lane boundary AND input + // pointer 8-aligned (the VM traps on unaligned doubleword loads). + // Keccak state lanes are little-endian, so an in-memory u64 load + // equals the from_le_bytes lane value. + if self.offset % 8 == 0 && (input.as_ptr() as usize) % 8 == 0 && input.len() >= 8 { + let lanes_left = (RATE_BYTES - self.offset) / 8; + let take = lanes_left.min(input.len() / 8); + let base = self.offset / 8; + for i in 0..take { + // SAFETY: `input.as_ptr()` is 8-aligned (checked above) and + // `(i + 1) * 8 <= input.len()`, so this reads 8 in-bounds + // bytes at an aligned address; any bit pattern is a valid u64. + let lane = unsafe { (input.as_ptr().add(i * 8) as *const u64).read() }; + self.state[base + i] ^= lane; + } + self.offset += take * 8; + input = &input[take * 8..]; + if self.offset == RATE_BYTES { + keccak_permute(&mut self.state); + self.offset = 0; + } + } else { + self.absorb_byte(input[0]); + input = &input[1..]; } } - while input.len() >= RATE_BYTES { - let mut block = [0u8; RATE_BYTES]; - block.copy_from_slice(&input[..RATE_BYTES]); - self.absorb_block(&block); - input = &input[RATE_BYTES..]; - } - if !input.is_empty() { - self.buf[..input.len()].copy_from_slice(input); - self.buf_len = input.len(); - } } /// Finalize the sponge and write the 32-byte digest into `output`. pub fn finalize(mut self, output: &mut [u8; 32]) { - // Pad: append delimiter byte, zeros, final pad bit at the rate boundary. - let mut last = [0u8; RATE_BYTES]; - last[..self.buf_len].copy_from_slice(&self.buf[..self.buf_len]); - last[self.buf_len] = DELIMITER; - last[RATE_BYTES - 1] |= FINAL_PAD_BIT; - self.absorb_block(&last); + // Pad in place: delimiter at the current offset, final bit at the end + // of the rate. `offset < RATE_BYTES` always holds here, so the padded + // block is exactly the one permutation below. + self.xor_byte_at_offset(DELIMITER); + self.state[RATE_LANES - 1] ^= FINAL_PAD_LANE_BIT; + keccak_permute(&mut self.state); // Squeeze the first 32 bytes (4 u64 lanes). - for (i, lane) in self.state[..4].iter().enumerate() { - output[i * 8..(i + 1) * 8].copy_from_slice(&lane.to_le_bytes()); + for (i, chunk) in output.chunks_exact_mut(8).enumerate() { + chunk.copy_from_slice(&self.state[i].to_le_bytes()); } } - - fn absorb_block(&mut self, block: &[u8; RATE_BYTES]) { - // XOR the block into the rate portion of the state (first 17 lanes for r=1088). - for (i, lane) in self.state.iter_mut().take(RATE_BYTES / 8).enumerate() { - let mut bytes = [0u8; 8]; - bytes.copy_from_slice(&block[i * 8..(i + 1) * 8]); - *lane ^= u64::from_le_bytes(bytes); - } - keccak_permute(&mut self.state); - } } /// One-shot Keccak-256 hash of a single byte slice. From 4754a99d1a0d985d0e0b8b799b4fe1a06b839321 Mon Sep 17 00:00:00 2001 From: MauroFab Date: Fri, 17 Jul 2026 12:15:58 -0300 Subject: [PATCH 02/10] perf(crypto): finalize keccak Merkle hashes straight into the node array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field-element Merkle backends finalized every leaf and parent hash through the `Digest::finalize` blanket: it allocates a zeroed `GenericArray`, the riscv64 keccak adapter fills a local `[u8; 32]` and copies it into that `Output`, then the caller copies the `Output` once more into its own node array. That is two 32-byte memcpys plus a 32-byte memset of pure plumbing around the single permutation each hash actually costs. Route the leaf (`hash_data`, `hash_data_from_slices`, `hash_bytes`) and parent (`hash_new_parent`) hashes of `FieldElementPairBackend` and `FieldElementVectorBackend` through a shared `hash_streamed` helper. On the riscv64 guest, when `D` is the platform keccak digest and nodes are 32 bytes, it drives the syscall sponge directly and squeezes straight into the result array — no GenericArray, no intermediate buffer, no double copy. Every other digest / node size and the entire host build keep the generic `Digest` path, so output stays byte-identical (blobs still verify). Recursion verifier guest cycles: min 42,185,756 -> 41,991,672 (-0.46%), blowup8 291,147,000 -> 273,789,625 (-5.96%); keccak permutation counts unchanged (3,025 / 134,173). --- .../backends/field_element_vector.rs | 97 ++++++++++++------- 1 file changed, 63 insertions(+), 34 deletions(-) diff --git a/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs b/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs index d60419cf4..4c4ad5645 100644 --- a/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs +++ b/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs @@ -9,6 +9,47 @@ use math::{ traits::AsBytes, }; +#[cfg(target_arch = "riscv64")] +use crate::hash::platform_keccak::PlatformKeccak256; +#[cfg(target_arch = "riscv64")] +use core::any::TypeId; +#[cfg(target_arch = "riscv64")] +use lambda_vm_syscalls::keccak::Keccak256 as SyscallKeccak256; + +/// Absorb `feed`'s byte stream into a fresh `D` and return the digest as a +/// fixed `[u8; NUM_BYTES]`. +/// +/// On the riscv64 guest, when `D` is the platform keccak digest and the node +/// is 32 bytes, this drives the syscall sponge directly and squeezes straight +/// into the result array. That skips the `Digest::finalize` blanket, which +/// allocates a zeroed `GenericArray`, has the adapter fill a local `[u8; 32]` +/// and copy it into that `Output`, then leaves the caller to copy the `Output` +/// once more into its own array — two 32-byte memcpys plus a 32-byte memset of +/// pure plumbing around the one permutation. Byte-identical to the generic +/// path; every other digest / node size (and the entire host build) takes the +/// generic path unchanged. +#[inline] +fn hash_streamed( + feed: impl Fn(&mut dyn FnMut(&[u8])), +) -> [u8; NUM_BYTES] { + #[cfg(target_arch = "riscv64")] + if NUM_BYTES == 32 && TypeId::of::() == TypeId::of::() { + let mut hasher = SyscallKeccak256::new(); + feed(&mut |bytes| hasher.update(bytes)); + let mut result = [0u8; NUM_BYTES]; + // NUM_BYTES == 32 in this branch, so the slice is exactly a [u8; 32]. + let out: &mut [u8; 32] = (&mut result[..]).try_into().unwrap(); + hasher.finalize(out); + return result; + } + + let mut hasher = D::new(); + feed(&mut |bytes| hasher.update(bytes)); + let mut result_hash = [0_u8; NUM_BYTES]; + result_hash.copy_from_slice(&hasher.finalize()); + result_hash +} + /// A backend for Merkle trees that uses fixed-size pairs of field elements. /// This is more efficient than `FieldElementVectorBackend` when the batch size is always 2, /// as it avoids Vec allocation overhead. @@ -27,7 +68,7 @@ impl Default for FieldElementPairBackend IsMerkleTreeBackend +impl IsMerkleTreeBackend for FieldElementPairBackend where F: IsField, @@ -38,21 +79,17 @@ where type Data = [FieldElement; 2]; fn hash_data(input: &[FieldElement; 2]) -> [u8; NUM_BYTES] { - let mut hasher = D::new(); - input[0].stream_bytes(&mut |b| hasher.update(b)); - input[1].stream_bytes(&mut |b| hasher.update(b)); - let mut result_hash = [0_u8; NUM_BYTES]; - result_hash.copy_from_slice(&hasher.finalize()); - result_hash + hash_streamed::(|sink| { + input[0].stream_bytes(sink); + input[1].stream_bytes(sink); + }) } fn hash_new_parent(left: &[u8; NUM_BYTES], right: &[u8; NUM_BYTES]) -> [u8; NUM_BYTES] { - let mut hasher = D::new(); - hasher.update(left); - hasher.update(right); - let mut result_hash = [0_u8; NUM_BYTES]; - result_hash.copy_from_slice(&hasher.finalize()); - result_hash + hash_streamed::(|sink| { + sink(left); + sink(right); + }) } } @@ -71,7 +108,7 @@ impl Default for FieldElementVectorBackend } } -impl FieldElementVectorBackend +impl FieldElementVectorBackend where [u8; NUM_BYTES]: From>, { @@ -80,15 +117,11 @@ where /// once, avoiding per-element allocations while staying consistent with the /// backend's hash function. pub fn hash_bytes(data: &[u8]) -> [u8; NUM_BYTES] { - let mut hasher = D::new(); - hasher.update(data); - let mut result = [0u8; NUM_BYTES]; - result.copy_from_slice(&hasher.finalize()); - result + hash_streamed::(|sink| sink(data)) } } -impl FieldElementVectorBackend +impl FieldElementVectorBackend where F: IsField, FieldElement: AsBytes, @@ -100,17 +133,15 @@ where /// `hash_data(&[a, b].concat())`: the sponge absorbs the same element bytes /// in the same order, just without the intermediate `Vec`. pub fn hash_data_from_slices(a: &[FieldElement], b: &[FieldElement]) -> [u8; NUM_BYTES] { - let mut hasher = D::new(); - for element in a.iter().chain(b.iter()) { - element.stream_bytes(&mut |bytes| hasher.update(bytes)); - } - let mut result_hash = [0_u8; NUM_BYTES]; - result_hash.copy_from_slice(&hasher.finalize()); - result_hash + hash_streamed::(|sink| { + for element in a.iter().chain(b.iter()) { + element.stream_bytes(sink); + } + }) } } -impl IsMerkleTreeBackend +impl IsMerkleTreeBackend for FieldElementVectorBackend where F: IsField, @@ -129,12 +160,10 @@ where } fn hash_new_parent(left: &[u8; NUM_BYTES], right: &[u8; NUM_BYTES]) -> [u8; NUM_BYTES] { - let mut hasher = D::new(); - hasher.update(left); - hasher.update(right); - let mut result_hash = [0_u8; NUM_BYTES]; - result_hash.copy_from_slice(&hasher.finalize()); - result_hash + hash_streamed::(|sink| { + sink(left); + sink(right); + }) } } From 917e2f9421428158c0734b3bbc267a5433efa44a Mon Sep 17 00:00:00 2001 From: MauroFab Date: Fri, 17 Jul 2026 12:27:04 -0300 Subject: [PATCH 03/10] perf(crypto,syscalls): fixed-shape keccak256_pair for Merkle parents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Merkle parent hash is exactly 64 bytes (two concatenated 32-byte nodes), which fits the keccak rate in a single block. Add `keccak256_pair` to the syscall sponge: it loads the eight data lanes straight from the two nodes, XORs the pad10*1 bits in place, runs one permutation, and squeezes four lanes — skipping the incremental sponge's per-byte absorb, running-offset bookkeeping, and separate padding pass. Route `hash_new_parent` of both keccak field-element backends to it on the riscv64 guest via the same TypeId + node-size dispatch used for the direct finalize; every other digest / node size and the host build keep the generic streaming path, so output stays byte-identical (blobs verify, keccak counts unchanged at 3,025 / 134,173). Cumulative recursion verifier guest cycles after this commit: min 41,973,461, blowup8 271,979,593. C's isolated effect vs the prior commit: min -266,369, blowup8 -14,646,310. (The prior commit is a measured regression DROP-candidate; A and C together, with B dropped, are the recommended stack.) --- .../backends/field_element_vector.rs | 40 +++++++++++++++---- syscalls/src/keccak.rs | 33 +++++++++++++++ 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs b/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs index 4c4ad5645..dcb7d8629 100644 --- a/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs +++ b/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs @@ -50,6 +50,36 @@ fn hash_streamed( result_hash } +/// Hash a Merkle parent — always exactly two concatenated 32-byte nodes. +/// +/// On the riscv64 guest, when `D` is the platform keccak digest and nodes are +/// 32 bytes, this is one fixed-shape 64-byte compression ([`keccak256_pair`]): +/// a single permutation with the input lanes and padding written straight into +/// the state, skipping the incremental sponge's per-byte absorb, running +/// offset, and separate padding pass. Byte-identical to streaming both nodes +/// through the digest; every other digest / node size (and the host build) +/// takes the generic streaming-and-finalize path unchanged. +#[inline] +fn hash_new_parent_bytes( + left: &[u8; NUM_BYTES], + right: &[u8; NUM_BYTES], +) -> [u8; NUM_BYTES] { + #[cfg(target_arch = "riscv64")] + if NUM_BYTES == 32 && TypeId::of::() == TypeId::of::() { + let l: &[u8; 32] = left[..].try_into().unwrap(); + let r: &[u8; 32] = right[..].try_into().unwrap(); + let hash = lambda_vm_syscalls::keccak::keccak256_pair(l, r); + let mut result = [0u8; NUM_BYTES]; + result.copy_from_slice(&hash); + return result; + } + + hash_streamed::(|sink| { + sink(left); + sink(right); + }) +} + /// A backend for Merkle trees that uses fixed-size pairs of field elements. /// This is more efficient than `FieldElementVectorBackend` when the batch size is always 2, /// as it avoids Vec allocation overhead. @@ -86,10 +116,7 @@ where } fn hash_new_parent(left: &[u8; NUM_BYTES], right: &[u8; NUM_BYTES]) -> [u8; NUM_BYTES] { - hash_streamed::(|sink| { - sink(left); - sink(right); - }) + hash_new_parent_bytes::(left, right) } } @@ -160,10 +187,7 @@ where } fn hash_new_parent(left: &[u8; NUM_BYTES], right: &[u8; NUM_BYTES]) -> [u8; NUM_BYTES] { - hash_streamed::(|sink| { - sink(left); - sink(right); - }) + hash_new_parent_bytes::(left, right) } } diff --git a/syscalls/src/keccak.rs b/syscalls/src/keccak.rs index 154f1cc03..36d0417b9 100644 --- a/syscalls/src/keccak.rs +++ b/syscalls/src/keccak.rs @@ -134,3 +134,36 @@ pub fn keccak256(input: &[u8]) -> [u8; 32] { hasher.finalize(&mut out); out } + +/// Keccak-256 of exactly two concatenated 32-byte nodes (64 bytes) — the fixed +/// shape of every Merkle parent hash. 64 bytes fit the 136-byte rate in one +/// block, so this skips the incremental sponge entirely: load the eight data +/// lanes straight from `left`/`right`, XOR the `pad10*1` bits in place, run one +/// permutation, squeeze four lanes. Byte-identical to feeding `left` then +/// `right` through the streaming [`Keccak256`] and finalizing. +/// +/// The nodes are only byte-aligned, so lanes are assembled with `from_le_bytes` +/// over owned arrays — never an aligned doubleword load, which the VM would trap +/// on at a misaligned address. +pub fn keccak256_pair(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { + let mut state = [0u64; 25]; + // Bytes 0..64 span rate lanes 0..8: lanes 0..4 from `left`, 4..8 from `right`. + for i in 0..4 { + let l: &[u8; 8] = left[i * 8..i * 8 + 8].try_into().unwrap(); + state[i] = u64::from_le_bytes(*l); + let r: &[u8; 8] = right[i * 8..i * 8 + 8].try_into().unwrap(); + state[4 + i] = u64::from_le_bytes(*r); + } + // pad10*1 for a 64-byte message at rate 136: delimiter at byte 64 (lane 8, + // low byte) and the final bit at the last rate byte (byte 135, lane 16 high + // byte). Both target lanes are still zero, so XOR == assignment. + state[8] ^= u64::from(DELIMITER); + state[RATE_LANES - 1] ^= FINAL_PAD_LANE_BIT; + keccak_permute(&mut state); + + let mut out = [0u8; 32]; + for (i, chunk) in out.chunks_exact_mut(8).enumerate() { + chunk.copy_from_slice(&state[i].to_le_bytes()); + } + out +} From 56451389ed8f04708819dfed1bbbb87b8fdd9e0f Mon Sep 17 00:00:00 2001 From: MauroFab Date: Fri, 17 Jul 2026 12:46:11 -0300 Subject: [PATCH 04/10] test(syscalls): differential-test the keccak sponge against sha3 on host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sponge's absorption chunking, padding, and squeezing had no unit coverage — its only oracle was end-to-end proof-blob acceptance. Host tests now inject a software Keccak-f[1600] in place of the ecall and check digest byte-identity against sha3::Keccak256: every input length through three rate blocks (all padding boundaries), 300 randomized misaligned-chunking cases, and the fixed-shape pair path against both the reference and the streaming sponge. The guest global allocator registration is now gated to riscv64 so a host `cargo test` of this crate uses the system allocator instead of aborting on the never-initialized guest heap. Guest builds unchanged (cycle counts bit-identical). --- syscalls/Cargo.toml | 5 ++ syscalls/src/allocator.rs | 5 +- syscalls/src/keccak.rs | 112 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) diff --git a/syscalls/Cargo.toml b/syscalls/Cargo.toml index cbc9c37a3..b85a52219 100644 --- a/syscalls/Cargo.toml +++ b/syscalls/Cargo.toml @@ -11,3 +11,8 @@ getrandom = { version = "0.3.4", default-features = false } getrandom_v2 = {version = "0.2.15", features = ["custom"], package = "getrandom"} lazy_static = "1.5.0" rand = "0.9.2" + +[dev-dependencies] +keccak = "0.1" +sha3 = "0.10" +critical-section = { version = "1", features = ["std"] } diff --git a/syscalls/src/allocator.rs b/syscalls/src/allocator.rs index 08dbb2d92..78b2933e5 100644 --- a/syscalls/src/allocator.rs +++ b/syscalls/src/allocator.rs @@ -1,7 +1,10 @@ use embedded_alloc::TlsfHeap as Heap; use riscv as _; -#[global_allocator] +// Only the guest routes Rust allocations through this heap; on host (e.g. +// `cargo test` for the sponge's differential tests) the attribute would hijack +// the test harness's allocator with a never-initialized heap and abort. +#[cfg_attr(target_arch = "riscv64", global_allocator)] static HEAP: Heap = Heap::empty(); const MAX_MEMORY_SIZE: usize = 0xC000_0000; diff --git a/syscalls/src/keccak.rs b/syscalls/src/keccak.rs index 36d0417b9..fb51b3a73 100644 --- a/syscalls/src/keccak.rs +++ b/syscalls/src/keccak.rs @@ -22,8 +22,18 @@ //! On a non-`riscv64` host, `keccak_permute` panics — this module is only //! meant to be used from guest programs (compiled to `riscv64im-lambda-vm-elf`). +#[cfg(not(all(test, not(target_arch = "riscv64"))))] use crate::syscalls::keccak_permute; +/// Software Keccak-f[1600] so host `cargo test` can exercise the sponge's +/// absorption/padding/squeezing against a reference implementation — the real +/// permutation is the VM ecall, unavailable off-guest. Guest builds and host +/// non-test builds are untouched. +#[cfg(all(test, not(target_arch = "riscv64")))] +fn keccak_permute(state: &mut [u64; 25]) { + keccak::f1600(state); +} + /// Keccak-256 sponge rate in bytes (1088 bits = 136 bytes; capacity = 512 bits). const RATE_BYTES: usize = 136; @@ -167,3 +177,105 @@ pub fn keccak256_pair(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { } out } + +/// Host-only differential tests: the sponge (absorption chunking, padding, +/// squeezing, the fixed-shape pair path) must produce digests byte-identical +/// to the reference `sha3::Keccak256` for every input length and every way of +/// slicing the input across `update` calls. The permutation itself is the +/// software `keccak::f1600` here (see `keccak_permute` above); on-guest the +/// end-to-end oracle is proof-blob acceptance (any digest difference diverges +/// the Fiat-Shamir transcript and fails verification loudly). +#[cfg(all(test, not(target_arch = "riscv64")))] +mod tests { + use super::*; + use sha3::{Digest, Keccak256 as RefKeccak256}; + + fn reference(input: &[u8]) -> [u8; 32] { + RefKeccak256::digest(input).into() + } + + /// Deterministic xorshift64* so the "fuzz" cases are reproducible. + struct Rng(u64); + impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } + } + + /// Every length from empty through three full rate blocks (+2), so every + /// padding boundary (135/136/137, 271/272/273, …) is hit. + #[test] + fn one_shot_matches_reference_for_all_lengths() { + let data: Vec = (0..3 * RATE_BYTES + 2) + .map(|i| (i * 31 + 7) as u8) + .collect(); + for len in 0..=data.len() { + assert_eq!( + keccak256(&data[..len]), + reference(&data[..len]), + "digest mismatch at len={len}" + ); + } + } + + /// Differential chunking fuzz: random sub-slices (random start => misaligned + /// pointers exercising the byte fallback) fed through `update` in random + /// pieces must match the one-shot reference digest. + #[test] + fn chunked_misaligned_updates_match_reference() { + let data: Vec = (0..1500).map(|i| (i * 131 + 17) as u8).collect(); + let mut rng = Rng(0x9E37_79B9_7F4A_7C15); + for case in 0..300 { + let len = rng.below(data.len()); + let start = rng.below(data.len() - len + 1); + let slice = &data[start..start + len]; + + let mut hasher = Keccak256::new(); + let mut fed = 0; + while fed < slice.len() { + let n = 1 + rng.below((slice.len() - fed).min(200)); + hasher.update(&slice[fed..fed + n]); + fed += n; + } + let mut out = [0u8; 32]; + hasher.finalize(&mut out); + assert_eq!( + out, + reference(slice), + "chunked digest mismatch: case={case} start={start} len={len}" + ); + } + } + + /// The fixed-shape parent path must equal hashing the 64-byte concatenation. + #[test] + fn pair_matches_reference() { + let mut rng = Rng(0xD1B5_4A32_D192_ED03); + for case in 0..64 { + let mut left = [0u8; 32]; + let mut right = [0u8; 32]; + for b in left.iter_mut().chain(right.iter_mut()) { + *b = rng.next() as u8; + } + let mut concat = [0u8; 64]; + concat[..32].copy_from_slice(&left); + concat[32..].copy_from_slice(&right); + assert_eq!( + keccak256_pair(&left, &right), + reference(&concat), + "pair digest mismatch: case={case}" + ); + assert_eq!( + keccak256_pair(&left, &right), + keccak256(&concat), + "pair vs streaming sponge mismatch: case={case}" + ); + } + } +} From 04e3fcccfc3e19132c197d04d5a93a323b03baa0 Mon Sep 17 00:00:00 2001 From: MauroFab Date: Fri, 17 Jul 2026 14:05:38 -0300 Subject: [PATCH 05/10] test(syscalls): wire keccak differential tests into make; drop unused critical-section dev-dep The syscalls crate is excluded from the workspace (riscv-only bare-metal allocator/entrypoints that assemble only for the guest target), so the root `cargo test` never reached its host keccak-vs-sha3 differential tests and CI couldn't run them. Add a `test-syscalls` target that runs `cargo test` in the crate dir and make `test` depend on it. Drop the `critical-section` dev-dependency: the embedded_alloc global allocator is gated to `target_arch = "riscv64"` (src/allocator.rs), so on host test builds it is never the active allocator and its critical-section path is never linked. A clean `cargo test` links and passes without it. critical-section stays in the lock transitively (via riscv/embedded-alloc) but needs no host impl. Commit syscalls/Cargo.lock (now that CI builds this crate standalone) to match the repo convention for excluded crates and pin dev-deps for reproducible tests. --- Makefile | 12 +- syscalls/Cargo.lock | 405 ++++++++++++++++++++++++++++++++++++++++++++ syscalls/Cargo.toml | 1 - 3 files changed, 415 insertions(+), 3 deletions(-) create mode 100644 syscalls/Cargo.lock diff --git a/Makefile b/Makefile index 110c2d31f..aa0dba9d1 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: deps deps-linux deps-macos compile-programs-asm compile-programs-rust compile-bench \ compile-programs compile-recursion-elfs clean-asm clean-rust clean-bench clean-shared \ clean-recursion-elfs clean test test-asm \ -test-rust test-ethrex test-executor test-flamegraph flamegraph-prover test-profile-recursion test-profile-recursion-single test-profile-recursion-multi \ +test-rust test-ethrex test-executor test-syscalls test-flamegraph flamegraph-prover test-profile-recursion test-profile-recursion-single test-profile-recursion-multi \ test-fast test-prover test-prover-all test-prover-debug test-disk-spill test-math-cuda test-cuda-integration test-cuda-fallback \ test-prover-cuda test-prover-comprehensive-cuda \ bench-math-cuda bench-prover bench-prover-cuda build check clippy fmt lint regen-ethrex-fixtures \ @@ -293,7 +293,15 @@ update-ethrex-fixture-checksums: check-ethrex-fixture-checksums: python3 tooling/ethrex-fixtures/update_readme_checksums.py --check -test: compile-programs +# The syscalls crate is excluded from the workspace (riscv-only bare-metal +# entrypoints/allocator that assemble only for the guest target — see the root +# Cargo.toml exclude), so the root `cargo test` never reaches its host +# differential tests (the keccak sponge vs sha3 reference). Run them explicitly +# in the crate dir; wired into `test` below so CI exercises them. +test-syscalls: + cd syscalls && cargo test + +test: compile-programs test-syscalls cargo test # === Quick test shortcuts === diff --git a/syscalls/Cargo.lock b/syscalls/Cargo.lock new file mode 100644 index 000000000..d931d9c3f --- /dev/null +++ b/syscalls/Cargo.lock @@ -0,0 +1,405 @@ +# 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 = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[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 = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[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 = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[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 = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "lambda-vm-syscalls" +version = "0.1.0" +dependencies = [ + "embedded-alloc", + "getrandom 0.2.17", + "getrandom 0.3.4", + "keccak", + "lazy_static", + "rand", + "riscv", + "sha3", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linked_list_allocator" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +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 = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + +[[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 = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[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 = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[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.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/syscalls/Cargo.toml b/syscalls/Cargo.toml index b85a52219..22fa3abbb 100644 --- a/syscalls/Cargo.toml +++ b/syscalls/Cargo.toml @@ -15,4 +15,3 @@ rand = "0.9.2" [dev-dependencies] keccak = "0.1" sha3 = "0.10" -critical-section = { version = "1", features = ["std"] } From 6db4c86dbab66fe8c96cdbe7540cc254ff416084 Mon Sep 17 00:00:00 2001 From: MauroFab Date: Fri, 17 Jul 2026 14:05:49 -0300 Subject: [PATCH 06/10] =?UTF-8?q?refactor(syscalls):=20keccak=20review=20f?= =?UTF-8?q?ixes=20=E2=80=94=20LE=20guard,=20shared=20squeeze,=20StdRng=20t?= =?UTF-8?q?ests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - update(): gate the whole-lane fast path on cfg!(target_endian = "little"). The raw *const u64 lane read equals the required little-endian value only on LE targets; the cfg! folds to a compile-time constant (codegen unchanged on every real target) and the byte fallback is endian-correct everywhere. - Deduplicate the squeeze: extract squeeze32_into(state, out), shared by Keccak256::finalize and keccak256_pair. Write-into (rather than returning [u8; 32]) so finalize fills its output reference in place — verified guest-codegen and cycle-identical to the pre-dedup loops. - Tests: replace the hand-rolled xorshift RNG with rand's StdRng + SeedableRng (matching src/random.rs), keeping fixed seeds for reproducibility. - Document at the byte-wise fallback that a from_le_bytes lane-assembly middle path was measured at +4.7% cycles (dropped commit f6d575ed) so it is not re-proposed on this 1-cycle-per-instruction VM. Digests remain byte-identical to sha3 Keccak-256 (host differential tests); guest cycles unchanged (recursion-min 41,747,766 / recursion-blowup8 260,967,578, keccak call counts identical). --- syscalls/src/keccak.rs | 68 +++++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/syscalls/src/keccak.rs b/syscalls/src/keccak.rs index fb51b3a73..7abef9689 100644 --- a/syscalls/src/keccak.rs +++ b/syscalls/src/keccak.rs @@ -94,9 +94,17 @@ impl Keccak256 { while !input.is_empty() { // Whole-lane fast path: sponge offset on a lane boundary AND input // pointer 8-aligned (the VM traps on unaligned doubleword loads). - // Keccak state lanes are little-endian, so an in-memory u64 load - // equals the from_le_bytes lane value. - if self.offset % 8 == 0 && (input.as_ptr() as usize) % 8 == 0 && input.len() >= 8 { + // LE-only by construction: the raw `*const u64` read below equals the + // required little-endian lane value only on a little-endian target, so + // gate on it. `cfg!(target_endian = ...)` is a compile-time constant + // that folds away on every real target (riscv64im-lambda-vm and the + // host CI targets are all little-endian), leaving codegen unchanged; + // the byte-wise fallback is endian-correct everywhere. + if cfg!(target_endian = "little") + && self.offset % 8 == 0 + && (input.as_ptr() as usize) % 8 == 0 + && input.len() >= 8 + { let lanes_left = (RATE_BYTES - self.offset) / 8; let take = lanes_left.min(input.len() / 8); let base = self.offset / 8; @@ -114,6 +122,11 @@ impl Keccak256 { self.offset = 0; } } else { + // Byte-wise fallback for misaligned input. A middle path that + // assembled each lane with `from_le_bytes` (three formulations, + // dropped commit f6d575ed) measured +4.7% cycles: on this + // 1-cycle-per-instruction VM the extra shifts/ORs cost more than + // the byte loop they replace, so don't re-propose it. self.absorb_byte(input[0]); input = &input[1..]; } @@ -129,10 +142,20 @@ impl Keccak256 { self.state[RATE_LANES - 1] ^= FINAL_PAD_LANE_BIT; keccak_permute(&mut self.state); - // Squeeze the first 32 bytes (4 u64 lanes). - for (i, chunk) in output.chunks_exact_mut(8).enumerate() { - chunk.copy_from_slice(&self.state[i].to_le_bytes()); - } + squeeze32_into(&self.state, output); + } +} + +/// Squeeze the 32-byte Keccak-256 digest out of a permuted state into `out`: +/// rate lanes 0..4, little-endian. Shared by the streaming +/// [`Keccak256::finalize`] and the fixed-shape [`keccak256_pair`] so the squeeze +/// loop lives in exactly one place. Writes into a caller-owned buffer (rather +/// than returning `[u8; 32]`) so `finalize` fills its `output` reference in +/// place, keeping the guest codegen identical to the pre-dedup loop. +#[inline(always)] +fn squeeze32_into(state: &[u64; 25], out: &mut [u8; 32]) { + for (i, chunk) in out.chunks_exact_mut(8).enumerate() { + chunk.copy_from_slice(&state[i].to_le_bytes()); } } @@ -172,9 +195,7 @@ pub fn keccak256_pair(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { keccak_permute(&mut state); let mut out = [0u8; 32]; - for (i, chunk) in out.chunks_exact_mut(8).enumerate() { - chunk.copy_from_slice(&state[i].to_le_bytes()); - } + squeeze32_into(&state, &mut out); out } @@ -188,26 +209,13 @@ pub fn keccak256_pair(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { #[cfg(all(test, not(target_arch = "riscv64")))] mod tests { use super::*; + use rand::{Rng, SeedableRng, rngs::StdRng}; use sha3::{Digest, Keccak256 as RefKeccak256}; fn reference(input: &[u8]) -> [u8; 32] { RefKeccak256::digest(input).into() } - /// Deterministic xorshift64* so the "fuzz" cases are reproducible. - struct Rng(u64); - impl Rng { - fn next(&mut self) -> u64 { - self.0 ^= self.0 << 13; - self.0 ^= self.0 >> 7; - self.0 ^= self.0 << 17; - self.0 - } - fn below(&mut self, n: usize) -> usize { - (self.next() % n.max(1) as u64) as usize - } - } - /// Every length from empty through three full rate blocks (+2), so every /// padding boundary (135/136/137, 271/272/273, …) is hit. #[test] @@ -230,16 +238,16 @@ mod tests { #[test] fn chunked_misaligned_updates_match_reference() { let data: Vec = (0..1500).map(|i| (i * 131 + 17) as u8).collect(); - let mut rng = Rng(0x9E37_79B9_7F4A_7C15); + let mut rng = StdRng::seed_from_u64(0x9E37_79B9_7F4A_7C15); for case in 0..300 { - let len = rng.below(data.len()); - let start = rng.below(data.len() - len + 1); + let len = rng.random_range(0..data.len()); + let start = rng.random_range(0..data.len() - len + 1); let slice = &data[start..start + len]; let mut hasher = Keccak256::new(); let mut fed = 0; while fed < slice.len() { - let n = 1 + rng.below((slice.len() - fed).min(200)); + let n = 1 + rng.random_range(0..(slice.len() - fed).min(200)); hasher.update(&slice[fed..fed + n]); fed += n; } @@ -256,12 +264,12 @@ mod tests { /// The fixed-shape parent path must equal hashing the 64-byte concatenation. #[test] fn pair_matches_reference() { - let mut rng = Rng(0xD1B5_4A32_D192_ED03); + let mut rng = StdRng::seed_from_u64(0xD1B5_4A32_D192_ED03); for case in 0..64 { let mut left = [0u8; 32]; let mut right = [0u8; 32]; for b in left.iter_mut().chain(right.iter_mut()) { - *b = rng.next() as u8; + *b = rng.random(); } let mut concat = [0u8; 64]; concat[..32].copy_from_slice(&left); From c0cb4596981a152238e8d841358b39ea6bcd3bd5 Mon Sep 17 00:00:00 2001 From: MauroFab Date: Fri, 17 Jul 2026 14:32:24 -0300 Subject: [PATCH 07/10] docs(crypto,syscalls): record measured dead-end optimizations at their code sites Two refactors that reviewers (and optimization-hunting agents) will keep re-proposing were implemented and measured slower on the guest; pin the numbers where the edit would happen so the effort isn't repeated: - replacing the TypeId dispatch in hash_streamed with a generic Digest::finalize_into route: +0.5% guest cycles at blowup8 across every formulation (by-value sponge through the trait layer, not elidable cross-crate without LTO) - return-value squeeze32: +81k cycles from the extra stack temporary The misaligned lane-assembly absorb path (+4.7%) is already documented at the byte fallback in update(). --- .../src/merkle_tree/backends/field_element_vector.rs | 11 +++++++++++ syscalls/src/keccak.rs | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs b/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs index dcb7d8629..e6c527ebe 100644 --- a/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs +++ b/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs @@ -28,6 +28,17 @@ use lambda_vm_syscalls::keccak::Keccak256 as SyscallKeccak256; /// pure plumbing around the one permutation. Byte-identical to the generic /// path; every other digest / node size (and the entire host build) takes the /// generic path unchanged. +/// +/// DO NOT replace this `TypeId` dispatch with a generic `Digest::finalize_into` +/// fix "at the adapter altitude" — that exact refactor was implemented and +/// MEASURED SLOWER on the guest across every formulation tried (best: +/// +60k min / +1.25M blowup8 cycles, i.e. +0.5%), including `#[inline]` +/// adapters and a check-free `AsMut` output conversion. The residual is +/// intrinsic: `FixedOutput::finalize_into` moves the 208-byte sponge by value +/// through the newtype + trait layer into a non-inlined cross-crate call, and +/// without LTO the placement isn't elided; the direct branch below builds the +/// sponge in place at the call's ABI slot. Deleting the dispatch also cannot +/// remove the `'static` bounds — `hash_new_parent_bytes` needs them regardless. #[inline] fn hash_streamed( feed: impl Fn(&mut dyn FnMut(&[u8])), diff --git a/syscalls/src/keccak.rs b/syscalls/src/keccak.rs index 7abef9689..45e00724d 100644 --- a/syscalls/src/keccak.rs +++ b/syscalls/src/keccak.rs @@ -151,7 +151,9 @@ impl Keccak256 { /// [`Keccak256::finalize`] and the fixed-shape [`keccak256_pair`] so the squeeze /// loop lives in exactly one place. Writes into a caller-owned buffer (rather /// than returning `[u8; 32]`) so `finalize` fills its `output` reference in -/// place, keeping the guest codegen identical to the pre-dedup loop. +/// place, keeping the guest codegen identical to the pre-dedup loop. Do NOT +/// "simplify" this to a return-value form: that shape was measured at +81k +/// guest cycles (min preset) from the extra stack temporary it introduces. #[inline(always)] fn squeeze32_into(state: &[u64; 25], out: &mut [u8; 32]) { for (i, chunk) in out.chunks_exact_mut(8).enumerate() { From 33f2ab81279d050793a29fdc995b68d223d14ad8 Mon Sep 17 00:00:00 2001 From: MauroFab Date: Fri, 17 Jul 2026 14:46:19 -0300 Subject: [PATCH 08/10] fix(ci,test,docs): close the review gaps on the keccak sponge PR - CI never invokes 'make test', so the syscalls differential tests still did not run in CI despite the Makefile wiring; run 'make test-syscalls' as a dedicated step in the cli-test job. - Swap the test RNG from StdRng (algorithm unstable across rand releases) to ChaCha8Rng so the fixed seeds pin the exact fuzz case streams across versions and checkouts. - Correct the finalize_into dead-end comment to give both preset percentages (+0.14% min / +0.48% blowup8) instead of one figure. - Cite PR #847 instead of a commit hash unreachable from any ref for the dropped lane-assembly measurement. --- .github/workflows/pr_main.yaml | 3 +++ Makefile | 3 ++- .../src/merkle_tree/backends/field_element_vector.rs | 2 +- syscalls/Cargo.lock | 1 + syscalls/Cargo.toml | 2 ++ syscalls/src/keccak.rs | 12 +++++++----- 6 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pr_main.yaml b/.github/workflows/pr_main.yaml index cb10ec72a..a4554fda2 100644 --- a/.github/workflows/pr_main.yaml +++ b/.github/workflows/pr_main.yaml @@ -131,6 +131,9 @@ jobs: - name: Run CLI tests run: cargo test -p cli + - name: Run syscalls host tests (keccak differential vs sha3) + run: make test-syscalls + # "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/Makefile b/Makefile index aa0dba9d1..12ac291f5 100644 --- a/Makefile +++ b/Makefile @@ -297,7 +297,8 @@ check-ethrex-fixture-checksums: # entrypoints/allocator that assemble only for the guest target — see the root # Cargo.toml exclude), so the root `cargo test` never reaches its host # differential tests (the keccak sponge vs sha3 reference). Run them explicitly -# in the crate dir; wired into `test` below so CI exercises them. +# in the crate dir; wired into `test` below and run as a dedicated step +# in CI's cli-test job (pr_main.yaml). test-syscalls: cd syscalls && cargo test diff --git a/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs b/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs index e6c527ebe..6d0cc6491 100644 --- a/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs +++ b/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs @@ -32,7 +32,7 @@ use lambda_vm_syscalls::keccak::Keccak256 as SyscallKeccak256; /// DO NOT replace this `TypeId` dispatch with a generic `Digest::finalize_into` /// fix "at the adapter altitude" — that exact refactor was implemented and /// MEASURED SLOWER on the guest across every formulation tried (best: -/// +60k min / +1.25M blowup8 cycles, i.e. +0.5%), including `#[inline]` +/// +60k min = +0.14%, +1.25M blowup8 = +0.48%), including `#[inline]` /// adapters and a check-free `AsMut` output conversion. The residual is /// intrinsic: `FixedOutput::finalize_into` moves the 208-byte sponge by value /// through the newtype + trait layer into a non-inlined cross-crate call, and diff --git a/syscalls/Cargo.lock b/syscalls/Cargo.lock index d931d9c3f..34e481dd8 100644 --- a/syscalls/Cargo.lock +++ b/syscalls/Cargo.lock @@ -134,6 +134,7 @@ dependencies = [ "keccak", "lazy_static", "rand", + "rand_chacha", "riscv", "sha3", "thiserror", diff --git a/syscalls/Cargo.toml b/syscalls/Cargo.toml index 22fa3abbb..0460a2435 100644 --- a/syscalls/Cargo.toml +++ b/syscalls/Cargo.toml @@ -14,4 +14,6 @@ rand = "0.9.2" [dev-dependencies] keccak = "0.1" +# Stable stream across versions (unlike StdRng), so fuzz-case seeds stay reproducible. +rand_chacha = "0.9" sha3 = "0.10" diff --git a/syscalls/src/keccak.rs b/syscalls/src/keccak.rs index 45e00724d..16c7550ef 100644 --- a/syscalls/src/keccak.rs +++ b/syscalls/src/keccak.rs @@ -123,8 +123,9 @@ impl Keccak256 { } } else { // Byte-wise fallback for misaligned input. A middle path that - // assembled each lane with `from_le_bytes` (three formulations, - // dropped commit f6d575ed) measured +4.7% cycles: on this + // assembled each lane with `from_le_bytes` was tried in three + // formulations and measured +4.7% cycles at the blowup8 preset + // (see PR #847's measurement notes): on this // 1-cycle-per-instruction VM the extra shifts/ORs cost more than // the byte loop they replace, so don't re-propose it. self.absorb_byte(input[0]); @@ -211,7 +212,8 @@ pub fn keccak256_pair(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { #[cfg(all(test, not(target_arch = "riscv64")))] mod tests { use super::*; - use rand::{Rng, SeedableRng, rngs::StdRng}; + use rand::{Rng, SeedableRng}; + use rand_chacha::ChaCha8Rng; use sha3::{Digest, Keccak256 as RefKeccak256}; fn reference(input: &[u8]) -> [u8; 32] { @@ -240,7 +242,7 @@ mod tests { #[test] fn chunked_misaligned_updates_match_reference() { let data: Vec = (0..1500).map(|i| (i * 131 + 17) as u8).collect(); - let mut rng = StdRng::seed_from_u64(0x9E37_79B9_7F4A_7C15); + let mut rng = ChaCha8Rng::seed_from_u64(0x9E37_79B9_7F4A_7C15); for case in 0..300 { let len = rng.random_range(0..data.len()); let start = rng.random_range(0..data.len() - len + 1); @@ -266,7 +268,7 @@ mod tests { /// The fixed-shape parent path must equal hashing the 64-byte concatenation. #[test] fn pair_matches_reference() { - let mut rng = StdRng::seed_from_u64(0xD1B5_4A32_D192_ED03); + let mut rng = ChaCha8Rng::seed_from_u64(0xD1B5_4A32_D192_ED03); for case in 0..64 { let mut left = [0u8; 32]; let mut right = [0u8; 32]; From 76f8e230f46a6fb0774fb15067d22fcec7ec9d57 Mon Sep 17 00:00:00 2001 From: MauroFab Date: Fri, 17 Jul 2026 14:59:53 -0300 Subject: [PATCH 09/10] fix(syscalls): gate the guest entrypoint module to riscv64 The _start entry symbol (and its imported main) only exist for the guest; on a Linux host they collide with the C runtime / test harness entry and 'cargo test' fails with "entry symbol `main` declared multiple times" (macOS tolerates the duplicate, which is why local host tests passed). Same treatment as the global-allocator gating: guest builds are unchanged. --- syscalls/src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/syscalls/src/lib.rs b/syscalls/src/lib.rs index d0ff4418c..767f0ff71 100644 --- a/syscalls/src/lib.rs +++ b/syscalls/src/lib.rs @@ -1,5 +1,10 @@ pub mod allocator; pub mod ef_io; +// Guest-only: `_start` + the imported `main` are entry symbols that collide +// with the host C runtime / test harness (Linux errors with "entry symbol +// `main` declared multiple times"; macOS happens to tolerate it, which is why +// host tests passed locally). Same treatment as the global allocator gating. +#[cfg(target_arch = "riscv64")] pub mod entrypoint; pub mod keccak; pub mod random; From 636ba679df4466a4458a291ecdb1719d860e8f28 Mon Sep 17 00:00:00 2001 From: MauroFab Date: Fri, 17 Jul 2026 15:07:01 -0300 Subject: [PATCH 10/10] test(syscalls),docs(crypto): make absorb-path coverage structural; pin the adapter passthrough invariant Review feedback (two reviewers independently): the whole-lane fast path was only exercised because Vec bases happen to be 8-aligned on current platforms. A repr(align(8)) buffer now guarantees the aligned path and a +1-offset view guarantees the byte fallback, across all padding boundaries. Also documents two load-bearing implicit facts: the TypeId specializations depend on PlatformKeccak256 remaining a pure passthrough of the syscall sponge (INVARIANT note in the adapter), and the host tests' coverage boundary (the ecall and riscv64 branches are validated only by the proof-blob oracle). --- crypto/crypto/src/hash/platform_keccak.rs | 9 +++++ syscalls/src/keccak.rs | 42 +++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/crypto/crypto/src/hash/platform_keccak.rs b/crypto/crypto/src/hash/platform_keccak.rs index 199c69625..3c3cb081e 100644 --- a/crypto/crypto/src/hash/platform_keccak.rs +++ b/crypto/crypto/src/hash/platform_keccak.rs @@ -11,6 +11,15 @@ mod imp { }; use lambda_vm_syscalls::keccak::Keccak256 as SyscallKeccak256; + // INVARIANT (load-bearing): this adapter must remain a PURE PASSTHROUGH of + // `SyscallKeccak256`. The TypeId specializations in + // crypto/crypto/src/merkle_tree/backends/field_element_vector.rs bypass it + // and drive the syscall sponge directly, on the assumption that both paths + // hash identically. Adding ANY behavior here (a domain prefix, extra + // absorption, a different reset policy) silently desyncs the specialized + // branches from the generic path — and the failure surfaces as in-guest + // proof rejection, not as a host test failure. + #[derive(Clone, Default)] pub struct PlatformKeccak256(SyscallKeccak256); diff --git a/syscalls/src/keccak.rs b/syscalls/src/keccak.rs index 16c7550ef..9fe543c59 100644 --- a/syscalls/src/keccak.rs +++ b/syscalls/src/keccak.rs @@ -209,6 +209,13 @@ pub fn keccak256_pair(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { /// software `keccak::f1600` here (see `keccak_permute` above); on-guest the /// end-to-end oracle is proof-blob acceptance (any digest difference diverges /// the Fiat-Shamir transcript and fails verification loudly). +/// +/// What these tests do NOT cover: the `keccak_permute` ecall itself and the +/// `#[cfg(target_arch = "riscv64")]` specialized call sites (the Merkle +/// backends' TypeId branches) — those are validated only by the blob oracle. +/// The generic-vs-specialized equivalence additionally rests on +/// `PlatformKeccak256` staying a pure passthrough of this sponge; see the +/// INVARIANT note in crypto/crypto/src/hash/platform_keccak.rs. #[cfg(all(test, not(target_arch = "riscv64")))] mod tests { use super::*; @@ -265,6 +272,41 @@ mod tests { } } + /// Structural (allocator-independent) coverage of BOTH absorb paths. The + /// other tests feed `Vec` slices, whose base alignment is up to the + /// allocator — on current platforms they happen to be 8-aligned, so the + /// whole-lane fast path is exercised only by luck. Here a `repr(align(8))` + /// buffer GUARANTEES the aligned fast path, and a +1-offset view of the + /// same bytes GUARANTEES the byte-wise fallback, across all padding + /// boundaries. + #[test] + fn aligned_and_misaligned_paths_match_reference() { + #[repr(align(8))] + struct Aligned([u8; 3 * RATE_BYTES + 9]); + + let mut buf = Aligned([0u8; 3 * RATE_BYTES + 9]); + for (i, b) in buf.0.iter_mut().enumerate() { + *b = (i * 131 + 17) as u8; + } + assert_eq!(buf.0.as_ptr() as usize % 8, 0, "repr(align(8)) must hold"); + + for len in [0, 1, 7, 8, 9, 63, 64, 135, 136, 137, 271, 272, 273, 400] { + let aligned = &buf.0[..len]; + assert_eq!(keccak256(aligned), reference(aligned), "aligned len={len}"); + let misaligned = &buf.0[1..1 + len]; + assert_eq!( + misaligned.as_ptr() as usize % 8, + 1, + "offset view must be misaligned" + ); + assert_eq!( + keccak256(misaligned), + reference(misaligned), + "misaligned len={len}" + ); + } + } + /// The fixed-shape parent path must equal hashing the 64-byte concatenation. #[test] fn pair_matches_reference() {