Skip to content
Draft
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
21 changes: 21 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,27 @@ endef
$(RUST_ARTIFACTS_DIR)/%.elf: FORCE | prepare-sysroot $(RUST_ARTIFACTS_DIR)
$(call build_guest_elf,$(RUST_PROGRAMS_DIR)/$*,$*)

# Plain-software-sponge ethrex guest, for a local A/B against the default one.
#
# The ethrex guest hashes on the KECCAK_SPONGE absorb accelerator BY DEFAULT
# (see its Cargo.toml), because /bench builds the workload with a plain
# `make .../ethrex.elf` and proves whatever that contains — an accelerator
# behind a non-default feature would be invisible to it. This rule builds the
# other side of the comparison.
#
# Unlike the recursion presets below, the variant cannot be selected with a
# second `required-features`-gated [[bin]]: cargo gates a bin on a feature being
# ON, never OFF, and this axis is `--no-default-features`. So both variants come
# from the one `ethrex` bin and land on the same
# shared_target/.../release/ethrex path — build them SEQUENTIALLY (each recipe
# copies to its own $@ before the next cargo run can overwrite it), and expect
# cargo to rebuild the crate when switching, since the feature set changes.
#
# An explicit rule beats the pattern rule above, which would otherwise go
# looking for a crate directory named `ethrex-nosponge`.
$(RUST_ARTIFACTS_DIR)/ethrex-nosponge.elf: FORCE | prepare-sysroot $(RUST_ARTIFACTS_DIR)
$(call build_guest_elf,$(RUST_PROGRAMS_DIR)/ethrex,ethrex,--no-default-features)

