Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/pr_main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 11 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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 \
Expand Down Expand Up @@ -293,7 +293,16 @@ 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 and run as a dedicated step
# in CI's cli-test job (pr_main.yaml).
test-syscalls:
cd syscalls && cargo test

test: compile-programs test-syscalls
cargo test

# === Quick test shortcuts ===
Expand Down
9 changes: 9 additions & 0 deletions crypto/crypto/src/hash/platform_keccak.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
132 changes: 98 additions & 34 deletions crypto/crypto/src/merkle_tree/backends/field_element_vector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,88 @@ 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.
///
/// 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 = +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
/// 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<D: Digest + 'static, const NUM_BYTES: usize>(
feed: impl Fn(&mut dyn FnMut(&[u8])),
) -> [u8; NUM_BYTES] {
#[cfg(target_arch = "riscv64")]
if NUM_BYTES == 32 && TypeId::of::<D>() == TypeId::of::<PlatformKeccak256>() {
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
}

/// 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<D: Digest + 'static, const NUM_BYTES: usize>(
left: &[u8; NUM_BYTES],
right: &[u8; NUM_BYTES],
) -> [u8; NUM_BYTES] {
#[cfg(target_arch = "riscv64")]
if NUM_BYTES == 32 && TypeId::of::<D>() == TypeId::of::<PlatformKeccak256>() {
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::<D, NUM_BYTES>(|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.
Expand All @@ -27,7 +109,7 @@ impl<F, D: Digest, const NUM_BYTES: usize> Default for FieldElementPairBackend<F
}
}

impl<F, D: Digest, const NUM_BYTES: usize> IsMerkleTreeBackend
impl<F, D: Digest + 'static, const NUM_BYTES: usize> IsMerkleTreeBackend
for FieldElementPairBackend<F, D, NUM_BYTES>
where
F: IsField,
Expand All @@ -38,21 +120,14 @@ where
type Data = [FieldElement<F>; 2];

fn hash_data(input: &[FieldElement<F>; 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::<D, NUM_BYTES>(|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_new_parent_bytes::<D, NUM_BYTES>(left, right)
}
}

Expand All @@ -71,7 +146,7 @@ impl<F, D: Digest, const NUM_BYTES: usize> Default for FieldElementVectorBackend
}
}

impl<F, D: Digest, const NUM_BYTES: usize> FieldElementVectorBackend<F, D, NUM_BYTES>
impl<F, D: Digest + 'static, const NUM_BYTES: usize> FieldElementVectorBackend<F, D, NUM_BYTES>
where
[u8; NUM_BYTES]: From<Output<D>>,
{
Expand All @@ -80,15 +155,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::<D, NUM_BYTES>(|sink| sink(data))
}
}

impl<F, D: Digest, const NUM_BYTES: usize> FieldElementVectorBackend<F, D, NUM_BYTES>
impl<F, D: Digest + 'static, const NUM_BYTES: usize> FieldElementVectorBackend<F, D, NUM_BYTES>
where
F: IsField,
FieldElement<F>: AsBytes,
Expand All @@ -100,17 +171,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<F>], b: &[FieldElement<F>]) -> [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::<D, NUM_BYTES>(|sink| {
for element in a.iter().chain(b.iter()) {
element.stream_bytes(sink);
}
})
}
}

impl<F, D: Digest, const NUM_BYTES: usize> IsMerkleTreeBackend
impl<F, D: Digest + 'static, const NUM_BYTES: usize> IsMerkleTreeBackend
for FieldElementVectorBackend<F, D, NUM_BYTES>
where
F: IsField,
Expand All @@ -129,12 +198,7 @@ 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_new_parent_bytes::<D, NUM_BYTES>(left, right)
}
}

Expand Down
Loading
Loading