# Compile rust benches (64-bit)
$(BENCH_ARTIFACTS_DIR)/%.elf: FORCE | prepare-sysroot $(BENCH_ARTIFACTS_DIR)
$(call build_guest_elf,$(BENCH_PROGRAMS_DIR)/$*,$*)
Expand Down
9 changes: 6 additions & 3 deletions bin/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ fn cmd_execute(
// below (the flamegraph path drives execution inside the executor and does
// not expose per-log data). `None` means "not counted", so the accel lines
// are omitted rather than printed as misleading zeros.
let mut accel_counts: Option<(u64, u64)> = None;
let mut accel_counts: Option<(u64, u64, u64)> = None;

let cycle_count = if let Some(ref output_path) = flamegraph.path {
// Shared execute+flamegraph path (executor::flamegraph) instead of
Expand Down Expand Up @@ -479,6 +479,7 @@ fn cmd_execute(

let mut cycle_count: u64 = 0;
let mut keccak_calls: u64 = 0;
let mut keccak_absorb_calls: u64 = 0;
let mut ecsm_calls: u64 = 0;
// Reused per chunk: `(current_pc, a7)` for logs whose a7 matches an
// accelerator syscall number. This is a cheap superset — a non-ECALL
Expand Down Expand Up @@ -511,6 +512,7 @@ fn cmd_execute(
for (pc, a7) in accel_candidates.drain(..) {
match accelerator_of(executor.instructions.get(pc), a7) {
Some(Accelerator::Keccak) => keccak_calls += 1,
Some(Accelerator::KeccakAbsorb) => keccak_absorb_calls += 1,
Some(Accelerator::Ecsm) => ecsm_calls += 1,
None => {}
}
Expand All @@ -526,15 +528,16 @@ fn cmd_execute(
}

if cycles {
accel_counts = Some((keccak_calls, ecsm_calls));
accel_counts = Some((keccak_calls, keccak_absorb_calls, ecsm_calls));
}
cycle_count
};

if cycles {
println!("Cycles: {}", cycle_count);
if let Some((keccak_calls, ecsm_calls)) = accel_counts {
if let Some((keccak_calls, keccak_absorb_calls, ecsm_calls)) = accel_counts {
println!("Keccak calls: {}", keccak_calls);
println!("KeccakAbsorb calls: {}", keccak_absorb_calls);
println!("Ecsm calls: {}", ecsm_calls);
}
}
Expand Down
19 changes: 19 additions & 0 deletions crypto/ethrex-crypto/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,25 @@ license = "MIT OR Apache-2.0"
# changes don't require an ethrex PR — the guest just constructs and injects
# `LambdaVmEcsmCrypto`.

[features]
default = []
# Route the whole 136-byte keccak rate blocks of `Crypto::keccak256` through
# the KECCAK_SPONGE absorb accelerator (ECALL -4) instead of XOR-ing them into
# the state in guest software; the padded tail stays on `keccak_permute`. The
# digests are identical either way.
#
# Off at this level, ON by default at the guest crate, so `make ethrex.elf`
# (which is what /bench builds) produces an accelerated ELF while
# `--no-default-features` still builds the plain one for a local A/B.
#
# The forward to `lambda-vm-syscalls/keccak-sponge-accel` is a no-op for this
# crate's own sponge (which calls `keccak_absorb_blocks` directly, no feature
# needed); it is here so one flag means "accelerator on everywhere". The
# dependency is target-gated to riscv64 and cargo applies the forward only when
# the dependency is active, so host builds are unaffected — smoke-checked with
# `cargo check --features keccak-sponge-accel` on the host.
keccak-sponge-accel = ["lambda-vm-syscalls/keccak-sponge-accel"]

[dependencies]
# Defines the `Crypto` trait, `CryptoError`, and `keccak::keccak_hash`. Same rev
# + `default-features = false` as the guest's ethrex-crypto, so feature
Expand Down
81 changes: 77 additions & 4 deletions crypto/ethrex-crypto/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -508,22 +508,42 @@ fn point_from_xy(x: &FieldElement, y: &FieldElement) -> Option<AffinePoint> {

// ── Keccak-256 over the keccak_permute precompile (riscv64 guest) ───────────

/// Keccak-256 sponge with an injected permutation function.
/// Keccak-256 sponge with injected primitives.
///
/// Keccak-f[1600], rate 1088 bits (136 bytes), capacity 512 bits.
/// Padding: `0x01 ... 0x80` (multi-rate, last bit set). The state is a
/// 25-element u64 array; bytes are absorbed into the state via little-endian
/// XOR (matching the standard Keccak byte-to-lane mapping).
///
/// `permute` runs one Keccak-f[1600]. `absorb_whole` is offered the message's
/// whole-rate-block prefix and returns how many of those blocks it absorbed
/// (XOR + permute each); returning 0 declines and leaves every block to the
/// software loop below. That is the seam the `ECALL -4` sponge accelerator
/// plugs into, and it is why the accelerator can bail out on an unmet
/// precondition without the caller knowing — both routes leave the same state.
///
/// Padding never reaches `absorb_whole`: the final partial block is framed and
/// absorbed here, always through `permute`.
///
/// Gated to `riscv64 | test` so the generic function is available to the host
/// unit tests without being dead code in the non-test host build.
#[cfg(any(target_arch = "riscv64", test))]
fn keccak256_with_permute<F: FnMut(&mut [u64; 25])>(input: &[u8], mut permute: F) -> [u8; 32] {
fn keccak256_with_backend<P, A>(input: &[u8], mut permute: P, mut absorb_whole: A) -> [u8; 32]
where
P: FnMut(&mut [u64; 25]),
A: FnMut(&mut [u64; 25], &[u8]) -> usize,
{
const RATE: usize = 136;

let mut state = [0u64; 25];
let mut offset = 0;

let whole_len = (input.len() / RATE) * RATE;
if whole_len > 0 {
offset = absorb_whole(&mut state, &input[..whole_len]) * RATE;
debug_assert!(offset <= whole_len);
}

while input.len() - offset >= RATE {
absorb_block(&mut state, &input[offset..offset + RATE]);
permute(&mut state);
Expand All @@ -547,10 +567,63 @@ fn keccak256_with_permute<F: FnMut(&mut [u64; 25])>(input: &[u8], mut permute: F
output
}

/// Keccak-256 via LambdaVM's `keccak_permute` syscall (riscv64 guest only).
/// Keccak-256 sponge driven by `permute` alone — the pure software absorb
/// path. Test-only: the guest goes through [`keccak256_with_backend`] so it can
/// offer the whole-block prefix to the accelerator.
#[cfg(test)]
fn keccak256_with_permute<F: FnMut(&mut [u64; 25])>(input: &[u8], permute: F) -> [u8; 32] {
keccak256_with_backend(input, permute, |_, _| 0)
}

/// Keccak-256 via LambdaVM's precompiles (riscv64 guest only): whole rate
/// blocks through the sponge-absorb accelerator when it is compiled in, the
/// padded tail always through `keccak_permute`.
#[cfg(target_arch = "riscv64")]
fn keccak256_via_lambdavm(input: &[u8]) -> [u8; 32] {
keccak256_with_permute(input, |s| lambda_vm_syscalls::syscalls::keccak_permute(s))
keccak256_with_backend(
input,
|s| lambda_vm_syscalls::syscalls::keccak_permute(s),
absorb_whole_blocks_accel,
)
}

/// Whole-block absorb through the `KECCAK_SPONGE` accelerator (`ECALL -4`),
/// returning the number of blocks it took.
///
/// The executor *traps* on an unmet precondition, so each one is either
/// discharged statically or checked here, and a failed check declines the whole
/// prefix (returns 0) rather than risking the run:
///
/// - state 8-aligned: guaranteed, it is a `[u64; 25]`;
/// - data 8-aligned: checked — the guest heap (rlsf) hands out 16-aligned
/// payloads, but a node encoding borrowed at an odd offset would not be;
/// - `n_blocks > 0`: the caller only passes a non-empty whole-block prefix;
/// - low-limb room, i.e. `(addr mod 2^32) + last_offset < 2^32`: checked, since
/// the chip addresses each dword as `base_lo + offset` with no carry;
/// - regions disjoint: guaranteed, `state` is `&mut` and cannot alias `blocks`.
#[cfg(all(target_arch = "riscv64", feature = "keccak-sponge-accel"))]
fn absorb_whole_blocks_accel(state: &mut [u64; 25], blocks: &[u8]) -> usize {
const RATE: usize = 136;
const LOW_LIMB: u64 = 1 << 32;

let low_limb_ok =
|addr: usize, len: usize| ((addr as u64) & (LOW_LIMB - 1)) + (len as u64 - 1) < LOW_LIMB;
if !(blocks.as_ptr() as usize).is_multiple_of(8)
|| !low_limb_ok(blocks.as_ptr() as usize, blocks.len())
|| !low_limb_ok(state.as_ptr() as usize, 25 * 8)
{
return 0;
}
let n_blocks = blocks.len() / RATE;
lambda_vm_syscalls::syscalls::keccak_absorb_blocks(state, blocks, n_blocks);
n_blocks
}

/// Accelerator compiled out: decline every block, so the sponge is exactly the
/// software one.
#[cfg(all(target_arch = "riscv64", not(feature = "keccak-sponge-accel")))]
fn absorb_whole_blocks_accel(_state: &mut [u64; 25], _blocks: &[u8]) -> usize {
0
}

/// XOR one rate-sized block of bytes into the state lanes (little-endian).
Expand Down
65 changes: 65 additions & 0 deletions crypto/ethrex-crypto/src/tests/keccak_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,71 @@ fn keccak_sponge_matches_trusted_permutation() {
check_keccak(&[0xbb; 271]);
}

/// Software mirror of the `ECALL -4` sponge-absorb accelerator, matching the
/// executor arm (`executor/src/vm/instruction/execution.rs`,
/// `SyscallNumbers::KeccakAbsorbBlocks`): per block, XOR 17 little-endian dword
/// lanes into the state, then permute. Lets the host test the *composition* the
/// guest uses — which blocks go to the chip, and where padding lands — without
/// a VM.
fn absorb_whole_blocks_mirror(state: &mut [u64; 25], blocks: &[u8]) -> usize {
const RATE: usize = 136;
assert_eq!(blocks.len() % RATE, 0);
let n_blocks = blocks.len() / RATE;
for k in 0..n_blocks {
absorb_block(state, &blocks[k * RATE..(k + 1) * RATE]);
keccak::f1600(state);
}
n_blocks
}

/// The accelerated composition must be digest-identical to the software sponge
/// and to ethrex's reference `keccak_hash`, at every length that crosses a rate
/// boundary. This is the host half of the `keccak-sponge-accel` correctness
/// argument: it pins the *split* (whole blocks to the chip, padded tail to
/// `keccak_permute`), while the chip's own semantics are the executor's and the
/// prover's business.
///
/// Also covers the accelerator DECLINING (returning 0, as it does on a
/// misaligned buffer): the software loop must then absorb everything and reach
/// the same digest.
#[test]
fn accelerated_absorb_matches_software_sponge() {
let data: Vec<u8> = (0..4 * 136 + 8).map(|i| (i * 97 + 13) as u8).collect();

for len in [
0, 1, 8, 135, 136, 137, 271, 272, 273, 407, 408, 409, 500, 544, 552,
] {
let msg = &data[..len];
let software = keccak256_with_permute(msg, keccak::f1600);
let accelerated = keccak256_with_backend(msg, keccak::f1600, absorb_whole_blocks_mirror);
let declined = keccak256_with_backend(msg, keccak::f1600, |_, _| 0);

assert_eq!(accelerated, keccak_hash(msg), "vs reference, len={len}");
assert_eq!(accelerated, software, "accel vs software, len={len}");
assert_eq!(declined, software, "declined vs software, len={len}");
}
}

/// The seam lets the accelerator absorb only a PREFIX of the whole blocks it is
/// offered, with the software loop picking up the rest. Nothing in the guest
/// takes a partial bite today, but the contract allows it and an off-by-one in
/// the `offset` handoff would otherwise go unnoticed.
#[test]
fn partial_accelerated_take_matches_software_sponge() {
const RATE: usize = 136;
let data: Vec<u8> = (0..5 * RATE).map(|i| (i * 31 + 7) as u8).collect();

for len in [3 * RATE, 3 * RATE + 40, 5 * RATE] {
let msg = &data[..len];
for take in 0..=len / RATE {
let got = keccak256_with_backend(msg, keccak::f1600, |state, blocks| {
absorb_whole_blocks_mirror(state, &blocks[..take * RATE])
});
assert_eq!(got, keccak_hash(msg), "len={len} take={take}");
}
}
}

#[test]
fn keccak_sponge_known_answer_vectors() {
// Vectors from the Ethereum Yellow Paper / EIP-155. These use Keccak-256
Expand Down
50 changes: 50 additions & 0 deletions executor/programs/asm/test_keccak_absorb.s
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
.attribute 5, "rv64i2p1_m2p0_zmmul1p0"
.globl main
main:
# 608 bytes on the stack: 200-byte keccak state at sp, then
# 3 x 136-byte rate blocks at sp+200 (regions disjoint, both 8-aligned).
addi sp, sp, -608

# Deterministic non-zero state: lane[i] = i + 1 (25 lanes).
# The host test replays the sponge over tiny-keccak from this seed.
mv t0, sp
li t1, 1
li t2, 26
.Lstate_loop:
sd t1, 0(t0)
addi t0, t0, 8
addi t1, t1, 1
bne t1, t2, .Lstate_loop

# Deterministic message data: dword[k] = k + 100 (51 dwords = 3 blocks).
addi t0, sp, 200
li t1, 100
li t2, 151
.Ldata_loop:
sd t1, 0(t0)
addi t0, t0, 8
addi t1, t1, 1
bne t1, t2, .Ldata_loop

# Absorb all 3 blocks in ONE ecall.
# a0 = state, a1 = data, a2 = n_blocks, a7 = u64::MAX - 3 (spec -4).
mv a0, sp
addi a1, sp, 200
li a2, 3
li a7, -4
ecall

# Commit the final 200-byte state.
li a0, 1
mv a1, sp
li a2, 200
li a7, 64
ecall

# Restore stack and halt.
addi sp, sp, 608
li a0, 0
li a7, 93
ecall
.Lfunc_end0:
.size main, .Lfunc_end0-main
27 changes: 27 additions & 0 deletions executor/programs/rust/ethrex/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,33 @@ name = "ethrex"
version = "0.1.0"
edition = "2024"

[features]
# ON BY DEFAULT, deliberately. `/bench` builds the workload with a plain
# `make executor/program_artifacts/rust/ethrex.elf` and proves that one ELF, so
# the accelerator has to be in the default build or the bench cannot see it.
# `--no-default-features` still yields the plain software sponge — that is how
# `ethrex-nosponge.elf` is built for a local correctness A/B.
default = ["keccak-sponge-accel"]

# Hash whole 136-byte keccak rate blocks on the KECCAK_SPONGE absorb
# accelerator (ECALL -4) rather than in guest software; the `10*1`-padded tail
# still goes through `keccak_permute`. The digests, and therefore the proven
# block, are identical either way.
#
# What this actually reaches: ethrex's trie node hashes, contract-code hashes,
# block/receipt hashes and the KECCAK256 opcode, all of which route through
# `Crypto::keccak256` -> `lambda-vm-ethrex-crypto`. It does NOT reach the
# `ethrex_crypto::keccak::keccak_hash` free function (software tiny-keccak),
# which on this ethrex rev is cold anyway — see ethrex-wiring.md §2.
#
# ⚠ An ELF built with this on runs `ECALL -4`, which only exists on an executor
# that has the KECCAK_SPONGE chip. It will NOT execute on `origin/main` until
# PR #912 lands — see the /bench fallback hazard in ethrex-wiring.md §10.
keccak-sponge-accel = [
"lambda-vm-ethrex-crypto/keccak-sponge-accel",
"lambda-vm-syscalls/keccak-sponge-accel",
]

[dependencies]
lambda-vm-syscalls = { path = "../../../../syscalls" }
# LambdaVM crypto provider (keccak + ECSM-accelerated ecrecover), defined in the
Expand Down
Loading
Loading