diff --git a/.github/workflows/bench-abba.yml b/.github/workflows/bench-abba.yml
index 23e323d57..ede04cadc 100644
--- a/.github/workflows/bench-abba.yml
+++ b/.github/workflows/bench-abba.yml
@@ -9,19 +9,29 @@ name: Bench ABBA tiebreaker
# Syntax: "/bench-abba [N]", where N is the pair count (default 12).
#
# Resolution, from the paired t-test (resolvable 95% delta = t* x sd / sqrt(N)). The
-# pair-delta sd on this runner is NOT yet measured; the two columns bracket it between
-# 1.0% (the GPU box's measured 0.64% plus margin) and 2.0% (sqrt(2) x this runner's
-# measured 1.43% single-run CV):
+# two columns are not a guess bracketing an unknown: they are the same runner under
+# two conditions, because its variance is contention and not prover jitter. Over a
+# 14-prove baseline (tooling/ethrex-fixtures/README.md) wall time and the share of CPU
+# a prove actually got correlate at -0.98, perfectly monotonic, so the single-run CV is
+# 0.34% across the proves that got the most CPU and 1.26% across all of them. sd of a
+# pair delta is sqrt(2) x that:
#
-# pairs wall resolves (sd 2.0% / sd 1.0%)
-# 8 ~50 min 1.7% / 0.8%
-# 12 ~72 min 1.3% / 0.6% <- default
-# 20 ~1h55m 0.9% / 0.5%
-# 32 ~3h 0.7% / 0.4%
+# pairs wall resolves (quiet box, sd 0.49% / shared, sd 1.78%)
+# 8 ~41 min 0.34% / 1.24%
+# 12 ~58 min 0.28% / 1.01% <- default
+# 20 ~1h31m 0.21% / 0.78%
+# 32 ~2h22m 0.16% / 0.62%
#
-# Wall is 2 x 158.8 s per pair (the runner's measured prove at epoch 2^22) plus ~8 min
-# of setup. The FIRST run measures the sd — it is the `sd` field of the paired-t line
-# in the result comment — and this table should be re-pinned to it then.
+# Wall is 2 x ~125 s per pair (the runner's measured prove at epoch 2^22) plus ~8 min of
+# setup. Read the column the run earned, not the one you want: the script prints an
+# exclusivity line reporting the CPU share of every prove and flagging any that came in
+# under 90% of the batch's best. A flagged batch belongs in the right-hand column.
+#
+# The workload rebuild did tighten this, but not as far as first recorded: a
+# 5-prove baseline put the CV at 0.57%, and its 95% interval was [0.34%, 1.64%] — wide
+# enough that the point estimate was not worth pinning a table to, which is what the
+# 14-prove follow-up then showed. Numbers from before the bump measured a different
+# fixture (block 25368371 as an rkyv ProgramInput) and are not comparable.
on:
issue_comment:
types: [created]
@@ -29,7 +39,8 @@ on:
env:
# The epoch /bench proves this block at, so the tiebreaker resolves the same
# configuration and not just the same block. Memory picks it: this runner peaks at
- # ~52 GB here against its >=64 GiB floor. See benchmark-pr.yml's REAL_BLOCK_EPOCH_LOG2.
+ # 44.81 GiB here against its >=64 GiB floor, and 2^23 would take that past 50 GiB
+ # for ~7% of wall. See benchmark-pr.yml's REAL_BLOCK_EPOCH_LOG2.
ABBA_REAL_EPOCH_LOG2: "22"
concurrency:
@@ -50,8 +61,8 @@ jobs:
startsWith(github.event.comment.body, '/bench-abba') &&
contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.comment.author_association)
runs-on: [self-hosted, bench]
- # Hang guardrail, not expected duration: a real-block pair is 2 x 158.8 s = ~5.3 min,
- # so the default 12 pairs runs ~72 min and the 40-pair clamp ~3.7 hr, plus up to
+ # Hang guardrail, not expected duration: a real-block pair is 2 x 125.17 s = ~4.2 min,
+ # so the default 12 pairs runs ~58 min and the 40-pair clamp ~2.9 hr, plus up to
# ~30 min of two-sided build on a cold cache.
timeout-minutes: 360
steps:
@@ -117,9 +128,9 @@ jobs:
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: context.issue.number,
- // A pair is TWO proves at the runner's measured 158.8 s, so ~5.3 min/pair,
- // plus ~8 min of checkout, two-sided build and fixture fetch.
- body: `⏳ **ABBA tiebreaker started** on the bench server: ${process.env.PAIRS} pairs of ${process.env.WORKLOAD} — a pair is 2 proves at ~158.8 s, so roughly ${Math.round(8 + Number(process.env.PAIRS) * 5.3)} min. Pass a smaller pair count for a quicker, coarser run. The bench server is occupied until it finishes.`
+ // A pair is TWO proves at the runner's measured 125.17 s, so ~4.2 min/pair,
+ // plus ~8 min of checkout, two-sided build and fixture build.
+ body: `⏳ **ABBA tiebreaker started** on the bench server: ${process.env.PAIRS} pairs of ${process.env.WORKLOAD} — a pair is 2 proves at ~125 s, so roughly ${Math.round(8 + Number(process.env.PAIRS) * 4.2)} min. Pass a smaller pair count for a quicker, coarser run. The bench server is occupied until it finishes.`
});
- name: Checkout (full history for ref resolution)
diff --git a/.github/workflows/benchmark-gpu.yml b/.github/workflows/benchmark-gpu.yml
index 4a9c33398..1f715d45a 100644
--- a/.github/workflows/benchmark-gpu.yml
+++ b/.github/workflows/benchmark-gpu.yml
@@ -9,7 +9,7 @@ name: Benchmark GPU (PR)
# Triggered by a "/bench-gpu [N]" comment on a PR (N = pair count, default 14) or via
# workflow_dispatch.
#
-# Workload: the real block (see tooling/ethrex-block-converter/README.md), proven with
+# Workload: the real block (see tooling/ethrex-fixtures/README.md), proven with
# --continuations at the calibrated epoch size below.
# Orchestration runs on a GitHub-hosted runner; all GPU work happens on the rented
# Vast box (provisioned by the template onstart).
@@ -229,11 +229,19 @@ jobs:
# because vast can't numerically compare the driver_version string server-side.
MIN_DRIVER: "580"
run: |
- # cpu_ram filter is in GB. Floor 48 GB: the real block at epoch 2^22 peaks at
- # ~36 GB host RSS on the CUDA path (measured, main vintage) — ~25% headroom.
+ # cpu_ram filter is in GB. Floor 64 GB, raised from 48 with the rebuilt
+ # workload: that block peaks at 44.81 GiB (47.0 GB) at epoch 2^22 on the CPU
+ # path, which on a 48 GB box is 98% of RAM -- not headroom to lose to a
+ # rental with a slightly hungrier allocator, it is already over. 64 GB restores the ~28% headroom the
+ # 48 GB floor had for the old block (~36 GB host RSS on the CUDA path,
+ # measured at main vintage) and matches the CPU runner's own floor. It costs
+ # offer availability: fewer boxes qualify, so PRICE_CAP may need a look if
+ # the query starts coming back empty. Peak is set by the epoch size, not the
+ # block, so dropping GPU_REAL_EPOCH_LOG2 to 21 is the other lever (~24 GiB,
+ # +14% wall).
# Continuation peak is set by the epoch size, not the block, so bigger blocks
- # don't move it; raising the epoch would (see the calibration tables in
- # tooling/ethrex-block-converter/README.md).
+ # don't move it; raising the epoch would (see the epoch table in
+ # tooling/ethrex-fixtures/README.md).
# gpu_frac=1 requires a WHOLE-MACHINE offer (you rent every GPU on the host), so
# Vast places no other tenant on the box: CPU cores, RAM/memory bandwidth, PCIe,
# and NVMe are fully dedicated. Without it the "most expensive" sort below lands on
@@ -246,7 +254,7 @@ jobs:
# at the toolchain sanity gate. `reliability` is the queryable field (the
# `reliability2` in the response schema is display-only, not filterable).
# Over-strict just yields no offers, surfaced by the retry loop's "No offer".
- QUERY="gpu_name=${GPU_NAME} num_gpus=1 gpu_frac=1 cpu_cores_effective>=16 cpu_cores_effective<=32 cpu_ram>=48 disk_space>=64 verified=true rentable=true reliability>=0.95 cuda_max_good>=12.8 dph_total<=${PRICE_CAP}"
+ QUERY="gpu_name=${GPU_NAME} num_gpus=1 gpu_frac=1 cpu_cores_effective>=16 cpu_cores_effective<=32 cpu_ram>=64 disk_space>=64 verified=true rentable=true reliability>=0.95 cuda_max_good>=12.8 dph_total<=${PRICE_CAP}"
echo "Query: $QUERY (+ client-side driver_version major >= $MIN_DRIVER)"
# Keep only offers whose driver major >= MIN_DRIVER, then most expensive first
# (within the price cap). Within the now whole-machine pool, price just tracks
@@ -495,7 +503,7 @@ jobs:
# That surfaces as the OOM killer reaping a rustc ("signal: 9") or as an
# allocation failure mid-compile. (Distinct from the toolchain gate's concern
# above, which is a host that is broken before any load is applied.)
- # 8 leaves ~6 GB/job on the >=48 GB floor; the build is a one-time per-bench
+ # 8 leaves ~8 GB/job on the >=64 GB floor; the build is a one-time per-bench
# cost, and the job timeout above has ample room for it.
REMOTE="set -e; cd /workspace/lambda_vm; \
command -v python3 >/dev/null || { apt-get update -qq && apt-get install -y -qq python3; }; \
diff --git a/.github/workflows/benchmark-pr.yml b/.github/workflows/benchmark-pr.yml
index 56fa23e5b..b96e938f1 100644
--- a/.github/workflows/benchmark-pr.yml
+++ b/.github/workflows/benchmark-pr.yml
@@ -70,45 +70,52 @@ env:
ELF: executor/program_artifacts/rust/ethrex.elf
# The workload: a real Ethereum block. WHICH block lives in
# the Makefile and nowhere else — nothing in this file names one, so a repoint
- # moves this job without editing it. At the current default and pinned ethrex rev that
- # is 30,498,818 cycles, 10,659 keccak calls and 116 ecsm calls — several times the work
- # of the synthetic block, at a very different keccak:ecrecover mix. That is the whole
- # point: a prover change can move the synthetic number and the real one in opposite
- # directions. All three figures move with the ethrex rev, so re-measure on a bump
- # rather than carrying them across one.
+ # moves this job without editing it. At the current default that is 37,137,748
+ # cycles, 6,003 keccak calls and 164 ecsm calls: 13.6x the work of the synthetic
+ # block (ethrex_bench_4, 2,727,681 cycles) with a keccak:ecrecover mix of 37:1
+ # against its 13:1. That is the whole point — a prover change can move the
+ # synthetic number and the real one in opposite directions.
+ #
+ # Those three counts are deterministic for a given ELF and input, so they are the
+ # check that the workload is what this job thinks it is; every figure below that
+ # carries seconds or bytes is machine-dependent and was measured on THIS runner.
+ # All three move with the ethrex rev, so re-measure on a bump rather than carrying
+ # them across one.
#
# The path is resolved from the Makefile (`make -s print-real-block-fixture`) into
- # REAL_INPUT at run time. The ~1 MB .bin is gitignored and FETCHED by URL + sha256
- # (see "Fetch ethrex real-block fixture"); while that URL is unset the whole
- # section degrades to a warning rather than failing the job.
+ # REAL_INPUT at run time. The .bin is gitignored and BUILT from the block's replay
+ # cache (see "Build ethrex real-block fixture"); while that cache's URL is unset the
+ # whole section degrades to a warning rather than failing the job.
#
# Continuations are mandatory here, not a preference: a monolithic prove costs
# ~4.9 GB of peak heap per million cycles on this workload family (from the
# measured growth fit, 10,728 MB + 2,007 MB/transfer at R^2 = 0.998), so the
- # current default would need ~240 GB and a heavier candidate far more.
+ # current default would need ~182 GB and a heavier candidate far more.
# `--continuations` makes peak heap a function of the epoch size instead of the
- # trace length. Costs move with the block; the per-candidate table is in
- # tooling/ethrex-block-converter/README.md.
+ # trace length. Costs move with the block; what this one costs is in
+ # tooling/ethrex-fixtures/README.md.
#
- # Budget ~1.2 GB of disk for the bundle each run — hence the `rm -f` after every
- # prove. A heavier block pushes it past 2 GiB, which needs rkyv `pointer_width_64`;
- # a PR branch predating that fix fails at write time rather than mismeasuring.
+ # Budget ~790 MB of disk for the bundle each run (measured: 828,430,552 bytes) —
+ # hence the `rm -f` after every prove. A heavier block pushes it past 2 GiB, which
+ # needs rkyv `pointer_width_64` in the PROVER's bundle serialisation; a PR branch
+ # predating that fix fails at write time rather than mismeasuring.
#
- # Epoch 2^22, from the CPU sweep on 2026-07-31 (124 GiB / 32-core box, real block,
- # branch vintage; full table in tooling/ethrex-block-converter/README.md):
+ # Epoch 2^22, measured on THIS runner on 2026-09-08 (box idle): mean 125.17 s over
+ # 14 proves, sd 1.58 s, CV 1.26%, 44.81 GiB peak RSS, 790 MB bundle; verifying one
+ # bundle is 12.0 s.
#
- # 2^21 464.26 s 18.43 GiB RSS 26 epochs 1.72 GB proof
- # 2^22 397.88 s 32.21 GiB RSS 13 epochs 1.15 GB proof
- # 2^23 356.47 s 60.01 GiB RSS 7 epochs 0.90 GB proof
+ # Memory, not speed, is what picks 2^22. The epoch trade-off was swept on the
+ # previous block (2^21 +13.7% wall / -12 GiB, 2^23 -7.5% wall / +16 GiB): peak RSS
+ # is set by the epoch size rather than the block, so the shape carries over even
+ # though the absolute seconds do not. 2^23 would take this workload past 50 GiB
+ # against the runner's >=64 GiB floor. Both tables in
+ # tooling/ethrex-fixtures/README.md.
#
- # Those RSS figures are the CALIBRATION BOX's, and memory does not transfer between
- # machines any better than seconds do: this runner measured ~52 GB of peak heap for
- # the same block at 2^22 — over 1.5x the calibration box's 32.21 GiB, and close
- # enough to its >=64 GiB floor that 2^23 (60 GiB on the roomier box, so more here)
- # is out of the question. Memory, not speed, is what picks 2^22. Moving off 2^21 is
- # worth ~14% wall on the calibration box; treat that ratio as transferable and
- # neither the absolute seconds nor the RSS column as such. This runner's own
- # measured time at 2^22 is 158.8 s (median of 3, 2.8% spread, 13 epochs).
+ # The pre-bump numbers this replaces (block 25368371, 50.78M cycles, 158.8 s at
+ # 2^22 on this runner, ~52 GB) measured a DIFFERENT workload: that fixture was an
+ # rkyv ProgramInput, which the pinned guest rejects. Do not compare across the
+ # bump. This block was picked to land as close to it as an Amsterdam rebuild can —
+ # see the screen in the Makefile's ETHREX_REAL_BLOCK comment.
#
# Deliberately NOT the CLI's DEFAULT_CONTINUATION_EPOCH_SIZE_LOG2, which stays 20 so
# a laptop can still prove; and not the GPU path's 2^22, which happens to coincide
@@ -118,11 +125,15 @@ env:
# each run is minutes rather than seconds; enough that one slow run shows up as
# spread instead of moving the median.
#
- # THIS IS THE DIAL. A run measures 158.8 s on this runner (median of 3, 2.8% spread,
- # at the epoch above), so 3 runs is ~8 min of proving and the right count. Should a
+ # THIS IS THE DIAL. A run measures 125.17 s on this runner (mean of 5, CV 0.57%, at
+ # the epoch above), so 3 runs is ~6.3 min of proving and the right count. Should a
# future block or prover change take a run past ~6 min, /bench becomes a ~25 min
# occupancy of a runner every other bench queues behind, and this count is what to
# turn down (2, or 1) before reaching for anything else.
+ #
+ # What 3 runs can actually resolve, from that sd: a two-sided 95% delta of ~2.00%.
+ # Five per side gets to ~1.00% and ten to ~0.58%, so a sub-2% claim needs `/bench N`
+ # (clamped to 5) or the ABBA tiebreaker, not a re-read of a 3-run table.
# `/bench N` overrides this, clamped to [1,5]. Past 5 the cached comparison can't
# beat the ~1% session-drift wall anyway — that is what /bench-abba is for, and it
# proves this same block at this same epoch, so escalating keeps the question fixed.
@@ -229,10 +240,10 @@ jobs:
# not need a second command to ask for, and it is the only workload /bench
# proves.
#
- # The cost is real and lands on a shared runner: 158.8 s per run x
- # BENCH_RUNS_REAL is ~8 min of proving, so every /bench and every push to main
- # occupies the bench server for roughly 15 min once checkout, the two-sided
- # build, the fixture fetch and the guest ELF are counted, and /bench-abba and
+ # The cost is real and lands on a shared runner: 125.17 s per run x
+ # BENCH_RUNS_REAL is ~6.3 min of proving, so every /bench and every push to main
+ # occupies the bench server for roughly 13 min once checkout, the two-sided
+ # build, the fixture build and the guest ELF are counted, and /bench-abba and
# /bench-verify queue behind it. That trade was made deliberately — see
# BENCH_RUNS_REAL above for the dial if it proves too expensive.
RUN_REAL=true
@@ -244,8 +255,8 @@ jobs:
# the growth sweep still runs and feeds the baseline artifact (no comment is
# posted on those events — the Comment step is gated to comment triggers);
# on a plain /bench nothing is left to measure, and the footer says so.
- if [ "$RUN_REAL" = "true" ] && [ -z "$(make -s print-real-block-fixture-url)" ]; then
- echo "::warning::Real-block benchmark skipped: ETHREX_REAL_BLOCK_FIXTURE_URL is unset in the Makefile."
+ if [ "$RUN_REAL" = "true" ] && [ -z "$(make -s print-real-block-cache-url)" ]; then
+ echo "::warning::Real-block benchmark skipped: ETHREX_REAL_BLOCK_CACHE_URL is unset in the Makefile."
RUN_REAL=false
fi
echo "run_real=$RUN_REAL" >> "$GITHUB_OUTPUT"
@@ -273,9 +284,9 @@ jobs:
# Clamp to 1-5. Beyond 5 the single-session cached comparison barely improves
# (it can't beat the ~1% drift wall); use /bench-abba for finer deltas — it
- # resolves ~1% over the same block in ~72 min at its default 12 pairs.
- # At 158.8 s a run this is also the difference between a ~10 min and a ~20 min
- # occupancy of the one bench runner.
+ # resolves ~0.5% over the same block in ~58 min at its default 12 pairs.
+ # At 125.17 s a run this is also the difference between a ~10 min and a ~20 min
+ # occupancy of the one bench runner. Three runs resolve ~2.00%, five ~1.00%.
if [ "$RUNS" -lt 1 ] 2>/dev/null || [ "$RUNS" -gt 5 ] 2>/dev/null; then
echo "::warning::Run count $RUNS out of range [1,5], defaulting to $BENCH_RUNS_REAL"
RUNS=$BENCH_RUNS_REAL
@@ -300,14 +311,16 @@ jobs:
echo "Using $RUNS iterations, TABLE_PARALLELISM=default"
fi
- - name: Fetch ethrex real-block fixture
+ - name: Build ethrex real-block fixture
if: steps.config.outputs.run_real == 'true'
run: |
- # ~1 MB, gitignored, and never in the checkout: fetch it rather than failing
- # on a missing file, the same way the synthetic fixtures are generated above.
- # This is a URL + sha256 download, not a build — no converter, no ethrex host
- # dependency tree, no ethrex-replay cache. The step is already gated on the
- # URL being set (see "Determine run count").
+ # Gitignored and never in the checkout, like the synthetic fixtures above.
+ # Built, not downloaded: the pinned guest decodes only the Amsterdam schema,
+ # so a hosted artifact for this pre-Amsterdam block cannot be valid — it would
+ # run ~500 cycles and commit `successful_validation = 0` without failing. The
+ # generator rebuilds the block's real transactions on its real pre-state and
+ # validates the result through the guest before writing. The step is gated on
+ # the cache URL being set (see "Determine run count").
#
# Untracked, so like the ELF it survives `git checkout origin/main` and both
# sides prove the identical block.
@@ -998,7 +1011,7 @@ jobs:
if (!realTime) {
body += `\n> 🧱 **No prover measurement — the real-block fixture was not available.** `;
body += `\`/bench\` proves only the real block, so nothing was measured this run. `;
- body += `Check that \`ETHREX_REAL_BLOCK_FIXTURE_URL\` is set in the Makefile and that the artifact is reachable; the run log carries the warning.\n`;
+ body += `Check that \`ETHREX_REAL_BLOCK_CACHE_URL\` is set in the Makefile and that the replay cache is reachable; the fixture is generated from it, and the run log carries the warning.\n`;
}
const sha = process.env.COMMIT_SHA.substring(0, 8);
body += `\nCommit: ${sha} · Baseline: ${baseSrc} · Runner: self-hosted bench\n`;
diff --git a/.github/workflows/ethrex-block-converter.yml b/.github/workflows/ethrex-block-converter.yml
index 53c640e9b..8a97a335d 100644
--- a/.github/workflows/ethrex-block-converter.yml
+++ b/.github/workflows/ethrex-block-converter.yml
@@ -7,14 +7,15 @@ name: ethrex block-converter tests
# network download plus a cold build of ~335 packages (blst, c-kzg and secp256k1-sys
# C builds, malachite, ark-ff/asm).
#
-# It fires on the things that can actually invalidate it: the converter, the ethrex
-# host-reference tests, and the Makefile (which holds the block pin, the fixture URL
-# and its sha256).
+# It fires on the things that can actually invalidate it: the fixture generator, the
+# converter, the ethrex host-reference tests, and the Makefile (which holds the block
+# pin, the cache URL and its sha256).
on:
workflow_dispatch:
pull_request:
branches: ["**"]
paths:
+ - 'tooling/ethrex-fixtures/**'
- 'tooling/ethrex-block-converter/**'
- 'tooling/ethrex-tests/**'
- 'Makefile'
@@ -22,6 +23,7 @@ on:
push:
branches: ["main"]
paths:
+ - 'tooling/ethrex-fixtures/**'
- 'tooling/ethrex-block-converter/**'
- 'tooling/ethrex-tests/**'
- 'Makefile'
@@ -81,17 +83,20 @@ jobs:
shared-key: "lambda-vm-real-block-usable"
cache-all-crates: "true"
workspaces: |
+ tooling/ethrex-fixtures -> target
tooling/ethrex-tests -> target
- # Fetch-and-verify, not build: no converter, no ethrex-replay cache, no rev pin.
- # The guard covers the window after a repoint but before the new artifact is
- # uploaded: the screen below is its only consumer, and failing the job on an
- # unset URL would block PRs on an upload nobody in the PR can perform.
- - name: Fetch real-block fixture
+ # Built from the block's replay cache, not downloaded: the pinned guest decodes
+ # only the Amsterdam schema, so no hosted artifact for a pre-Amsterdam block can
+ # be valid. The generator validates the block through the guest before writing,
+ # which is what the screen below then re-checks against the ELF. The guard covers
+ # an unset cache URL, where failing the job would block PRs on an upload nobody
+ # in the PR can perform.
+ - name: Build real-block fixture
id: fixture
run: |
- if [ -z "$(make -s print-real-block-fixture-url)" ]; then
- echo "::warning::ETHREX_REAL_BLOCK_FIXTURE_URL is unset — skipping the real-block usability screen. Set it in the Makefile once the .bin is hosted."
+ if [ -z "$(make -s print-real-block-cache-url)" ]; then
+ echo "::warning::ETHREX_REAL_BLOCK_CACHE_URL is unset — skipping the real-block usability screen. Set it in the Makefile once the cache is hosted."
echo "present=false" >> "$GITHUB_OUTPUT"
exit 0
fi
diff --git a/.github/workflows/pr_main.yaml b/.github/workflows/pr_main.yaml
index 89bff2b03..7d54319c8 100644
--- a/.github/workflows/pr_main.yaml
+++ b/.github/workflows/pr_main.yaml
@@ -138,10 +138,10 @@ jobs:
cargo test --release -p executor test_ckzg -- --ignored
# ethrex host-reference tests live in the detached `tooling/ethrex-tests`
- # workspace (ethrex pins rkyv's `unaligned` feature, which must not
- # feature-unify with the main workspace's aligned proof format), so run
- # them from that directory to use its isolated Cargo.lock. The guest ELF
- # and committed fixtures are already present from the steps above.
+ # workspace (the ethrex host dependency tree is heavy and pinned by rev in its
+ # own Cargo.lock, kept out of the main build), so run them from that directory
+ # to use that lockfile. The guest ELF and committed fixtures are already
+ # present from the steps above.
# --include-ignored also runs the heavier synthetic-block test.
#
# `--skip test_ethrex_real_block` is a substring match, so it drops BOTH
diff --git a/Makefile b/Makefile
index cf794e081..5dc403512 100644
--- a/Makefile
+++ b/Makefile
@@ -8,7 +8,7 @@ test-prover-cuda test-prover-comprehensive-cuda \
bench-math-cuda bench-prover bench-prover-cuda build check clippy fmt lint regen-ethrex-fixtures \
update-ethrex-fixture-checksums check-ethrex-fixture-checksums ethrex-real-block-fixture \
ethrex-real-block-cache ethrex-real-block-converter-cache print-real-block-fixture \
-print-real-block-fixture-url \
+print-real-block-cache-url \
test-ethrex-real-block-converter regen-real-block-fixture
UNAME := $(shell uname)
@@ -280,7 +280,7 @@ test-rust: compile-programs-rust
# from tooling/ethrex-fixtures. Two artifacts, both gitignored and both FETCHED
# rather than built:
#
-# the fixture the rkyv ProgramInput the benchmarks prove (~1 MB)
+# the fixture the schema-prefixed SSZ input the benchmarks prove
# the cache the ethrex-replay JSON it was converted from (~2 MB), read only
# by `regen-real-block-fixture`. The converter's TESTS read a
# different, upstream-pinned cache — see below.
@@ -303,20 +303,48 @@ test-rust: compile-programs-rust
# names a block. Outside this file the repoint touches only REAL_BLOCK_FIXTURE in
# tooling/ethrex-tests, which points the usability screen at the block actually
# being proven. The converter's own pins do NOT move — see below.
-# tooling/ethrex-block-converter/README.md carries the procedure and each candidate's
-# measured cost.
+# tooling/ethrex-fixtures/README.md carries what the workload costs and how to pick
+# the epoch size; the converter's README covers converting a cache by hand.
ETHREX_REAL_BLOCK_NETWORK := mainnet
-ETHREX_REAL_BLOCK := 25368371
-# The asset name carries the ethrex rev because the bytes are a function of it: the
-# archived ProgramInput layout moves with the pin, so one block has one fixture per rev.
-# Uploading under a new name rather than replacing the old one keeps `main` — which still
-# expects the pre-bump sha256 — fetching its own artifact while this branch is open.
-ETHREX_REAL_BLOCK_FIXTURE_URL := https://github.com/yetanotherco/lambda_vm/releases/download/bench-fixtures-v1/ethrex_mainnet_25368371_797df554.bin
-ETHREX_REAL_BLOCK_FIXTURE_SHA256 := 573004e62e3680a00d3cdbae19dc4897e2ec60d6ec0c1d05d9ef118cb8aef17f
-# The block's source cache, hosted in the same release. Only `regen-real-block-fixture`
-# reads it — the converter's TESTS use a different, upstream-pinned cache (below).
-ETHREX_REAL_BLOCK_CACHE_URL := https://github.com/yetanotherco/lambda_vm/releases/download/bench-fixtures-v1/cache_mainnet_25368371.json
-ETHREX_REAL_BLOCK_CACHE_SHA256 := 7aa88a5f7c5755b7575870f95e6c5c26186947f5e9e0d52199148c74e2a2736b
+ETHREX_REAL_BLOCK := 25453112
+# WHY THIS BLOCK: 25368371 was the pre-bump default, and under Amsterdam its
+# transactions lose so much gas to the new state-gas model that the workload drops
+# to 20.36M cycles against the 30.50M the retired fixture cost on today's guest. A
+# screen of twelve real mainnet blocks (our two release caches plus ethrex's curated
+# zkevm_bench corpus) put this one closest: 37.14M cycles, +22% against that target,
+# where 25368371 is -33% and the next candidate up (25087308) is +197%. Reverts are
+# not a selection criterion — every pre-Amsterdam block loses 26-50% of its
+# transactions to the fork, which is a property of the fork and not of the block.
+#
+# The fixture is GENERATED from the cache below, not fetched. The pinned guest
+# decodes only the Amsterdam schema (`0x1501`) and mainnet has no Amsterdam fork,
+# so no hosted artifact for this block can be valid: the release's rkyv one now
+# runs 496 cycles and commits `successful_validation = 0` instead of failing.
+# `tooling/ethrex-fixtures --bin real_block` rebuilds the block's real
+# transactions and real pre-state as an Amsterdam block and validates the result
+# through the guest before writing it. Nothing to publish, and it works offline
+# once the cache is there. Read the caveat in tooling/ethrex-fixtures/README.md
+# before quoting numbers: Amsterdam's gas model (EIP-8037 state gas, cold access
+# 2600 -> 3000) makes 10 of this block's Osaka-era transactions run out of gas.
+#
+# The digest below is what keeps a stale fixture out of a benchmark. Generating
+# validates the block, but generation only happens when the file is MISSING (see
+# the rule), and the bench runner is persistent -- so without this, a fixture
+# built before a rev bump survives it and every benchmark keeps proving the old
+# workload. The minimum-cycle floor in bench_abba.sh cannot see that either: a
+# stale fixture still runs tens of millions of cycles, and both sides of an A/B
+# read the same file, so the delta looks healthy while the absolute numbers
+# belong to the wrong block. Verified byte-identical on macOS arm64 and on the
+# Linux x86-64 runner, which is what makes pinning it safe. Repointing the block
+# or moving the ethrex rev changes it: run `make regen-real-block-fixture`, take
+# the new digest, and paste it here. Leaving it empty disables the check and says
+# so out loud.
+ETHREX_REAL_BLOCK_FIXTURE_SHA256 := 08a52e10c2f89870dade14f7b0dfe01625e57416afbff664ad31fcd067233eea
+# The block's source cache: an ethrex-replay dump, fork-independent, still the one
+# hosted in bench-fixtures-v1. Only the fixture rebuild reads it; converter TESTS
+# use a different, upstream-pinned cache (below).
+ETHREX_REAL_BLOCK_CACHE_URL := https://github.com/yetanotherco/lambda_vm/releases/download/bench-fixtures-v1/cache_mainnet_25453112.json
+ETHREX_REAL_BLOCK_CACHE_SHA256 := 20ffbbc1b051df9dfa6285f0ea7bd3f6d054db883aba92230ea20350f1b1d4ad
ETHREX_REAL_BLOCK_ID := $(ETHREX_REAL_BLOCK_NETWORK)_$(ETHREX_REAL_BLOCK)
ETHREX_REAL_BLOCK_FIXTURE := executor/tests/ethrex_$(ETHREX_REAL_BLOCK_ID).bin
@@ -354,8 +382,9 @@ define ensure_verified
if [ -z "$(1)" ]; then \
echo "$(4): $(5) is unset." >&2; \
echo " The $(ETHREX_REAL_BLOCK_ID) $(4) is fetched, not built. Set $(5) in the" >&2; \
- echo " Makefile to wherever the artifact is hosted; see" >&2; \
- echo " tooling/ethrex-block-converter/README.md for how to produce and host one." >&2; \
+ echo " Makefile to wherever it is hosted. The FIXTURE is not fetched at all -" >&2; \
+ echo " it is built by 'make regen-real-block-fixture'; see" >&2; \
+ echo " tooling/ethrex-fixtures/README.md." >&2; \
exit 1; \
fi; \
mkdir -p $(dir $(3)); \
@@ -375,8 +404,44 @@ define ensure_verified
trap - EXIT
endef
-ethrex-real-block-fixture:
- $(call ensure_verified,$(ETHREX_REAL_BLOCK_FIXTURE_URL),$(ETHREX_REAL_BLOCK_FIXTURE_SHA256),$(ETHREX_REAL_BLOCK_FIXTURE),fixture,ETHREX_REAL_BLOCK_FIXTURE_URL)
+# A file rule, unlike the fetched artifacts above: those are phony so their digest
+# is re-checked on every invocation, which is how a stale download is caught. This
+# one is BUILT from the cache (whose digest IS re-checked) by a deterministic
+# generator, so its digest is re-checked here the same way -- hashing 549 KB costs
+# nothing, and only a MISMATCH pays for a rebuild, which is what keeps a cargo
+# build off every benchmark's critical path.
+ethrex-real-block-fixture: $(ETHREX_REAL_BLOCK_FIXTURE)
+ @set -e; \
+ want="$(ETHREX_REAL_BLOCK_FIXTURE_SHA256)"; \
+ if [ -z "$$want" ]; then \
+ echo "::warning::ETHREX_REAL_BLOCK_FIXTURE_SHA256 is unset - the fixture is not being verified."; \
+ exit 0; \
+ fi; \
+ if command -v sha256sum >/dev/null 2>&1; then shacmd="sha256sum"; \
+ elif command -v shasum >/dev/null 2>&1; then shacmd="shasum -a 256"; \
+ else echo "fixture: missing sha256sum or shasum for checksum verification" >&2; exit 1; fi; \
+ sha_of() { $$shacmd "$$1" | awk '{print $$1}'; }; \
+ if [ "$$(sha_of "$(ETHREX_REAL_BLOCK_FIXTURE)")" = "$$want" ]; then exit 0; fi; \
+ echo "fixture $(ETHREX_REAL_BLOCK_FIXTURE) does not match $$want - regenerating."; \
+ $(MAKE) regen-real-block-fixture; \
+ got="$$(sha_of "$(ETHREX_REAL_BLOCK_FIXTURE)")"; \
+ if [ "$$got" != "$$want" ]; then \
+ echo "ERROR: the freshly generated fixture is $$got, not the pinned $$want." >&2; \
+ echo " The generator is deterministic, so this means its inputs or its output" >&2; \
+ echo " format moved: the ethrex rev, the cache, or the generator itself. If that" >&2; \
+ echo " was intended, re-baseline ETHREX_REAL_BLOCK_FIXTURE_SHA256 in the Makefile" >&2; \
+ echo " and re-measure -- the workload is no longer the one every recorded number" >&2; \
+ echo " was taken against." >&2; \
+ exit 1; \
+ fi
+
+# No prerequisites on purpose. `ethrex-real-block-cache` is phony (so its digest is
+# re-checked on every call), and a phony prerequisite always reads as newer than its
+# target, which would rebuild the fixture on every single benchmark invocation. The
+# cache is fetched from inside `regen-real-block-fixture` instead, so this recipe runs
+# only when the file is genuinely missing.
+$(ETHREX_REAL_BLOCK_FIXTURE):
+ $(MAKE) regen-real-block-fixture
ethrex-real-block-cache:
$(call ensure_verified,$(ETHREX_REAL_BLOCK_CACHE_URL),$(ETHREX_REAL_BLOCK_CACHE_SHA256),$(ETHREX_REAL_BLOCK_CACHE),cache,ETHREX_REAL_BLOCK_CACHE_URL)
@@ -385,14 +450,19 @@ ethrex-real-block-cache:
# scripts/bench_abba.sh, scripts/perf_diff.sh and
# .github/workflows/benchmark-pr.yml read the fixture path from here instead of
# hardcoding it, so repointing the block above moves every benchmark at once.
+# One consumer is NOT wired through here: `REAL_BLOCK_FIXTURE` in
+# tooling/ethrex-tests/tests/ethrex.rs is a const, because a glob would happily
+# pick up a stale fixture from an earlier block and pass. Repointing the block
+# means editing it too; it fails on the missing file, loudly.
# `-s` on the caller's side keeps the output clean.
print-real-block-fixture:
@echo $(ETHREX_REAL_BLOCK_FIXTURE)
-# Lets CI ask "is the fixture hosted yet?" without parsing the Makefile. Prints
-# nothing while the URL is unset, which is the condition callers branch on.
-print-real-block-fixture-url:
- @echo $(ETHREX_REAL_BLOCK_FIXTURE_URL)
+# Lets CI ask "can the fixture be produced?" without parsing the Makefile. The
+# fixture itself is generated, so what has to be hosted is its source cache;
+# prints nothing while that URL is unset, which is the condition callers branch on.
+print-real-block-cache-url:
+ @echo $(ETHREX_REAL_BLOCK_CACHE_URL)
# ===== Real-block converter (regeneration tool, off the build path) =====
#
@@ -401,23 +471,27 @@ print-real-block-fixture-url:
# path builds this crate.
#
# Its TEST input is pinned to Hoodi 1265656, independently of whichever block the
-# benchmarks currently prove, and stays there across a repoint. What these tests
-# exercise is the CONVERSION — cache JSON in, correctly-laid-out rkyv out — which
-# any real block demonstrates equally well. Hoodi's is the one cache ethrex-replay
-# publishes, so pinning there costs us no hosting, cannot drift, and leaves the
-# benchmark block free to change without touching this crate.
+# benchmarks currently prove, and stays there across a repoint. Hoodi's is the one
+# cache ethrex-replay publishes, so pinning there costs us no hosting, cannot drift,
+# and leaves the benchmark block free to change without touching this crate.
+#
+# What those tests reach is only the REJECTION paths: a pre-Amsterdam cache, and an
+# unmappable network. The conversion's success path cannot be covered yet — it needs a
+# cache carrying `slot_number` and `block_access_list_hash`, and no published cache has
+# them until a network runs Amsterdam. Until one does, this crate's SSZ encoder is
+# exercised by hand (`cargo run`) and by nothing automatic.
#
# Pinned by immutable `rev`, as the guest pins ethrex itself: a branch ref would let
-# the converter's reproducibility digest drift under a fixed input.
+# the test input move under a fixed set of assertions.
ETHREX_REPLAY_REV := 2693e0182a8734117151d8ea2891eda5afc60383
ETHREX_CONVERTER_TEST_BLOCK := hoodi_1265656
ETHREX_CONVERTER_CACHE := tooling/ethrex-block-converter/caches/cache_$(ETHREX_CONVERTER_TEST_BLOCK).json
# The cache filename is keyed on the block only, and its download rule has no other
# prerequisite, so make would treat an already-present cache as up to date across an
# `ETHREX_REPLAY_REV` bump and silently keep reading the old input. Depending on a
-# rev-stamped marker makes a re-pin discard the stale cache; without it the mismatch
-# only surfaces downstream as a `conversion_is_reproducible` digest failure, which
-# reads as "regenerate the fixture" and points at the wrong thing.
+# rev-stamped marker makes a re-pin discard the stale cache. Nothing downstream would
+# catch it otherwise: the two tests that remain assert that a cache is REJECTED, and a
+# stale cache satisfies that just as well as the right one.
ETHREX_REPLAY_REV_STAMP := tooling/ethrex-block-converter/caches/.replay-rev-$(ETHREX_REPLAY_REV)
$(ETHREX_REPLAY_REV_STAMP):
@@ -433,22 +507,23 @@ $(ETHREX_CONVERTER_CACHE): $(ETHREX_REPLAY_REV_STAMP)
ethrex-real-block-converter-cache: $(ETHREX_CONVERTER_CACHE)
-# Converter correctness: host-side parity through the guest's own Crypto impl, the
-# network-rejection guard, and the reproducibility digest. Runs on changes to the
-# converter (see .github/workflows/ethrex-block-converter.yml), not on every PR.
+# Converter correctness, as far as it is testable today: a pre-Amsterdam cache and an
+# unmappable network are both rejected. The success path needs an Amsterdam cache —
+# see the comment above. Runs on changes to the converter
+# (see .github/workflows/ethrex-block-converter.yml), not on every PR.
test-ethrex-real-block-converter: $(ETHREX_CONVERTER_CACHE)
cd tooling/ethrex-block-converter && cargo test --locked --release
-# Manual regeneration of the BENCHMARK fixture (not the converter's test block):
-# fetches that block's own cache and re-converts it, overwriting the fixture in
-# place so you can hash the result and upload it. That upload, plus SHA256/URL at
-# the top, is how the fixture is actually replaced.
+# The BENCHMARK fixture (not the converter's test block): rebuilt from that block's
+# own replay cache. Deterministic, so re-running it is how the fixture is replaced —
+# there is no artifact to upload.
regen-real-block-fixture: ethrex-real-block-cache
- cd tooling/ethrex-block-converter && \
- cargo run --release -- ../../$(ETHREX_REAL_BLOCK_CACHE) ../../$(ETHREX_REAL_BLOCK_FIXTURE)
+ cd tooling/ethrex-fixtures && \
+ cargo run --release --bin real_block -- \
+ ../../$(ETHREX_REAL_BLOCK_CACHE) ../../$(ETHREX_REAL_BLOCK_FIXTURE)
# ethrex host-reference tests live in the detached `tooling/ethrex-tests`
-# workspace (ethrex pins rkyv's `unaligned` feature; isolated Cargo.lock).
+# workspace (the ethrex host dependency tree is isolated in its own Cargo.lock).
# Needs the real-block fixture, so it needs the fixture URL to be set. This is a
# local convenience target: no workflow invokes it. The PR gate spells out the
# `-offline` variant below inline (pr_main.yaml), and ethrex-block-converter.yml's
diff --git a/crypto/ethrex-crypto/Cargo.lock b/crypto/ethrex-crypto/Cargo.lock
index 47fe0bbbd..44c941387 100644
--- a/crypto/ethrex-crypto/Cargo.lock
+++ b/crypto/ethrex-crypto/Cargo.lock
@@ -342,8 +342,8 @@ dependencies = [
[[package]]
name = "ethrex-crypto"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"ark-bn254",
"ark-ec",
diff --git a/crypto/ethrex-crypto/Cargo.toml b/crypto/ethrex-crypto/Cargo.toml
index 54a7dfc53..a3377a32e 100644
--- a/crypto/ethrex-crypto/Cargo.toml
+++ b/crypto/ethrex-crypto/Cargo.toml
@@ -16,9 +16,10 @@ license = "MIT OR Apache-2.0"
[dependencies]
# Defines the `Crypto` trait, `CryptoError`, and `keccak::keccak_hash`. Same rev
-# + `default-features = false` as the guest's ethrex-crypto, so this line adds
-# nothing to the guest build. Note `std` is on anyway, via ethrex-trie's default.
-ethrex-crypto = { git = "https://github.com/lambdaclass/ethrex.git", rev = "797df5540c7d35cafd69b6971a74b2a49c67d1dd", package = "ethrex-crypto", default-features = false }
+# + `default-features = false` as the guest's ethrex-crypto, so feature
+# unification adds nothing to the guest build (no C secp256k1 / malachite / kzg).
+# `std` is on anyway, via ethrex-trie's default.
+ethrex-crypto = { git = "https://github.com/lambdaclass/ethrex.git", rev = "8effcb0671c5d0b12fe0161ea37c174ec4466b6a", package = "ethrex-crypto", default-features = false }
# Pinned to the exact 0.13.4 ethrex uses so the guest resolves a single k256
# (a version split would make `FieldElement`/`Scalar` incompatible types).
# `expose-field` is required by the x-only reconstruction.
diff --git a/crypto/ethrex-crypto/src/lib.rs b/crypto/ethrex-crypto/src/lib.rs
index d3724f92c..9a44cd00b 100644
--- a/crypto/ethrex-crypto/src/lib.rs
+++ b/crypto/ethrex-crypto/src/lib.rs
@@ -2,7 +2,7 @@
//!
//! Implements ethrex's `Crypto` trait with LambdaVM precompile acceleration and
//! is injected into the ethrex guest (`Arc::new(LambdaVmEcsmCrypto)` →
-//! `execution_program`). Living in the lambda_vm repo (not in ethrex) means
+//! `run_stateless_guest`). Living in the lambda_vm repo (not in ethrex) means
//! accelerator changes don't require an ethrex PR.
//!
//! Accelerated today:
@@ -19,6 +19,7 @@
//! `0x0b`-`0x11` (aborts). Pinned by `no_{kzg,bls}_backend_linked` in
//! `tooling/ethrex-tests`.
+#[cfg(not(target_arch = "riscv64"))]
use ethrex_crypto::keccak::keccak_hash;
use ethrex_crypto::{Crypto, CryptoError};
use k256::elliptic_curve::group::prime::PrimeCurveAffine;
diff --git a/executor/programs/rust/ethrex/Cargo.lock b/executor/programs/rust/ethrex/Cargo.lock
index 63705e473..441cd6fde 100644
--- a/executor/programs/rust/ethrex/Cargo.lock
+++ b/executor/programs/rust/ethrex/Cargo.lock
@@ -663,13 +663,12 @@ dependencies = [
"ethrex-guest-program",
"lambda-vm-ethrex-crypto",
"lambda-vm-syscalls",
- "rkyv",
]
[[package]]
name = "ethrex-common"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"crc32fast",
@@ -700,8 +699,8 @@ dependencies = [
[[package]]
name = "ethrex-crypto"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"ark-bn254",
"ark-ec",
@@ -720,8 +719,8 @@ dependencies = [
[[package]]
name = "ethrex-guest-program"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"ethereum-types",
@@ -731,6 +730,10 @@ dependencies = [
"ethrex-rlp",
"ethrex-vm",
"hex",
+ "libssz",
+ "libssz-derive",
+ "libssz-merkle",
+ "libssz-types",
"rkyv",
"serde",
"serde_with",
@@ -739,8 +742,8 @@ dependencies = [
[[package]]
name = "ethrex-l2-common"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"ethereum-types",
@@ -757,8 +760,8 @@ dependencies = [
[[package]]
name = "ethrex-levm"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"derive_more",
@@ -775,8 +778,8 @@ dependencies = [
[[package]]
name = "ethrex-rlp"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"ethereum-types",
@@ -785,8 +788,8 @@ dependencies = [
[[package]]
name = "ethrex-trie"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"anyhow",
"bytes",
@@ -805,8 +808,8 @@ dependencies = [
[[package]]
name = "ethrex-vm"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"derive_more",
@@ -1247,18 +1250,18 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libssz"
-version = "0.2.2"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d498c0482bba87d2647ea4601ea76cf2b498065e3958798a88f49274f3ced5e9"
+checksum = "b4bdd6d63ed811ae164966de20810be780e07de784a4834ccfe6be90480c369e"
dependencies = [
"smallvec",
]
[[package]]
name = "libssz-derive"
-version = "0.2.2"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08ddfb5c969c28a4a54043e630f80c723352637bd1020f256ee3ac7a8814922b"
+checksum = "aeee1b9ac9200429f7e9830492765445989ea61c3fb9028ad5a96e1dd5f5e913"
dependencies = [
"proc-macro2",
"quote",
@@ -1267,9 +1270,9 @@ dependencies = [
[[package]]
name = "libssz-merkle"
-version = "0.2.2"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "63c6d6d5ce5d79bba66bc98c99869eedffedf7f14f0aa0915f1a62802650bdf6"
+checksum = "863eca32d1a43e5ec41106a515552efa8307768d37c68d26b7f21ff13cfee1a7"
dependencies = [
"libssz",
"sha2",
@@ -1277,9 +1280,9 @@ dependencies = [
[[package]]
name = "libssz-types"
-version = "0.2.2"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "747273ab2d923e82ed147091fe0fb3e602dd2012c872cdad5efe69e27c3b4099"
+checksum = "d4231ac301726840a3fe111f11bd4619d3c97ed155cb94c88dbf92b70e04e017"
dependencies = [
"libssz",
"libssz-merkle",
diff --git a/executor/programs/rust/ethrex/Cargo.toml b/executor/programs/rust/ethrex/Cargo.toml
index b93265450..8036ae315 100644
--- a/executor/programs/rust/ethrex/Cargo.toml
+++ b/executor/programs/rust/ethrex/Cargo.toml
@@ -33,22 +33,35 @@ lambda-vm-ethrex-crypto = { path = "../../../../crypto/ethrex-crypto" }
# `["dep:k256", "dep:lambda-vm-syscalls", "ethrex-l2-common/secp256k1"]`, and
# since #702 moved the `Crypto` impl into this repo the syscalls it pulled are for
# ethrex's own provider, which we no longer use — while this guest already depends
-# on `lambda-vm-syscalls` by path, above.
+# on `lambda-vm-syscalls` by path, three lines above.
#
# What makes this guest LambdaVM is those path deps plus the
-# `riscv64im-lambda-vm-elf` target, and `lambdavm` existed ONLY on the unmerged
-# branch, so requiring it is what tied this guest to it. Re-pin with
-# scripts/set_ethrex_rev.sh.
+# `riscv64im-lambda-vm-elf` target. None of it travels through a feature: every
+# run reports its ECSM and keccak precompile calls, which is what actually proves
+# the injected crypto is live.
#
-# Measured, not assumed: against the sibling feature activating the widest dependency
-# set, `stress_modexp_150M` (6,373,285,966) and `stress_alt_bn128_150M`
-# (22,986,061,145) are identical to the cycle, and the real benchmark block is
-# 30,498,818 here vs 30,501,620 there, with a 2,928-byte smaller ELF. What the drop
-# actually removes is C libsecp256k1 (`secp256k1`, `secp256k1-sys`, `bitcoin_hashes`,
-# `bitcoin-io`, `hex-conservative` leave the lock); we inject our own ecrecover.
+# Verified rather than assumed. Against the sibling feature activating the widest
+# dependency set, ethrex's own precompile stress fixtures come out identical to
+# the cycle — `stress_modexp_150M` 6,373,285,966 and `stress_alt_bn128_150M`
+# 22,986,061,145 both ways — and the real benchmark block is 30,498,818 here vs
+# 30,501,620 there, with a 2,928-byte smaller ELF. The difference is dead
+# `substrate-bn`/`bls12_381` that the arkworks-based trait defaults never call.
#
-# 0x0a and 0x0b-0x11 are unsupported — see src/main.rs.
-ethrex-guest-program = { git = "https://github.com/lambdaclass/ethrex.git", rev = "797df5540c7d35cafd69b6971a74b2a49c67d1dd", package = "ethrex-guest-program", default-features = false }
-# Exact pin: must match the fixture writer (tooling/ethrex-fixtures) and the
-# executor test reader so the rkyv ProgramInput layout stays consistent.
-rkyv = { version = "=0.8.16", features = ["std", "unaligned"] }
+# Declaring nothing is also what frees the pin: `lambdavm` exists ONLY on that
+# unmerged branch, so requiring it is precisely what tied this guest to it. See
+# scripts/bench_ethrex_pr.sh, which re-pins across arbitrary revs to measure
+# upstream guest PRs, and scripts/set_ethrex_rev.sh, which moves every manifest
+# together.
+#
+# KZG is not linked, so the point-evaluation precompile (0x0a) is unsupported —
+# see src/main.rs.
+ethrex-guest-program = { git = "https://github.com/lambdaclass/ethrex.git", rev = "8effcb0671c5d0b12fe0161ea37c174ec4466b6a", package = "ethrex-guest-program", default-features = false }
+# Defence in depth, inert today. `lambda-vm-syscalls` can reach this graph only as a git
+# dep of `ethrex-guest-program`'s `lambdavm` feature, which this manifest does not enable
+# — the lockfile carries no `git+https://github.com/yetanotherco/lambda_vm.git` source at
+# all, so this patch currently matches nothing and cargo does not warn about it. It stays
+# because the hazard it covers is real and silent: a future ethrex rev that pulls those
+# syscalls from git would link a different copy than the rest of the repo, and the guest
+# is the one binary where that divergence matters.
+[patch."https://github.com/yetanotherco/lambda_vm.git"]
+lambda-vm-syscalls = { path = "../../../../syscalls" }
diff --git a/executor/programs/rust/ethrex/src/main.rs b/executor/programs/rust/ethrex/src/main.rs
index eae31effd..133868899 100644
--- a/executor/programs/rust/ethrex/src/main.rs
+++ b/executor/programs/rust/ethrex/src/main.rs
@@ -1,25 +1,18 @@
use std::sync::Arc;
-use ethrex_guest_program::l1::{ProgramInput, execution_program};
+use ethrex_guest_program::l1::run_stateless_guest;
use lambda_vm_ethrex_crypto::LambdaVmEcsmCrypto;
-use rkyv::rancor::Error;
pub fn main() {
- // 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)
- // and `secp256k1_ecrecover` (via the ECSM precompile); everything else uses
- // ethrex's pure-Rust trait defaults. ethrex's trie/RLP keccak that goes
- // through the free `keccak_hash` fn is still software. Unsupported, no backend
- // linked: KZG 0x0a (reverts) and BLS12-381 0x0b-0x11 (aborts on the unwrap below).
+ // LambdaVM crypto provider, defined in this 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) and
+ // `secp256k1_ecrecover` (via the ECSM precompile); every other `Crypto` method
+ // inherits ethrex's pure-Rust trait default. No KZG backend is linked, so the
+ // point-evaluation precompile (0x0a) is unsupported — `no_kzg_backend_linked`
+ // in tooling/ethrex-tests pins that.
let crypto = Arc::new(LambdaVmEcsmCrypto);
- let output = execution_program(input, crypto).unwrap();
- lambda_vm_syscalls::syscalls::commit(&output.encode());
+ let output = run_stateless_guest(input, crypto);
+ lambda_vm_syscalls::syscalls::commit(&output);
}
diff --git a/executor/tests/README.md b/executor/tests/README.md
index 7ae08e85c..12309f39a 100644
--- a/executor/tests/README.md
+++ b/executor/tests/README.md
@@ -1,31 +1,30 @@
# Executor Test Fixtures
-## Ethrex private inputs
+The `ethrex_*.bin` files are schema-prefixed SSZ stateless inputs consumed by
+`ethrex_guest_program::l1::run_stateless_guest`. The first two bytes are the
+Amsterdam schema ID (`0x1501`); the body contains the payload, public keys, and
+execution witness.
-The `ethrex_*.bin` files are rkyv-serialized `ethrex_guest_program::l1::ProgramInput`
-values consumed by the ethrex guest (`executor/programs/rust/ethrex`).
-
-The native-reference tests live in `tooling/ethrex-tests` (a detached
-workspace: ethrex pins rkyv `unaligned`, which must not feature-unify with the
-main workspace's aligned proof format).
-
-The ethrex guest, the native test reference, and the fixture generator are all
-pinned to the same ethrex revision — a commit on ethrex `main`:
+The guest, native reference tests, and fixture generator all use this ethrex
+commit:
```text
https://github.com/lambdaclass/ethrex.git
-797df5540c7d35cafd69b6971a74b2a49c67d1dd
+8effcb0671c5d0b12fe0161ea37c174ec4466b6a
```
Five manifests carry that pin, not one. `scripts/set_ethrex_rev.sh --show` prints
it and fails if they ever disagree.
+The generator enables Amsterdam in its synthetic genesis and includes the two
+EIP-8282 request predeploys the pinned ethrex requires.
+
### Generation
-These blobs are generated reproducibly by the in-repo tool `tooling/ethrex-fixtures`
-(in-memory, offline — no RPC). It builds a synthetic block with N signed ETH
-transfers from a funded genesis account and serializes the resulting
-`ProgramInput`:
+These blobs are generated reproducibly by the in-repo tool
+`tooling/ethrex-fixtures` (in-memory, offline — no RPC). It builds a synthetic
+block with N signed ETH transfers from a funded genesis account and serializes
+the resulting SSZ stateless input:
```bash
cd tooling/ethrex-fixtures
@@ -35,53 +34,50 @@ cargo run --release -- 10 ../../executor/tests/ethrex_10_transfers.bin # 10 tra
cargo run --release -- 4 ../../executor/tests/ethrex_bench_4.bin distinct # recursion profile
```
+or all four at once with `make regen-ethrex-fixtures` from the repo root.
+
`ethrex_bench_4.bin` is the odd one out: `distinct` mode, and it is read by the
recursion profile target rather than the executor tests (see the Makefile's
`recursion-profile-block-input`). It is committed like the rest, so it is
regenerated and checksummed with them — a rev bump makes every one of these
undecodable, not just the three the executor reads.
-It is also the only committed fixture nothing decodes in PR-blocking CI: the other three
-go through `rkyv::from_bytes` in `tooling/ethrex-tests`, while this one's single in-repo
-reader (`prover/src/tests/page_offset_forgery_poc.rs`) only reads its length.
-
To regenerate after an ethrex rev bump, re-pin with
-`scripts/set_ethrex_rev.sh <40-char-sha>` (all five manifests at once), regenerate the
-five locks, then run `make regen-ethrex-fixtures` from the repo root.
+`scripts/set_ethrex_rev.sh <40-char-sha>` (all five manifests at once), regenerate
+the five locks, then run `make regen-ethrex-fixtures`.
-The checksums below are refreshed by that same run, so they catch a hand-edited `.bin`
-but never one that is stale against the pinned rev. `--show` is what catches the cause.
+The checksums below are refreshed by that same run, so they catch a hand-edited
+`.bin` but never one that is stale against the pinned rev. `--show` is what
+catches the cause.
Known fixtures:
```text
ethrex_empty_block.bin
- sha256: 8d6f6061c71c23fad1d5dee26242d631efe0bff8d7f49422c2ba4cde9d4be919
- contents: stateless ethrex empty block ProgramInput (0 transactions)
+ sha256: d914d36e673dc0e24bc4e105f3037e78305e63f6121e1937058dcc704fabbb8e
+ contents: stateless ethrex empty block (0 transactions)
ethrex_simple_tx.bin
- sha256: c40bce364f22758ab7fa6fe8b45ce4c305dee5add4536ef6dca0e74e410e2729
- contents: stateless ethrex block with one plain ETH transfer transaction
+ sha256: 4dd4ab89d904981844f28b093fde0ed18ffa4d61273482eb8592d41db6a38e7d
+ contents: stateless ethrex block with one plain ETH transfer
ethrex_10_transfers.bin
- sha256: 4d862e8537284729ff11c7bcf91c971e562dd6bbce2a1e181ba5bf48cb6b65cf
- contents: stateless ethrex block with ten plain ETH transfer transactions
+ sha256: e86c5fc80b8b603c4a58fd6ab6ce5bbb40d378c67c8b65f4d89a15b01f69fc6f
+ contents: stateless ethrex block with ten plain ETH transfers
ethrex_bench_4.bin
- sha256: 03ed0d175622af6ef9a981d7652ba7c86630b9473f49cae17edf649724b704e1
- contents: stateless ethrex block with four plain ETH transfers, `distinct` mode
- (N senders -> N recipients); read by the recursion profile target
+ sha256: dbfe0d808ff9476ef70bfd4459b82330a2dc038bdfdf2808447ed04012556386
+ contents: stateless ethrex block with four distinct plain ETH transfers
```
-## Real-block fixtures
-
-The blocks above are synthetic (N plain ETH transfers over a small genesis).
-For a representative workload — real contract execution, real trie depth, real
-bytecode — `make ethrex-real-block-fixture` downloads
-`ethrex_mainnet_25368371_797df554.bin` (1,110,183 B) from the `bench-fixtures-v1` release
-and verifies it against `ETHREX_REAL_BLOCK_FIXTURE_SHA256` in the Makefile before
-moving it into place. It is gitignored rather than committed, so the checksum
-lives next to the URL in the Makefile rather than in the table above (the checksum
-script only covers committed fixtures). See
-`tooling/ethrex-block-converter/README.md` for how the fixture is produced and
-repointed.
+## Real-block fixture
+
+The blocks above are synthetic. For a representative workload — real contract
+execution, real trie depth, real bytecode — `make ethrex-real-block-fixture`
+BUILDS `ethrex_mainnet_25453112.bin` from the block's replay cache, which is the
+only fetched artifact; nothing about the fixture is published, because the pinned
+guest decodes only the Amsterdam schema and no hosted artifact for a pre-Amsterdam
+block can be valid. It is gitignored rather than committed, so its digest lives
+next to the block pin in the Makefile rather than in the table above, and it is
+verified on every use. See `tooling/ethrex-fixtures/README.md` for what that
+workload is, what it costs, and what it is not.
diff --git a/executor/tests/ethrex_10_transfers.bin b/executor/tests/ethrex_10_transfers.bin
index 201351189..90eb03c15 100644
Binary files a/executor/tests/ethrex_10_transfers.bin and b/executor/tests/ethrex_10_transfers.bin differ
diff --git a/executor/tests/ethrex_bench_4.bin b/executor/tests/ethrex_bench_4.bin
index 23c2453ee..3a0cba109 100644
Binary files a/executor/tests/ethrex_bench_4.bin and b/executor/tests/ethrex_bench_4.bin differ
diff --git a/executor/tests/ethrex_empty_block.bin b/executor/tests/ethrex_empty_block.bin
index abec5263d..3b3598e99 100644
Binary files a/executor/tests/ethrex_empty_block.bin and b/executor/tests/ethrex_empty_block.bin differ
diff --git a/executor/tests/ethrex_simple_tx.bin b/executor/tests/ethrex_simple_tx.bin
index 055f6f73c..f9228b8fd 100644
Binary files a/executor/tests/ethrex_simple_tx.bin and b/executor/tests/ethrex_simple_tx.bin differ
diff --git a/scripts/bench_abba.sh b/scripts/bench_abba.sh
index 0baa21db7..f997c1b8b 100755
--- a/scripts/bench_abba.sh
+++ b/scripts/bench_abba.sh
@@ -48,17 +48,21 @@
# WORKLOAD=synthetic to reproduce a number recorded against that fixture.
#
# Sizing at WORKLOAD=real, from the paired t-test (resolvable 95% delta =
-# t* x sd / sqrt(N)). The pair-delta sd on the bench runner is NOT yet measured; the
-# two columns bracket it between 1.0% (the GPU box's measured 0.64% plus margin) and
-# 2.0% (sqrt(2) x the runner's measured 1.43% single-run CV):
+# t* x sd / sqrt(N)). The two columns are the same runner under two conditions, not a
+# guess bracketing an unknown: its variance is contention, so the single-run CV is
+# 0.34% across the proves that got the most CPU and 1.26% across all of a 14-prove
+# baseline. sd of a pair delta is sqrt(2) x that. Keep this table in sync with the one
+# in .github/workflows/bench-abba.yml:
#
-# pairs wall resolves (sd 2.0% / sd 1.0%)
-# 8 ~50 min 1.7% / 0.8%
-# 12 ~72 min 1.3% / 0.6% <- workflow default
-# 20 ~1h55m 0.9% / 0.5%
-# 32 ~3h 0.7% / 0.4%
+# pairs wall resolves (quiet box, sd 0.49% / shared, sd 1.78%)
+# 8 ~41 min 0.34% / 1.24%
+# 12 ~58 min 0.28% / 1.01% <- workflow default
+# 20 ~1h31m 0.21% / 0.78%
+# 32 ~2h22m 0.16% / 0.62%
#
-# Wall assumes epoch 2^22 (158.8 s per prove, two per pair) plus ~8 min of setup.
+# Wall assumes epoch 2^22 (~125 s per prove, two per pair) plus ~8 min of setup. Read
+# the column the run earned: the exclusivity line printed after the pairs reports the
+# CPU share of every prove and flags any under 90% of the batch's best.
# The first real ABBA run MEASURES that sd — read it off the `sd` field of the
# paired-t line printed below — and this table should be re-pinned to it.
#
@@ -148,12 +152,11 @@ if [ ! -f "$ELF_REL" ]; then
make "$ELF_REL"
fi
if [ "$WORKLOAD" = "real" ]; then
- # ~1 MB, gitignored, never in a fresh checkout — and a rented GPU box is always a
- # fresh checkout. Fetched by URL + sha256, not built: no converter, no ethrex host
- # dependency tree, so this costs seconds on the box. Unconditional on purpose: the
- # target hashes whatever is on disk on every invocation, which is what catches a
- # copy left behind by an earlier run in the same rental. A match costs ~35 ms.
- echo "==> Verifying ethrex real-block fixture (fetches on a digest miss)"
+ # Gitignored, never in a fresh checkout — and a rented GPU box is always a fresh
+ # checkout. Built from the block's replay cache (fetched by URL + sha256): ethrex 25's
+ # guest decodes only the Amsterdam schema, so no hosted artifact for this block can be
+ # valid. The generator validates the block through the guest before writing.
+ echo "==> Building ethrex real-block fixture (from its replay cache)"
make ethrex-real-block-fixture
elif [ ! -f "$INPUT_REL" ]; then
echo "==> Generating ethrex ${TX_COUNT}-transfer fixture (missing)"
@@ -163,6 +166,28 @@ fi
ELF="$(cd "$(dirname "$ELF_REL")" && pwd)/$(basename "$ELF_REL")"
INPUT="$(cd "$(dirname "$INPUT_REL")" && pwd)/$(basename "$INPUT_REL")"
+# A workload the guest REJECTS still produces a proof -- of a program that decoded two
+# bytes and gave up. `run_stateless_guest` cannot fail: on a schema it does not
+# recognise it commits `successful_validation = 0` and exits cleanly, which is what the
+# pre-Amsterdam rkyv fixture now does in 496 cycles. Proving that reads as a ~99%
+# improvement, in green, on both sides of the A/B. One cheap execution up front turns
+# that class of mistake -- stale fixture, wrong fork, fixture built against another
+# ethrex rev -- into a hard stop. The floor is far below any real block (the current
+# one is ~37M cycles) and far above a rejected one.
+#
+# Real workload only. The synthetic fixtures are regenerated by the same tool at the
+# same rev and are gated twice already (`make check-ethrex-fixture-checksums` plus the
+# `successful_validation == 1` assert in tooling/ethrex-tests), while a small TX_COUNT
+# legitimately lands near this floor -- a 1-transfer block ran 1.80M cycles before the
+# bump, an empty one 0.99M. Applying the floor there would reject valid runs.
+#
+# Runs after step 2 and reuses `cli_B`: the baseline prover executes the workload just as
+# well as a freshly built one, and building a third binary here would be a full extra
+# release build -- at the repo root with default features, so sharing neither the
+# worktree's target dir nor its feature set. Free on a warm runner, a cold build on paid
+# time on a rented GPU box.
+MIN_PLAUSIBLE_CYCLES=1000000
+
# --- 2. Build (or reuse) both prover binaries ---
need_build=0
if [ "${REBUILD:-0}" = "1" ] || [ ! -x "$WORK/cli_A" ] || [ ! -x "$WORK/cli_B" ]; then
@@ -212,12 +237,49 @@ else
echo " cli_A=${SHA_A:0:10} cli_B=${SHA_B:0:10} features=$BENCH_FEATURES"
fi
+if [ "$WORKLOAD" = "real" ]; then
+ # `|| workload_cycles=""` keeps the failure inside a compound: a bare assignment from a
+ # failing command substitution aborts under `set -e` before the diagnostic below can
+ # print, which is fail-closed but silent.
+ workload_cycles="$("$WORK/cli_B" execute "$ELF" --private-input "$INPUT" --cycles 2>&1 \
+ | awk '/^Cycles:/ {print $2}')" || workload_cycles=""
+ if [ "${workload_cycles:-0}" -lt "$MIN_PLAUSIBLE_CYCLES" ]; then
+ echo "ERROR: the workload executed only ${workload_cycles:-0} cycles, below the" >&2
+ echo " ${MIN_PLAUSIBLE_CYCLES} floor. The guest almost certainly rejected the input:" >&2
+ echo " ELF $ELF" >&2
+ echo " input $INPUT" >&2
+ echo " Rebuild the fixture at this ethrex rev (make regen-real-block-fixture)." >&2
+ exit 1
+ fi
+ echo "==> Workload executes: $workload_cycles cycles"
+fi
+
# --- 3. Interleaved A/B/B/A measurement (fresh CSV -- pre-committed batch) ---
+# Every prove also records the share of CPU it actually got. On a shared box that
+# is the difference between a number and a coincidence: measured on the bench
+# runner, wall time and CPU share correlate at -0.98 across a ten-prove sweep,
+# perfectly monotonic, and a colleague's job landing mid-sweep cost 47 of 75 cores
+# and +73% of wall. So the spread this script reports is a property of how
+# exclusive the box was, not of the prover -- gated to the runs that got the most
+# CPU, the same sweep's CV falls from 1.54% to 0.34%. The ABBA pairing cancels most
+# of it (both sides meet the same neighbours), which is why this flags rather than
+# discards; a flagged batch is one whose spread should not be read as prover noise.
+CPU_SHARES="$WORK/cpu_shares.txt"; : > "$CPU_SHARES"
+TIME_BIN=""
+if /usr/bin/time -f %P true >/dev/null 2>&1; then TIME_BIN=/usr/bin/time; fi
+
run_prove() { # $1=binary -> echoes proving time (s)
- local out t
+ local out t share tf
+ tf="$(mktemp)"
# shellcheck disable=SC2086 # CONT_ARGS is intentionally word-split (0 or 2 args)
- out="$("$1" prove "$ELF" --private-input "$INPUT" -o "$PROOF" --time $CONT_ARGS 2>&1)"
- rm -f "$PROOF"
+ if [ -n "$TIME_BIN" ]; then
+ out="$($TIME_BIN -f '%P' -o "$tf" "$1" prove "$ELF" --private-input "$INPUT" -o "$PROOF" --time $CONT_ARGS 2>&1)"
+ else
+ out="$("$1" prove "$ELF" --private-input "$INPUT" -o "$PROOF" --time $CONT_ARGS 2>&1)"
+ fi
+ share="$(tr -d '%' < "$tf" | tr -d '[:space:]')"
+ rm -f "$tf" "$PROOF"
+ case "$share" in ''|*[!0-9]*) : ;; *) echo "$share" >> "$CPU_SHARES" ;; esac
t="$(printf '%s\n' "$out" | grep -o 'Proving time: [0-9.]*' | awk '{print $3}')"
if [ -z "$t" ]; then
echo "ERROR: could not parse 'Proving time' from cli output:" >&2
@@ -240,6 +302,29 @@ for i in $(seq 1 "$N_PAIRS"); do
"$i" "$N_PAIRS" "$a" "$b" "$(awk "BEGIN{print ($a-$b)/$b*100}")"
done
+# Exclusivity report. Self-calibrating: the best prove of this batch defines what
+# the box can give, so a run well under it met a neighbour. No box-specific
+# constant, which matters because this script also runs on rented 16-32 core GPU
+# hosts where an absolute percentage means nothing.
+if [ -s "$CPU_SHARES" ]; then
+ awk '
+ { n++; s[n]=$1; if ($1>mx) mx=$1; if (mn==0 || $1 Exclusivity: CPU share %d%%-%d%% of %d proves", mn, mx, n
+ if (bad) {
+ printf ", %d below 90%% of the best\n", bad
+ printf " Something else was on the box. The pairing absorbs most of it, but do\n"
+ printf " not read this batch spread as prover noise, and re-run on a quiet box\n"
+ printf " before quoting a resolvable delta.\n"
+ } else {
+ printf ", all within 10%% of the best\n"
+ }
+ }' "$CPU_SHARES"
+fi
+
# --- 4. Paired t-test + robust median/Wilcoxon ---
python3 - "$WORK/pairs.csv" <<'PY'
import sys, csv, math
diff --git a/scripts/bench_verify.sh b/scripts/bench_verify.sh
index 369041c48..9deaea0df 100755
--- a/scripts/bench_verify.sh
+++ b/scripts/bench_verify.sh
@@ -37,7 +37,7 @@
# CONT_EPOCH_LOG2= continuation epoch size (default 20, min 18). 20 is the
# laptop-safe setting, not the fast one: prefer the calibrated tier for the box
# you are on — 2^22 on the bench runner or a 64 GiB machine, 2^23 on a 128 GiB
-# one (see tooling/ethrex-block-converter/README.md, "Choosing the epoch size"),
+# one (see tooling/ethrex-fixtures/README.md, "Choosing the epoch size"),
# which is what /bench and /bench-abba pin. (`cli prove --epoch-size-log2 --help`
# measured ethrex 10tx at ~9.5 GB for 2^20 vs ~15.8 GB for 2^21.) Note this does
# NOT match bench_recursion_cycles.sh's BLOCK_EPOCH_LOG2=21: that arm needs FEW
diff --git a/scripts/perf_diff.sh b/scripts/perf_diff.sh
index 2c359a548..8e3150761 100755
--- a/scripts/perf_diff.sh
+++ b/scripts/perf_diff.sh
@@ -16,19 +16,22 @@
# Env: WORKLOAD=real|synthetic (default real) picks the block to profile;
# EPOCH_SIZE_LOG2= (default 22) sizes the epoch, WORKLOAD=real only.
# 22 is the calibrated bench-runner tier, matching /bench; use 23 on a
-# 128 GiB box (tooling/ethrex-block-converter/README.md, "Choosing the epoch size").
+# 128 GiB box (tooling/ethrex-fixtures/README.md, "Choosing the epoch size").
#
-# Pick the workload that matches the run you are localizing, because the symbol
-# mix follows the block: the real default is 50.78M cycles, 10,478 keccak calls and
-# 116 ecsm calls, and the synthetic option (20 plain transfers) inverts that at
-# 8.73M cycles, 411 keccak, 80 ecsm — so a hot symbol in one need not be hot in the other.
-# Both counts are from the same guest ELF (merge fdb92f67, main @ 9ccdaf2, clang 21);
-# they move with guest optimisation (#861's thin LTO) and ~2% with the clang major, so
-# pin the ELF when quoting one.
+# Pick the workload that matches the run you are localizing, because the symbol mix
+# follows the block: the real default is 37,137,748 cycles, 6,003 keccak calls and 164
+# ecsm calls, and the synthetic option (20 plain transfers) shifts that to 7,029,393
+# cycles, 486 keccak, 80 ecsm — a keccak:ecsm ratio of 37:1 against 6:1, so a hot symbol
+# in one need not be hot in the other.
#
-# WORKLOAD=real also switches to a continuation prove (monolithic would need ~240 GB
-# at that trace length), which is 158.8 s per recording on the bench runner — five
-# recordings, so budget ~13 min of proving, plus ~1.2 GB of disk per bundle and ~52 GB
+# Both counts are deterministic for a given ELF and input, and belong to the ethrex rev
+# in the manifests (`scripts/set_ethrex_rev.sh --show`) — they move with the rev, with
+# guest optimisation, and possibly with the compiler, which the Makefile does not pin.
+# See "Pin the ELF whenever you quote a cycle count" in tooling/ethrex-fixtures/README.md.
+#
+# WORKLOAD=real also switches to a continuation prove (monolithic would need ~182 GB at
+# that trace length), which is ~125 s per recording on the bench runner — five
+# recordings, so budget ~11 min of proving, plus ~790 MB of disk per bundle and ~45 GiB
# of RAM at the default epoch.
#
# Produces:
@@ -49,8 +52,9 @@ REF_A="$1"
REF_B="${2:-origin/main}"
WORKLOAD="${WORKLOAD:-real}"
# 2^22: the calibrated tier for the bench server this script targets, same as
-# /bench's real-block arm. Memory picks it, not speed — that server peaks at ~52 GB on
-# a >=64 GiB floor, and 2^23 measured 60 GiB on a roomier box, so it would not fit here.
+# /bench's real-block arm. Memory picks it, not speed — this workload peaks at 44.81 GiB
+# there against a >=64 GiB floor, and 2^23 would take it past 50 GiB for ~7% of wall
+# (the epoch sweep is in tooling/ethrex-fixtures/README.md).
EPOCH_SIZE_LOG2="${EPOCH_SIZE_LOG2:-22}"
case "$WORKLOAD" in
synthetic|real) ;;
diff --git a/tooling/ethrex-block-converter/Cargo.lock b/tooling/ethrex-block-converter/Cargo.lock
index 5c216acbb..e741597cc 100644
--- a/tooling/ethrex-block-converter/Cargo.lock
+++ b/tooling/ethrex-block-converter/Cargo.lock
@@ -510,12 +510,6 @@ dependencies = [
"cfg-if",
]
-[[package]]
-name = "critical-section"
-version = "1.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
-
[[package]]
name = "crossbeam"
version = "0.8.4"
@@ -777,12 +771,6 @@ dependencies = [
"zeroize",
]
-[[package]]
-name = "embedded-hal"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89"
-
[[package]]
name = "enum-ordinalize"
version = "4.4.1"
@@ -854,18 +842,17 @@ dependencies = [
"ethrex-config",
"ethrex-crypto",
"ethrex-guest-program",
- "k256",
- "lambda-vm-ethrex-crypto",
- "rkyv",
+ "ethrex-rlp",
+ "libssz",
+ "libssz-types",
"serde",
"serde_json",
- "sha2",
]
[[package]]
name = "ethrex-blockchain"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"crossbeam",
@@ -889,8 +876,8 @@ dependencies = [
[[package]]
name = "ethrex-common"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"crc32fast",
@@ -912,6 +899,7 @@ dependencies = [
"once_cell",
"rkyv",
"rustc-hash",
+ "secp256k1",
"serde",
"serde_json",
"sha2",
@@ -921,8 +909,8 @@ dependencies = [
[[package]]
name = "ethrex-config"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"ethrex-common",
"ethrex-p2p",
@@ -933,8 +921,8 @@ dependencies = [
[[package]]
name = "ethrex-crypto"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"ark-bn254",
"ark-ec",
@@ -947,6 +935,7 @@ dependencies = [
"num-bigint",
"p256",
"ripemd",
+ "secp256k1",
"sha2",
"thiserror 2.0.19",
"tiny-keccak",
@@ -954,8 +943,8 @@ dependencies = [
[[package]]
name = "ethrex-guest-program"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"ethereum-types",
@@ -965,6 +954,10 @@ dependencies = [
"ethrex-rlp",
"ethrex-vm",
"hex",
+ "libssz",
+ "libssz-derive",
+ "libssz-merkle",
+ "libssz-types",
"rkyv",
"serde",
"serde_with",
@@ -973,8 +966,8 @@ dependencies = [
[[package]]
name = "ethrex-l2-common"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"ethereum-types",
@@ -991,8 +984,8 @@ dependencies = [
[[package]]
name = "ethrex-levm"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"derive_more",
@@ -1009,8 +1002,8 @@ dependencies = [
[[package]]
name = "ethrex-metrics"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"ethrex-common",
"serde",
@@ -1021,8 +1014,8 @@ dependencies = [
[[package]]
name = "ethrex-p2p"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"aes",
"aes-gcm",
@@ -1063,8 +1056,8 @@ dependencies = [
[[package]]
name = "ethrex-rlp"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"ethereum-types",
@@ -1073,8 +1066,8 @@ dependencies = [
[[package]]
name = "ethrex-storage"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"anyhow",
"bytes",
@@ -1095,8 +1088,8 @@ dependencies = [
[[package]]
name = "ethrex-trie"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"anyhow",
"bytes",
@@ -1115,8 +1108,8 @@ dependencies = [
[[package]]
name = "ethrex-vm"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"derive_more",
@@ -1622,27 +1615,6 @@ version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37"
-[[package]]
-name = "lambda-vm-ethrex-crypto"
-version = "0.1.0"
-dependencies = [
- "ethrex-crypto",
- "k256",
- "lambda-vm-syscalls",
-]
-
-[[package]]
-name = "lambda-vm-syscalls"
-version = "0.1.0"
-dependencies = [
- "getrandom 0.2.17",
- "getrandom 0.3.4",
- "lazy_static",
- "rand 0.9.5",
- "riscv",
- "thiserror 1.0.69",
-]
-
[[package]]
name = "lambdaworks-crypto"
version = "0.13.0"
@@ -1691,18 +1663,18 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libssz"
-version = "0.2.2"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d498c0482bba87d2647ea4601ea76cf2b498065e3958798a88f49274f3ced5e9"
+checksum = "b4bdd6d63ed811ae164966de20810be780e07de784a4834ccfe6be90480c369e"
dependencies = [
"smallvec",
]
[[package]]
name = "libssz-derive"
-version = "0.2.2"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08ddfb5c969c28a4a54043e630f80c723352637bd1020f256ee3ac7a8814922b"
+checksum = "aeee1b9ac9200429f7e9830492765445989ea61c3fb9028ad5a96e1dd5f5e913"
dependencies = [
"proc-macro2",
"quote",
@@ -1711,9 +1683,9 @@ dependencies = [
[[package]]
name = "libssz-merkle"
-version = "0.2.2"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "63c6d6d5ce5d79bba66bc98c99869eedffedf7f14f0aa0915f1a62802650bdf6"
+checksum = "863eca32d1a43e5ec41106a515552efa8307768d37c68d26b7f21ff13cfee1a7"
dependencies = [
"libssz",
"sha2",
@@ -1721,9 +1693,9 @@ dependencies = [
[[package]]
name = "libssz-types"
-version = "0.2.2"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "747273ab2d923e82ed147091fe0fb3e602dd2012c872cdad5efe69e27c3b4099"
+checksum = "d4231ac301726840a3fe111f11bd4619d3c97ed155cb94c88dbf92b70e04e017"
dependencies = [
"libssz",
"libssz-merkle",
@@ -2334,36 +2306,6 @@ dependencies = [
"digest",
]
-[[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 = "rkyv"
version = "0.8.16"
diff --git a/tooling/ethrex-block-converter/Cargo.toml b/tooling/ethrex-block-converter/Cargo.toml
index ba7a458b2..877a3087f 100644
--- a/tooling/ethrex-block-converter/Cargo.toml
+++ b/tooling/ethrex-block-converter/Cargo.toml
@@ -7,16 +7,16 @@ edition = "2024"
[workspace]
[dependencies]
-# Pinned to the SAME ethrex rev as the guest so the generated ProgramInput rkyv
-# layout matches what the guest deserializes. Re-pin all five manifests together
-# with scripts/set_ethrex_rev.sh.
-ethrex-common = { git = "https://github.com/lambdaclass/ethrex.git", rev = "797df5540c7d35cafd69b6971a74b2a49c67d1dd", package = "ethrex-common", default-features = false }
-ethrex-config = { git = "https://github.com/lambdaclass/ethrex.git", rev = "797df5540c7d35cafd69b6971a74b2a49c67d1dd", package = "ethrex-config", default-features = false }
-# Only for `NativeCrypto`, the host `Crypto` impl that `into_execution_witness`
-# now takes. `ethrex_guest_program::crypto` re-exports it, but this crate's own
-# guest-crypto impl is a dev-dependency, so the bin build needs its own line.
-# Already in the graph via `ethrex-config` → `ethrex-p2p`, so this adds no compilation.
-ethrex-crypto = { git = "https://github.com/lambdaclass/ethrex.git", rev = "797df5540c7d35cafd69b6971a74b2a49c67d1dd", package = "ethrex-crypto", default-features = false }
+# Pinned to the SAME ethrex rev as the guest so the generated SSZ input matches
+# what the stateless guest deserializes.
+ethrex-common = { git = "https://github.com/lambdaclass/ethrex.git", rev = "8effcb0671c5d0b12fe0161ea37c174ec4466b6a", package = "ethrex-common", default-features = false, features = ["secp256k1"] }
+ethrex-config = { git = "https://github.com/lambdaclass/ethrex.git", rev = "8effcb0671c5d0b12fe0161ea37c174ec4466b6a", package = "ethrex-config", default-features = false }
+# Provides `NativeCrypto`, the host `Crypto` implementation used to recover
+# transaction keys, hash the BAL, and validate the generated stateless input.
+# Already in the graph via `ethrex-config` -> `ethrex-p2p`, so this adds no
+# compilation; the line exists because the bin build needs its own.
+ethrex-crypto = { git = "https://github.com/lambdaclass/ethrex.git", rev = "8effcb0671c5d0b12fe0161ea37c174ec4466b6a", package = "ethrex-crypto", default-features = false, features = ["secp256k1"] }
+ethrex-rlp = { git = "https://github.com/lambdaclass/ethrex.git", rev = "8effcb0671c5d0b12fe0161ea37c174ec4466b6a", package = "ethrex-rlp" }
# Matches the guest's own declaration (executor/programs/rust/ethrex/Cargo.toml).
#
# CAVEAT: this line alone does NOT reproduce the guest's precompile surface. The
@@ -27,36 +27,10 @@ ethrex-crypto = { git = "https://github.com/lambdaclass/ethrex.git", rev = "797d
# verify with `cargo tree -e features -i ethrex-crypto`. c-kzg is the whole gap now —
# malachite modexp and `ark-ff/asm` are on both sides since ethrex-trie's `std` default.
# Closing it means dropping `ethrex-config` and sourcing `ChainConfig` another way.
-ethrex-guest-program = { git = "https://github.com/lambdaclass/ethrex.git", rev = "797df5540c7d35cafd69b6971a74b2a49c67d1dd", package = "ethrex-guest-program", default-features = false }
+ethrex-guest-program = { git = "https://github.com/lambdaclass/ethrex.git", rev = "8effcb0671c5d0b12fe0161ea37c174ec4466b6a", package = "ethrex-guest-program", default-features = false }
-# Exact pin: the fixture writer and the guest/executor readers must agree on the
-# rkyv layout. Keep this in sync with the guest
-# (executor/programs/rust/ethrex/Cargo.toml) and the other two detached ethrex
-# tool workspaces (tooling/ethrex-fixtures, tooling/ethrex-tests). The `executor`
-# crate itself declares no rkyv — the guest is what deserializes ProgramInput.
-rkyv = { version = "=0.8.16", features = ["std", "unaligned"] }
+# Exact pin: keep the SSZ implementation in sync with the guest's ethrex rev.
+libssz = "=0.3.0"
+libssz-types = "=0.3.0"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
-
-# Not used by this crate's code directly — declared to pin k256's feature set.
-# `lambda-vm-ethrex-crypto` (a dev-dependency) is otherwise the only thing asking
-# for `expose-field`, and under resolver 3 dev-dependency features are not unified
-# into builds that exclude dev-dependencies. This crate is built both ways against
-# one target dir: `cargo run` for `make regen-real-block-fixture`, `cargo test` for
-# `make test-ethrex-real-block-converter`. Without this line the two resolve
-# different k256 feature sets, so alternating between them recompiles k256 and every
-# ethrex crate above it. Declaring the features here puts them in both resolutions.
-# Keep in sync with crypto/ethrex-crypto's k256 line; verify:
-# cargo tree -e features,no-dev -i k256 # must list expose-field
-# cargo tree -e features -i k256 # must match the above
-k256 = { version = "=0.13.4", default-features = false, features = ["arithmetic", "expose-field"] }
-
-[dev-dependencies]
-# The `Crypto` impl the guest injects. Off riscv64 its keccak and ECSM paths both
-# fall back to software, so this exercises the guest's *fallback* crypto semantics
-# — not the accelerators themselves. (Its k256 feature needs are mirrored in
-# [dependencies] above — see the comment there.)
-lambda-vm-ethrex-crypto = { path = "../../crypto/ethrex-crypto" }
-# No feature delta against the rest of the graph (sha2/std is already on), so this
-# one can stay a dev-dependency without forcing the rebuild described above.
-sha2 = "0.10"
diff --git a/tooling/ethrex-block-converter/README.md b/tooling/ethrex-block-converter/README.md
index 1d14d6c9b..5f3197ea7 100644
--- a/tooling/ethrex-block-converter/README.md
+++ b/tooling/ethrex-block-converter/README.md
@@ -1,576 +1,62 @@
# ethrex-block-converter
-Converts a **real Ethereum block** into a serialized `ProgramInput` `.bin` for
-the lambda-vm ethrex guest, reading an [`ethrex-replay`][replay] cache JSON.
+Converts an `ethrex-replay` cache into the schema-prefixed SSZ stateless input
+consumed by `ethrex_guest_program::l1::run_stateless_guest`.
-This complements `tooling/ethrex-fixtures`, which builds *synthetic* blocks of N
-plain ETH transfers. Those are cheap and deterministic but not representative:
-they execute no contract code, touch a genesis state trie only a couple of
-levels deep, and carry no bytecode in the witness. This tool produces the
-opposite — a block that actually looks like Ethereum.
-
-Figures below are for the **current default** real block, mainnet 25368371; a
-repoint replaces them (see [Adopting a different block](#adopting-a-different-block)).
-All are measured. Everything downstream of the archive layout — serialized size, cycles
-and the accelerator counts — also moves with the pinned ethrex rev, so each column says
-which rev it was taken at; the synthetic column has not been re-measured since the
-`797df554` bump.
-
-| | `ethrex_bench_20.bin` (synthetic, `4f658c2b`) | `ethrex_mainnet_25368371.bin` (real, default, `797df554`) |
-|---|---|---|
-| gas used | 420,000 | **2,428,684** |
-| transactions | 20 (all plain transfers) | 29 (real mix) |
-| serialized size | 32,766 B | 1,110,183 B |
-| cycles | 8,734,622 | **30,498,818** |
-| keccak / ecsm calls | 411 / 80 | **10,659** / 116 |
-| keccaks per ECSM call | 5.1 | **92** |
-
-The last row counts keccaks per **ECSM call**, which is the ratio of the two rows
-above it. One ecrecover issues four ECSM ecalls — `lincomb2_with_oracle` in
-`crypto/ethrex-crypto/src/lib.rs` makes four oracle queries, each an `ecsm_mul` — so
-per *ecrecover* the figures are 20.6 and 361, and the 80 and 116 above are 4x the 20
-synthetic and 29 real signature checks.
-
-Note the real block uses ~5.8x the gas and costs ~5.8x the cycles here, and that it
-inverts the crypto mix: the synthetic block is ecrecover-bound, the real one keccak-
-and trie-bound. That inversion is the point — a prover change can move the two
-numbers in opposite directions. (The gas and cycle ratios agreeing is a coincidence
-of this pair, not a rule: cycles/gas is 20.9 for this block on this ELF, and spans
-29.7–38.2 across the three candidate blocks when all three are measured on one
-common pre-LTO ELF — a ~29% spread.)
-
-**Pin the ELF whenever you quote a cycle count.** Counts above were measured on this
-branch at merge `fdb92f67` (main @ `9ccdaf2`), guest built with **clang 21.1.8**.
-Two things move them:
-- **Guest optimisation.** #861 gave the guest thin LTO; this block read 74,819,518
- on a mid-July pre-LTO ELF, so anything quoting ~74.8M or ~65.6M is **superseded**.
-- **clang major version**, by ~2%. The guest embeds C (secp256k1-sys) and the
- Makefile pins target flags but not the compiler, so `cc` picks up whatever `clang`
- is on PATH. The RTX 5090 box (clang-18) measured **50,713,534** for this block on
- main @ `9ccdaf2` — 0.13% below the number above, same commit, different compiler.
-
-Why this block specifically: it was the **only** block in a 90-day Dune sweep that
-matched the shape constraints in the 1.6–2.6M gas band — exactly 2 heavy
-transactions, no single whale transaction dominating, and a sane plain-transfer
-share. Its composition is 11.94 tx/Mgas, 44.3% of gas in heavy transactions, 22.5%
-in the top transaction, p50 transaction gas 41,297. A block that is merely *large*
-is easy to find; one that is structurally typical is not.
-
-The synthetic column is `ethrex_bench_20.bin` as the benchmark scripts actually
-generate it — `ethrex-fixtures 20 … distinct`, i.e. 20 distinct genesis-funded
-senders to 20 distinct recipients (`scripts/bench_verify.sh`,
-`scripts/bench_recursion_scaling.sh`). The same block in `same` mode (one sender,
-one recipient) serializes to 16,811 B.
-
-Block 25368371 is **verified to run on the guest's precompile surface** (see
-[Validation](#validation)); it needs no accelerator we don't have. Any
-replacement block must clear the same check — that is what makes it usable, not
-just realistic.
-
-## Getting the fixture
-
-The fixture is **fetched, not built**:
-
-```bash
-make ethrex-real-block-fixture
-```
-
-That downloads the finished `.bin` from `ETHREX_REAL_BLOCK_FIXTURE_URL` and
-verifies `ETHREX_REAL_BLOCK_FIXTURE_SHA256` before moving it into place —
-the same contract as `prepare-sysroot`. The file is gitignored (~1 MB; see
-`executor/.gitignore`), and a corrupt or interrupted download is discarded rather
-than left looking valid.
-
-The digest of whatever is already on disk is re-checked on every invocation, not
-only when the file is missing — so a stale copy left over from a re-upload under the
-same block number, a corrupted file, or a hand-placed one is all caught and
-re-fetched. That check is the reason these are phony targets rather than file rules.
-
-Artifacts live in the **[`bench-fixtures-v1`][release]** release on
-`yetanotherco/lambda_vm`, fetched unauthenticated:
-
-| asset | sha256 | read by |
-|---|---|---|
-| `ethrex_mainnet_25368371_797df554.bin` | `573004e6…` | every benchmark (**current default**) |
-| `ethrex_mainnet_25368371.bin` | `61eba49b…` | superseded; `main` still fetches this one |
-| `ethrex_mainnet_25368371_4f658c2b.bin` | `0a301731…` | superseded (intermediate rev, unused) |
-| `cache_mainnet_25368371.json` | `7aa88a5f…` | `regen-real-block-fixture` |
-| `ethrex_mainnet_25453112.bin` | `0298663d…` | alternate candidate |
-| `cache_mainnet_25453112.json` | `20ffbbc1…` | alternate candidate |
-
-> The asset name carries the ethrex rev because the bytes are a function of it: the
-> archived `ProgramInput` rkyv layout moves with the pin, so one block has one
-> fixture per rev. The pre-bump bytes stay hosted under the original name
-> (`ethrex_mainnet_25368371.bin`, `61eba49b…`) so older `main`s — whose Makefile
-> pins that sha256 — keep fetching their own artifact.
-
-Each block has two assets: the fixture and the **cache** it was converted from
-(`make ethrex-real-block-cache`, ~2 MB, same verify-then-move contract). Only
-`regen-real-block-fixture` reads the cache. Note it is *not* the cache the
-converter's own tests use — see [Validation](#validation).
-
-This crate is not on the fixture's path at all. Fetching a verified binary takes the
-converter, the ~335-package ethrex host dependency tree and an ethrex-replay `rev`
-pin off the critical path of everyone who just wants to run a benchmark. It also
-decouples the benchmark block from what upstream hosts: ethrex-replay publishes a
-cache for Hoodi and nothing else, so any mainnet block is unreachable by the
-convert-locally route — producing its cache takes ~4 minutes and ~700 calls against
-an archive RPC — and trivial by this one.
-
-[release]: https://github.com/yetanotherco/lambda_vm/releases/tag/bench-fixtures-v1
-
-## Regenerating the fixture (ethrex rev bumps)
-
-Needed roughly twice a year, when the guest's ethrex `rev` moves and the rkyv
-layout changes with it:
-
-```bash
-make regen-real-block-fixture # fetches that block's cache, rebuilds the .bin
-sha256sum "$(make -s print-real-block-fixture)"
-```
-
-Then upload the result and update `ETHREX_REAL_BLOCK_FIXTURE_SHA256` and its URL.
-
-Directly, against any cache file:
+The converter reads the block, raw witness preimages, and public network from
+the cache. It builds the Amsterdam `NewPayloadRequest`, recovers one public key
+per transaction, carries the raw state/code/header witness, and validates the
+serialized result with ethrex's native guest before writing it.
```bash
cd tooling/ethrex-block-converter
cargo run --release --
```
-Output is deterministic for a given cache file:
-
-```text
-wrote ../../executor/tests/ethrex_mainnet_25368371.bin (1110183 bytes): 1 block(s) \
- from mainnet starting at #25368371, 29 transaction(s), 2428684 gas
-```
-
-Verified at the current ethrex rev (`797df554`): regenerating from the hosted cache
-reproduces `573004e6…` byte for byte, and the result passes
-`test_ethrex_real_block_native` — which is what proves the hosted cache and the fixture
-the Makefile expects describe the same block. Byte count and digest are both
-rev-dependent; this block has one archive per rev it has been pinned at
-(1,110,156 B / `61eba49b…`, then 1,110,165 B / `0a301731…`, now this one), which is why
-the release assets carry the rev in their names.
-
-The converter's `conversion_is_reproducible` test enforces the same property, but
-against its own pinned block rather than this one — see [Validation](#validation).
-
-## What benchmarks with it
-
-Costs below are for the **current default block** (mainnet 25368371) and move with
-it — see [Measured cost of candidate blocks](#measured-cost-of-candidate-blocks).
-
-| Where | How to run it | Cost (current default) |
-|---|---|---|
-| `benchmark-pr.yml` | **`/bench`** on a PR — also push to main and `workflow_dispatch` | 3 runs, 158.8 s each (~8 min of proving) |
-| `bench-abba.yml` | **`/bench-abba [N]`** on a PR | ~72 min at the default 12 pairs (see below) |
-| `benchmark-gpu.yml` | **`/bench-gpu [N]`** on a PR | 59.87 s/prove on an RTX 5090 (see below) |
-| `scripts/bench_verify.sh` | `scripts/bench_verify.sh [` | ~2.6 min per side, then cached |
-| `scripts/perf_diff.sh` | `scripts/perf_diff.sh ][` | 5 recordings, so ~13 min of proving |
-| `scripts/bench_abba.sh` | `scripts/bench_abba.sh ][ [base] [pairs]` | 2 x 158.8 s per pair |
-
-This block is what all three scripts prove by default (`WORKLOAD=real`); pass
-`WORKLOAD=synthetic` for the N-plain-transfer fixture instead. `/bench-verify` is the
-one flow that pins `synthetic`, because it reports a monolithic arm as well as a
-continuation one and a real block does not fit monolithically.
-
-None of them hardcode the fixture path or a block number — they read the path from
-`make -s print-real-block-fixture` and run `make ethrex-real-block-fixture` on every
-invocation, so the digest is re-checked rather than trusted.
-
-**Every bench flow proves this block.** `/bench` runs it sampled on the shared
-runner, against the cached baseline main publishes; `/bench-abba [N]` runs it as
-N A/B/B/A pairs on that same runner; `/bench-gpu [N]` runs the same pairs on a
-rented box, comparing PR vs main on the same machine — absolute GPU times are
-host-CPU-dependent, so only same-box deltas are meaningful.
-
-**Escalating from `/bench` to `/bench-abba`.** `/bench` resolves about 3%: three
-runs of a 158.8 s prove, so it reports 3–10% as unresolved rather than as a
-verdict. The paired test resolves a 95% delta of `t* × sd / sqrt(N)`, where `sd`
-is the pair-delta standard deviation on the runner. **That sd is not measured
-yet.** The columns below bracket it between 1.0% — the GPU box's measured 0.64%
-pair sd plus margin — and 2.0%, which is `sqrt(2) ×` this runner's measured 1.43%
-single-run CV:
-
-| pairs | wall | resolves (sd 2.0%) | resolves (sd 1.0%) |
-|---|---|---|---|
-| 8 | ~50 min | 1.7% | 0.8% |
-| **12** | **~72 min** | **1.3%** | **0.6%** |
-| 20 | ~1h55m | 0.9% | 0.5% |
-| 32 | ~3h | 0.7% | 0.4% |
-
-12 pairs is the default. Wall is two 158.8 s proves per pair plus ~8 min of
-setup. The first real `/bench-abba` run **measures** the sd — it is the `sd`
-field of the paired-t line in the result comment — and this table should be
-re-pinned to that value once it exists.
-
-**GPU baseline (measured), and why the GPU epoch is 2^22.** On an RTX 5090 (32,607 MiB)
-against main @ `9ccdaf2`, same fixture and CLI, one prove per setting:
-
-| epoch | wall | VRAM | epochs | proof |
-|---|---|---|---|---|
-| 2^21 | 70.52 s | 19,193 MiB (58.9%) | 25 | 1.65 GB |
-| **2^22** | **59.87 s** | 23,193 MiB (71.1%) | 13 | 1.12 GB |
-| 2^23 | OOM after 9.7 s | 32,079 MiB (98.4%) — needs ~44 GiB | — | — |
-
-**VRAM is the binding constraint**, so 2^22 is simply the largest setting that fits a
-32 GiB card — and it is ~15% faster than 2^21 (equivalently, 2^21 is ~18% slower) with
-28.9% headroom to spare. 2^23 is out of reach for every card below 48 GiB, not just
-this one. `benchmark-gpu.yml` defaults the real-block path to 2^22 for this reason;
-raw traces are in `~/workspace/lambda_vm_bench_cache/gpu_epoch_calib_2026-07-31/`
-(`PROVENANCE.txt`).
-
-2^22 is also what the CPU runner uses, but the two arrive there for different reasons
-and must not be derived from each other: VRAM binds on the GPU path and host RAM on the
-CPU one. The workflows pin it on both sides (`GPU_REAL_EPOCH_LOG2` here,
-`REAL_BLOCK_EPOCH_LOG2` in `benchmark-pr.yml`, `ABBA_REAL_EPOCH_LOG2` in
-`bench-abba.yml`); `bench_abba.sh` and `bench_verify.sh` still *default* to 2^20, as
-does the CLI's `DEFAULT_CONTINUATION_EPOCH_SIZE_LOG2`, because 2^22 needs ~32 GiB of
-host memory on a CPU build (peak RSS on the calibration box) and would break laptops.
-See [Choosing the epoch size](#choosing-the-epoch-size) for the CPU tiers.
-
-The CPU bench runner is roughly **2.65x** the GPU wall time for the same block: 158.8 s
-median against the calibration RTX 5090's 59.87 s, both at epoch 2^22.
-
-Do not derive one from the other in general: the CPU rate (3.13 s/Mcycle on this
-block) does not transfer to the GPU, and the RTX 5090 sweep found the prover
-CPU-bound at the serial producer above epoch 2^21, so GPU time lands closer to CPU
-time than a naive device-throughput estimate suggests.
-
-**Continuations are mandatory, not a tuning choice.** Peak heap on a monolithic
-prove grows ~4.9 GB per million cycles on this workload family (measured on the
-bench server: `10,728 MB + 2,007 MB/transfer`, R² = 0.998 across 4→20 transfers),
-so this block would need **~240 GB** monolithically — and a heavier candidate far
-more. `--continuations` makes peak heap a function of the epoch size instead of the
-trace length, so the same block fits in **~32 GiB** at epoch 2^22 on the calibration
-box — the setting both the CPU bench runner and the GPU path now use, picked by host
-RAM on one and by VRAM on the other. Host peaks are machine-specific: the bench runner
-itself measured **~52 GB** of peak heap for the same block and epoch. See
-[Choosing the epoch size](#choosing-the-epoch-size) for the full curve and the other
-tiers. The bundle on disk is ~1.15 GB (1.12 GB on the GPU path); a block would have to
-be ~1.9x heavier to push it past the 2 GiB (2.147 GB) rkyv offset limit, which needs
-`pointer_width_64` to serialize.
-
-**`/bench` proves this block and nothing else** — 3 sampled runs against the cached
-3-run baseline main publishes on every push. `/bench N` changes the sample count
-(clamped to 5).
-
-The synthetic N-transfer screen that used to run alongside it was **removed**. Two
-reasons, recorded here because "add a cheap screen back" is an easy suggestion to
-make twice:
-
-1. Its only unique coverage was the **monolithic** prove path, which is vestigial —
- reportedly slower than a single-epoch continuation. Spending runner time to cover
- a path we intend to delete is not a trade worth making.
-2. Its crypto mix is one **no real block has**: 9.16 ECSM per Mcycle, against a real
- block's 2.28. Screening against it tunes the prover for a worst case that
- cannot occur.
-
-The synthetic fixtures themselves are not gone — `/bench-growth` still sweeps them for
-a heap-vs-block-size slope, which needs a family of blocks and so cannot come from one
-real one, and `/bench-verify` still proves the 20-transfer block so it can report a
-monolithic arm as well as a continuation one.
-
-**The cost is a shared resource.** One runner carries every `/bench`, `/bench-abba`
-and `/bench-verify` in the repo, and a `/bench` now occupies it for **~15 min**, on
-every comment and every push to main — ~8 min of that is the three proves, the rest is
-checkout, a two-sided build, the fixture fetch and the guest ELF. `BENCH_RUNS_REAL` in
-`benchmark-pr.yml` is the dial. A run measured **158.8 s** on that runner (median of 3,
-2.8% spread, 13 epochs, epoch 2^22), so 3 runs is the right count; the dial is there
-for a future block or prover change that takes a run past ~6 min.
-
-Cycle counts here (8.73M synthetic, 50.78M real) are from merge `fdb92f67` (main @
-`9ccdaf2`, clang 21). They move with guest optimisation and ~2% with the clang major,
-so pin the ELF whenever you quote one, or it will look like a regression the next time
-someone measures.
-
-## Where validation runs
+The output starts with the two-byte big-endian schema ID `0x1501`. The current
+pinned stateless schema validates one Amsterdam block at a time. A replay
+cache must therefore contain one block, its `slot_number`, its
+`block_access_list_hash`, and the raw BAL when the BAL is non-empty. Caches
+created before Amsterdam are rejected rather than silently rewriting their
+block hash or chain rules.
-The checks themselves are described under [Validation](#validation) below; this is
-where each one executes.
+The converter supports empty execution requests, which is the format currently
+written by `ethrex-replay` for the public L1 cache. Caches with non-empty
+requests are rejected because replay does not persist those request bodies.
-`.github/workflows/ethrex-block-converter.yml` runs them on changes to this crate,
-`tooling/ethrex-tests`, or the `Makefile` — **not** on every PR. The fixture is a
-benchmark input, read by no product code; it has to be right when it changes, not on
-every commit, and running it per-PR put a network fetch and a cold build of ~335
-packages in the required gate.
+## Revision pin
-`no_kzg_backend_linked` is the exception and stays in the required gate
-(`pr_main.yaml`): it is a pure unit test costing microseconds, and it is the property
-the usability screen depends on, so it should fail on the PR that breaks it rather
-than on some later, unrelated one.
+The converter, fixture generator, guest, and host tests all pin:
-## Prerequisites (for regeneration only)
-Rust (stable) and network access on first run (cargo fetches the pinned ethrex
-crates; `make` downloads the cache). **No RV64 target or sysroot needed** — this
-is a host tool.
-
-## Getting a cache for a different block (regeneration only)
-
-The cache format is `ethrex-replay`'s, so use that tool to produce one — it
-handles the RPC fetching, multiple client backends, and the `eth_getProof`
-fallback, none of which is worth reimplementing here:
-
-```bash
-# In a checkout of https://github.com/lambdaclass/ethrex-replay
-ethrex-replay cache --rpc-url
-```
-
-`debug_executionWitness` requires a **reth or ethrex** node; public providers
-(Alchemy, Infura) do not serve it. `ethrex-replay` also supports `eth_getProof`
-for geth/nethermind.
-
-Only **mainnet, Hoodi and Sepolia** caches are accepted. ethrex-replay writes
-`network: "LocalDevnet"` for any other chain, and that resolves to a test chain
-(chain_id 9, every fork active from timestamp 0) — so converting it would replay
-the block under invented rules while still passing every check here, since the
-witness is only ever validated against whichever config we chose. The converter
-refuses instead; `unmappable_network_is_rejected` pins that.
-
-## Adopting a different block
-
-The benchmark block and this crate's test block are **independent** — the fetch is
-what decouples them — so a repoint touches two files and neither is this crate's
-source.
-
-**1. The Makefile — the only place a block number appears.** Five lines:
-
-```make
-ETHREX_REAL_BLOCK_NETWORK :=
-ETHREX_REAL_BLOCK :=
-ETHREX_REAL_BLOCK_FIXTURE_URL :=
-ETHREX_REAL_BLOCK_FIXTURE_SHA256 :=
-ETHREX_REAL_BLOCK_CACHE_URL / _SHA256 :=
+```text
+https://github.com/lambdaclass/ethrex.git
+8effcb0671c5d0b12fe0161ea37c174ec4466b6a
```
-**2. `REAL_BLOCK_FIXTURE` in `tooling/ethrex-tests`**, which points the usability
-screen at the block actually being benchmarked. That is the whole of it.
-
-**Nothing in this crate moves.** Its test constants stay pinned to Hoodi 1265656
-across every repoint — what they exercise is the conversion, not the workload, and
-Hoodi's is the one cache ethrex-replay publishes, so pinning there costs no hosting
-and cannot drift.
-
-Everything else derives from the Makefile — the fixture name, and through
-`make -s print-real-block-fixture` the benchmark scripts and `benchmark-pr.yml`. No
-workflow, script or env var names a block. Nothing in `executor/.gitignore` needs
-touching either: it already ignores every accepted network's fixture name, so a
-repointed ~1 MB fixture cannot become committable by accident.
-
-### Measured cost of candidate blocks
-
-Cost is a property of the block, so it changes with the repoint. All figures are
-measured, never derived from gas — **cycles per gas is not constant** (12.6 for the
-current default at `797df554`; it was 20.9 at `4f658c2b`), so sizing a candidate from its
-gas mispredicts cost.
-
-**Current default — main-vintage (merge `fdb92f67`, main @ `9ccdaf2`):**
-
-These figures were measured on the pre-bump fixture (`61eba49b…`, 1,110,156 B) and are
-left as measured rather than restamped. The ethrex `4f658c2b` bump changed the fixture
-bytes, so they are a baseline for a workload that no longer exists byte-for-byte.
-
-Counterparts at the intermediate `4f658c2b` rev, measured ABBA on `vm-benchmarks-1` at
-the same epoch 2^22: **45,074,552 cycles** (−11.24%), **142.37 s** CPU prove (−10.87%),
-**936.7 MB** proof (−12.22%), peak RSS flat at ~48 GB.
-
-At the currently pinned `797df554` only the cycle count has been re-measured —
-**30,498,818 cycles**, 10,659 keccak, 116 ecsm. Prove time, proof size and the GPU column
-have no counterpart at this rev yet; re-run `/bench` rather than reading the rows below
-as current.
-
-| block | gas | cycles | GPU prove (RTX 5090) | CPU prove | proof | fixture |
-|---|---|---|---|---|---|---|
-| **mainnet 25368371** | 2.43M | **50,781,557** (clang 21)]
50,713,534 (clang 18) | **59.87 s** @ epoch 2^22 | **158.8 s** @ epoch 2^22 (2.65x the GPU wall) | 1.15 GB CPU / 1.12 GB GPU | 1,110,156 B, `61eba49b…` |
-
-Epoch 2^22 is the GPU recommendation: VRAM binds, 2^22 leaves 28.9% headroom on a
-32 GiB card and 2^23 does not fit one at all. See
-[Choosing the epoch size](#choosing-the-epoch-size) for the CPU tiers.
-
-**Alternates — PRE-LTO vintage, superseded, re-measure before quoting.** These were
-taken on a mid-July guest ELF, before #861 gave the guest thin LTO; the same build
-change took the current default from 74,819,518 to ~50.7M, so expect these to fall by
-a comparable factor. Kept because they are the selection evidence, not because the
-numbers are current:
-
-| block | gas | cycles (pre-LTO) | fixture |
-|---|---|---|---|
-| mainnet 25453112 | 4.24M | 125,932,956 | 2,019,747 B, `0298663d…` |
-| hoodi 1265656 | 4.40M | 168,319,360 | 1,021,207 B, `1f7d4c4c…` |
-
-All three clear the usability screen. Add a row rather than editing the wiring, and
-say which ELF a number came from.
-
-Two things these numbers show that a gas-based estimate would have got wrong, and both
-survive the vintage change because they are same-ELF comparisons. **Gas does not size
-cost:** on one common pre-LTO ELF the three blocks run at 30.8, 29.7
-and 38.2 cycles per gas, so budgeting a candidate from gas alone is off by up to ~29%
-— 25453112 and hoodi 1265656 sit within 4% of each other on gas (4.24M vs 4.40M) yet
-25453112 costs ~25% fewer cycles (125.9M vs 168.3M). Gas happens to *order* these
-three correctly; it does not size them. And **fixture size does not track cost**
-either: the current default is the cheapest block and the middle-sized fixture.
-
-The default is the cheapest of the three, which matters because the CPU workload sits
-on a single shared bench runner. It was also the only block in a 90-day Dune sweep
-matching the shape constraints (2 heavy transactions, no whale, sane transfer share)
-in the 1.6–2.6M gas band — so it is cheap *and* structurally typical, not cheap
-because it is degenerate.
-
-### Choosing the epoch size
+Keep these pins together. The SSZ wire format and the guest implementation are
+coupled to the ethrex revision.
-`--epoch-size-log2` trades memory for speed, and **the right value is a property of the
-machine, not of the block**. Three tiers, all measured:
+## Real-block fixture
-| where | epoch | why |
-|---|---|---|
-| GPU, 32 GiB card | **2^22** | VRAM-bound — 2^23 does not fit |
-| CPU bench runner (≥64 GiB) | **2^22** | host-RAM-bound — it already peaks at ~52 GB here, and 2^23 measured 60 GiB on a roomier box |
-| CPU server, 128 GiB class | **2^23** | the knee; 2^24 fits but is not worth it |
-| laptops (CLI default) | **2^20** | unchanged, so a plain `cli prove` still works |
-
-CPU sweep, 2026-07-31, on a 124 GiB / 32-core box, real block, **branch vintage**
-(53,757,588 cycles on that box's clang-21 pre-LTO ELF):
-
-| epoch | epochs | wall | peak RSS | proof |
-|---|---|---|---|---|
-| 2^20 | 52 | 616.90 s | 14.56 GiB | 2.83 GB |
-| 2^21 | 26 | 464.26 s | 18.43 GiB | 1.72 GB |
-| 2^22 | 13 | 397.88 s | 32.21 GiB | 1.15 GB |
-| **2^23** | 7 | **356.47 s** | **60.01 GiB** | 0.90 GB |
-| 2^24 | 4 | ~334 s | ~97–105 GiB *(provisional)* | — |
-
-**There is a real knee.** Speed gained per doubling shrinks — 24.7%, 14.3%, 10.4%,
-~6% — while memory roughly doubles at each step near the top. 2^23 uses 48% of a
-124 GiB box (~52% headroom); 2^24 buys only ~6% more speed for ~15% headroom, so it
-**fits but is not recommended on a shared box**, where one co-tenant turns a tight fit
-into an OOM. The 2^24 RSS figure is provisional pending the calibration agent's formal
-report.
-
-A main-vintage anchor also ran on the same box: 2^23 = 344.12 s / 58.87 GiB, i.e. ~3%
-faster than branch vintage at the same memory — as expected, since #861 cut cycles and
-peak RSS is set by the epoch size rather than the trace length.
-
-**Do not read absolute seconds or peak memory off this table for another machine.** This
-box took 397.88 s at 2^22 on its branch-vintage ELF; the bench runner takes 158.8 s at
-the same epoch on the main-vintage one — a 2.5x gap, against the ~6% the two cycle
-counts differ by. The *ratios* between epochs transfer; the wall times do not. Memory
-transfers no better: the bench runner peaks at ~52 GB of heap at 2^22 where this box
-measured 32.21 GiB RSS.
-
-### Verifying a repoint
-
-Run **both**, in this order:
+The benchmark fixture is generated rather than fetched, and not by this crate:
+`tooling/ethrex-fixtures --bin real_block` rebuilds a real mainnet block as an
+Amsterdam block. What is hosted is that block's replay cache, which is
+fork-independent. `make ethrex-real-block-fixture` produces the fixture when it is
+missing; after an ethrex revision bump, rebuild it and re-baseline its digest:
```bash
-make ethrex-real-block-fixture # fetch + verify the new .bin
-make test-ethrex # the block is USABLE on the guest
+make regen-real-block-fixture
+sha256sum "$(make -s print-real-block-fixture)" # -> ETHREX_REAL_BLOCK_FIXTURE_SHA256
```
-`make test-ethrex` is the one that matters here, and the converter's tests cannot
-replace it — they run against a different block. A new block is only
-usable if it needs no accelerator the guest lacks, and this crate cannot tell you
-that — its graph links a working c-kzg, so a block calling point evaluation (0x0a)
-passes here and fails in the guest. `test_ethrex_real_block_native` in
-`tooling/ethrex-tests` is the screen. See [Validation](#validation).
-
-Benchmark comparability does not survive the swap, and that is intentional rather
-than a wrinkle to work around: `benchmark-pr.yml` records which block it measured
-and refuses to diff a PR against a baseline that measured a different one, so the
-first run after a repoint reports one-sided numbers until main republishes.
-
-## Why the JSON and not ethrex-replay's own `.bin`
-
-`ethrex-replay` can already emit a rkyv `ProgramInput`, but the type has diverged from
-what our guest expects before: back when we pinned a branch off `main` (`156cb8d6…`)
-`main` carried an extra `fee_configs` field and had moved the type from `l1::` to
-`input::`, so replay's binary would not deserialize in our guest at all.
-
-We now pin `main` directly (`797df554…`), so that particular gap is closed by
-construction. What has not closed: replay resolves rkyv itself from ethrex's `^0.8.10`
-rather than our exact `=0.8.16`, and the next bump can reopen the type gap without
-warning.
-
-The cache JSON carries only `blocks` + `witness` + `network` as plain serde, so
-it survives that drift. This tool re-reads it with **our** pinned ethrex types
-and re-serializes with **our** rkyv, which is what keeps the output layout
-correct by construction. When the guest's ethrex `rev` is bumped, bump it here
-too (and in `tooling/ethrex-fixtures` and the guest) and regenerate.
-
-A previous real-block fixture (`ethrex_hoodi.bin`) was lost exactly to this kind
-of drift — it predated the `Crypto` trait and stopped deserializing. Reading the
-version-tolerant JSON instead of a pinned binary is the mitigation.
-
-## Validation
+There is no artifact left to publish: the generator is deterministic, so re-running
+it is how the fixture is replaced. The old release assets are rkyv `ProgramInput`
+artifacts and are incompatible with the pinned ethrex.
-Six checks, ordered so the argument builds: the host-side parity test first, then
-what it does *not* cover, then the checks in `tooling/ethrex-tests` that close the
-gap. Five run on the host and need no RV64 toolchain, and each executes in
-milliseconds.
-
-What costs time is a cold build of the ethrex host dependency tree — ~335
-packages, including the `blst`, `c-kzg` and `secp256k1-sys` C builds, `malachite`
-and `ark-ff/asm` — not the tests. That build is why these checks live in their own
-path-filtered workflow rather than the PR gate (see [Where validation
-runs](#where-validation-runs)); `ethrex-block-converter.yml` caches this workspace's
-`target/` under its own key, so only cold runs pay it.
-
-These run against this crate's own pinned block (Hoodi 1265656), **not** the
-benchmark block — they test the conversion, which any real block exercises equally,
-and Hoodi's is the one cache ethrex-replay publishes. Only
-`test_ethrex_real_block_native` follows the benchmark block.
-
-**`cargo test` here — `real_block_executes_under_guest_crypto`.** Executes the
-block through `LambdaVmEcsmCrypto`, the `Crypto` impl the guest injects, so it is
-exercised via the guest's own trait dispatch. Stateless re-execution ends in a
-post-state-root check, so any divergence from consensus fails here.
-
-**It does not screen KZG.** Declaring `ethrex-guest-program` with
-`default-features = false` (and no features, as the guest does) is necessary but not
-sufficient: the `ethrex-config` dependency (used only for
-`Network::get_genesis()`) pulls `ethrex-p2p`, whose `default = ["c-kzg"]`
-propagates down to `ethrex-crypto/c-kzg` — and `default-features = false` cannot
-switch it off, because ethrex's own workspace declares `ethrex-p2p` with defaults
-on. Verify with `cargo tree -e features -i ethrex-crypto`. So point evaluation
-(0x0a) resolves to a working c-kzg here and to nothing in the guest.
-
-Scope of the gap: in `ethrex-crypto`, KZG is the **only** precompile whose
-*availability* is feature-gated. The other two gates swap between working
-implementations — `secp256k1` picks libsecp256k1 over k256, `std` picks malachite
-over num-bigint for modexp — so they change which code runs, not whether a block
-can execute. Dropping `ethrex-config` would therefore be a CI-time improvement
-(it also sheds `ethrex-p2p`, `ethrex-blockchain`, `ethrex-storage` and the c-kzg
-and secp256k1 C builds), not a correctness fix.
-
-**`cargo test` here — `unmappable_network_is_rejected`.** Refuses a cache whose
-`network` cannot be mapped to real chain rules rather than converting it under
-substituted ones — see [above](#getting-a-cache-for-a-different-block) for why
-that matters.
-
-**`cargo test` here — `conversion_is_reproducible`.** Pins the block's stats and
-the fixture's **sha256**. A length assert would not do: `ChainConfig` is
-fixed-size, so a substituted chain config yields a byte-length-identical fixture,
-and rkyv's `big_endian` feature would byte-swap in place — neither changes the
-byte count. This is what catches an ethrex rev bump that moves the rkyv layout
-instead of silently producing a fixture the guest can't read.
-
-**`tooling/ethrex-tests` — `no_kzg_backend_linked`.** Asserts that crate links no
-KZG backend. That was incidental to its dependency graph, and it is the property
-the next check relies on, so it is pinned here rather than assumed.
-
-**`tooling/ethrex-tests` — `test_ethrex_real_block_native`.** The one check that
-follows the BENCHMARK block. Checks the serialized `.bin` itself deserializes and
-executes. Since that crate links no KZG
-backend, this is also **what screens point evaluation (0x0a)**: a block reaching
-it diverges from consensus and fails here.
-
-**`tooling/ethrex-tests` — `test_ethrex_real_block_vm`** (`#[ignore]`, excluded
-from PR CI). The block through the guest ELF, comparing the VM's committed
-output against the native reference. Needs the RV64 toolchain and its runtime is
-unmeasured; run it on a build server, not a laptop:
-
-```bash
-cd tooling/ethrex-tests && cargo test --release test_ethrex_real_block_vm -- --ignored
-```
+## What the tests cover
-[replay]: https://github.com/lambdaclass/ethrex-replay
+Both tests assert a rejection. The pinned Hoodi cache predates Amsterdam and is
+refused rather than rewritten, and an unmappable network is refused too. The
+success path — cache in, valid SSZ out — has no automated coverage: it needs a
+cache from a network that runs Amsterdam, and none is published yet. Until one is,
+the encoder is exercised by hand through the `cargo run` above.
diff --git a/tooling/ethrex-block-converter/src/main.rs b/tooling/ethrex-block-converter/src/main.rs
index 98df955d6..7f5bdfd0f 100644
--- a/tooling/ethrex-block-converter/src/main.rs
+++ b/tooling/ethrex-block-converter/src/main.rs
@@ -1,37 +1,45 @@
-//! Convert a real Ethereum block into the rkyv-serialized `ProgramInput` the
-//! lambda-vm ethrex guest consumes, from an `ethrex-replay` cache JSON.
+//! Convert an `ethrex-replay` cache into the SSZ stateless input consumed by
+//! the lambda-vm ethrex guest.
//!
//! Usage:
//! cargo run --release --
+use ethrex_common::constants::{DEFAULT_REQUESTS_HASH, EMPTY_BLOCK_ACCESS_LIST_HASH};
use ethrex_common::types::Block;
-use ethrex_common::types::block_execution_witness::{RpcExecutionWitness, decode_witness_headers};
+use ethrex_common::types::block_access_list::BlockAccessList;
+use ethrex_common::types::block_execution_witness::RpcExecutionWitness;
+use ethrex_common::types::stateless_ssz::{
+ Bytes20, ExecutionPayload, ExecutionRequests, LogsBloom, NewPayloadRequest,
+ STATELESS_INPUT_SCHEMA_ID, SszExecutionWitness, SszPublicKeys, SszStatelessInput,
+};
use ethrex_config::networks::Network;
use ethrex_crypto::NativeCrypto;
-use ethrex_guest_program::l1::ProgramInput;
+use ethrex_guest_program::l1::run_stateless_guest;
+use libssz::SszEncode;
+use libssz_types::{ProgressiveList, SszList, SszVector};
use serde::Deserialize;
-/// The subset of `ethrex-replay`'s on-disk cache that a `ProgramInput` needs.
+/// The subset of an `ethrex-replay` cache needed by the stateless SSZ input.
///
-/// Deliberately deserialized with *our* pinned ethrex types rather than by
-/// depending on `ethrex-replay`: it tracks ethrex `main`, we pin a branch off it,
-/// and `ProgramInput` has diverged between the two before (it once carried an
-/// extra `fee_configs` field and lived at a different module path). At the
-/// currently pinned rev the type happens to match `main`'s again, but replay
-/// still resolves rkyv itself from ethrex's `^0.8.10` against our exact `=0.8.16`,
-/// and nothing stops the type drifting apart on the next bump. This JSON carries
-/// only `blocks` + `witness` + `network` as plain serde, so it is the
-/// version-tolerant interface between the two regardless.
+/// Current replay caches carry the witness as raw RLP preimages. A future cache
+/// may carry the Amsterdam block access list beside the block; accepting it here
+/// keeps the converter independent of replay's Rust type layout.
///
-/// Extra fields in the file (L2 blob data, custom `chain_config`) are ignored.
+/// Deliberately deserialized with *our* pinned ethrex types rather than by
+/// depending on `ethrex-replay`: it tracks ethrex `main` while we pin a commit of
+/// it, and the input type has diverged between the two before. This JSON carries
+/// only `blocks` + `witness` + `network` as plain serde, so it stays the
+/// version-tolerant interface between them. Extra fields in the file (L2 blob
+/// data, custom `chain_config`) are ignored.
#[derive(Deserialize)]
struct Cache {
blocks: Vec,
witness: RpcExecutionWitness,
network: Network,
+ #[serde(default, alias = "blockAccessList")]
+ block_access_list: Option,
}
-/// Summary of the converted block, for the CLI's one-line report.
struct BlockSummary {
network: String,
first_block_number: u64,
@@ -40,56 +48,208 @@ struct BlockSummary {
gas_used: u64,
}
-fn program_input_from_cache(
+fn empty_execution_requests() -> ExecutionRequests {
+ ExecutionRequests {
+ deposits: ProgressiveList::new(),
+ withdrawals: ProgressiveList::new(),
+ consolidations: ProgressiveList::new(),
+ builder_deposits: ProgressiveList::new(),
+ builder_exits: ProgressiveList::new(),
+ }
+}
+
+fn ssz_witness(
+ witness: &RpcExecutionWitness,
+) -> Result> {
+ let state = witness
+ .state
+ .iter()
+ .enumerate()
+ .map(|(i, bytes)| {
+ SszList::try_from(bytes.to_vec())
+ .map_err(|e| format!("witness state[{i}] is too large: {e:?}"))
+ })
+ .collect::, _>>()?;
+ let codes = witness
+ .codes
+ .iter()
+ .enumerate()
+ .map(|(i, bytes)| {
+ SszList::try_from(bytes.to_vec())
+ .map_err(|e| format!("witness codes[{i}] is too large: {e:?}"))
+ })
+ .collect::, _>>()?;
+ let headers = witness
+ .headers
+ .iter()
+ .enumerate()
+ .map(|(i, bytes)| {
+ SszList::try_from(bytes.to_vec())
+ .map_err(|e| format!("witness headers[{i}] is too large: {e:?}"))
+ })
+ .collect::, _>>()?;
+
+ Ok(SszExecutionWitness {
+ state: ProgressiveList::from(state),
+ codes: ProgressiveList::from(codes),
+ headers: SszList::try_from(headers)
+ .map_err(|e| format!("witness has too many headers: {e:?}"))?,
+ })
+}
+
+fn stateless_input_from_cache(
cache_path: &str,
-) -> Result<(ProgramInput, BlockSummary), Box> {
+) -> Result<(Vec, BlockSummary), Box> {
let cache: Cache =
serde_json::from_reader(std::io::BufReader::new(std::fs::File::open(cache_path)?))?;
- let Some(first_block) = cache.blocks.first() else {
+ let Some(block) = cache.blocks.first() else {
return Err("cache contains no blocks".into());
};
- let summary = BlockSummary {
- network: cache.network.to_string(),
- first_block_number: first_block.header.number,
- blocks: cache.blocks.len(),
- transactions: cache.blocks.iter().map(|b| b.body.transactions.len()).sum(),
- gas_used: cache.blocks.iter().map(|b| b.header.gas_used).sum(),
- };
-
- // The chain rules come from `network`, and ethrex-replay maps every chain it
- // doesn't recognise (anything but mainnet / Hoodi / Sepolia) onto
- // `LocalDevnet` — which resolves to a test chain: chain_id 9, every fork
- // active from timestamp 0. Converting under that would execute the block
- // against invented rules while still satisfying every check downstream (the
- // witness is replayed against whatever config we picked, and host and guest
- // read the same one), so refuse rather than guess.
+ if cache.blocks.len() != 1 {
+ return Err(format!(
+ "cache contains {} blocks; the pinned stateless guest accepts one block",
+ cache.blocks.len()
+ )
+ .into());
+ }
if !matches!(cache.network, Network::PublicNetwork(_)) {
return Err(format!(
- "unsupported network `{}`: its chain rules would be guessed, not read \
- (ethrex-replay writes LocalDevnet for any chain it does not recognise)",
+ "unsupported network `{}`: its chain rules would be guessed, not read",
cache.network
)
.into());
}
- // `into_execution_witness` rebuilds the trie structures from the flat node
- // list and needs the parent header, which the cache carries inside `witness`.
- // Those headers are now decoded by the caller rather than inside the call, and the
- // parent lookup matches on `h.number` without touching `crypto` at all. The `Crypto`
- // argument is used only for keccak over the trie-node preimages, so `NativeCrypto`
- // produces the same bytes as the `LambdaVmEcsmCrypto` the guest injects (whose host
- // path is also software keccak) — the fixture does not depend on which one converts it.
- let chain_config = cache.network.get_genesis()?.config;
- let decoded_headers = decode_witness_headers(&cache.witness.headers)?;
- let witness = cache.witness.into_execution_witness(
- chain_config,
- summary.first_block_number,
- &decoded_headers,
- &NativeCrypto,
- )?;
+ let summary = BlockSummary {
+ network: cache.network.to_string(),
+ first_block_number: block.header.number,
+ blocks: 1,
+ transactions: block.body.transactions.len(),
+ gas_used: block.header.gas_used,
+ };
+ let header = &block.header;
+
+ // The new wire format is the Amsterdam payload. A pre-Amsterdam replay
+ // cache has no BAL or slot and cannot be upgraded without replaying the
+ // block, so reject it instead of changing its block hash or chain rules.
+ let block_access_list_hash = header
+ .block_access_list_hash
+ .ok_or("cache block has no Amsterdam block_access_list_hash")?;
+ let slot_number = header
+ .slot_number
+ .ok_or("cache block has no Amsterdam slot_number")?;
+ let block_access_list = match cache.block_access_list.as_ref() {
+ Some(bal) => bal.clone(),
+ None if block_access_list_hash == *EMPTY_BLOCK_ACCESS_LIST_HASH => BlockAccessList::new(),
+ None => {
+ return Err("cache block has a non-empty BAL hash but no raw block access list".into());
+ }
+ };
+ if block_access_list.compute_hash(&NativeCrypto) != block_access_list_hash {
+ return Err("cache block access list does not match block_access_list_hash".into());
+ }
- Ok((ProgramInput::new(cache.blocks, witness), summary))
+ let requests_hash = header
+ .requests_hash
+ .ok_or("cache block has no requests_hash")?;
+ if requests_hash != *DEFAULT_REQUESTS_HASH {
+ return Err(
+ "cache contains execution requests, but the replay cache has no request data".into(),
+ );
+ }
+ let base_fee = header
+ .base_fee_per_gas
+ .ok_or("cache block has no base_fee_per_gas")?;
+ let transactions = block
+ .body
+ .transactions
+ .iter()
+ .map(|tx| tx.encode_canonical_to_vec().into())
+ .collect::>>()
+ .into();
+ let public_keys = block
+ .body
+ .transactions
+ .iter()
+ .enumerate()
+ .map(|(i, tx)| {
+ let key = tx
+ .public_key(&NativeCrypto)
+ .map_err(|e| format!("failed to recover public key for transaction {i}: {e}"))?
+ .ok_or_else(|| format!("transaction {i} has no recoverable signature"))?;
+ SszVector::try_from(key.to_vec())
+ .map_err(|e| format!("public key for transaction {i} is invalid: {e:?}"))
+ })
+ .collect::, String>>()?;
+
+ let withdrawals = block
+ .body
+ .withdrawals
+ .clone()
+ .unwrap_or_default()
+ .into_iter()
+ .map(
+ |withdrawal| ethrex_common::types::stateless_ssz::Withdrawal {
+ index: withdrawal.index,
+ validator_index: withdrawal.validator_index,
+ address: Bytes20(withdrawal.address.0),
+ amount: withdrawal.amount,
+ },
+ )
+ .collect::>()
+ .into();
+ let logs_bloom: LogsBloom = SszVector::try_from(header.logs_bloom.0.to_vec())
+ .map_err(|e| format!("logs bloom is not 256 bytes: {e:?}"))?;
+ let extra_data = SszList::try_from(header.extra_data.to_vec())
+ .map_err(|e| format!("extra data exceeds 32 bytes: {e:?}"))?;
+ let mut base_fee_bytes = [0u8; 32];
+ base_fee_bytes[..8].copy_from_slice(&base_fee.to_le_bytes());
+
+ let execution_payload = ExecutionPayload {
+ parent_hash: header.parent_hash.0,
+ fee_recipient: Bytes20(header.coinbase.0),
+ state_root: header.state_root.0,
+ receipts_root: header.receipts_root.0,
+ logs_bloom,
+ prev_randao: header.prev_randao.0,
+ block_number: header.number,
+ gas_limit: header.gas_limit,
+ gas_used: header.gas_used,
+ timestamp: header.timestamp,
+ extra_data,
+ base_fee_per_gas: base_fee_bytes,
+ block_hash: header.compute_block_hash(&NativeCrypto).0,
+ transactions,
+ withdrawals,
+ blob_gas_used: header.blob_gas_used.unwrap_or_default(),
+ excess_blob_gas: header.excess_blob_gas.unwrap_or_default(),
+ block_access_list: ethrex_rlp::encode::RLPEncode::encode_to_vec(&block_access_list).into(),
+ slot_number,
+ };
+ let new_payload_request = NewPayloadRequest {
+ execution_payload,
+ versioned_hashes: block
+ .body
+ .transactions
+ .iter()
+ .flat_map(|tx| tx.blob_versioned_hashes())
+ .map(|hash| hash.0)
+ .collect::>()
+ .into(),
+ parent_beacon_block_root: header.parent_beacon_block_root.unwrap_or_default().0,
+ execution_requests: empty_execution_requests(),
+ };
+ let input = SszStatelessInput {
+ new_payload_request,
+ witness: ssz_witness(&cache.witness)?,
+ chain_id: cache.network.get_genesis()?.config.chain_id,
+ public_keys: SszPublicKeys::from(public_keys),
+ };
+
+ let mut bytes = STATELESS_INPUT_SCHEMA_ID.to_be_bytes().to_vec();
+ input.ssz_append(&mut bytes);
+ Ok((bytes, summary))
}
fn usage_and_exit(program: &str) -> ! {
@@ -109,13 +269,15 @@ fn main() -> Result<(), Box> {
usage_and_exit(&program);
}
- let (program_input, summary) = program_input_from_cache(&cache_path)?;
- let bytes = rkyv::to_bytes::(&program_input)?;
+ let (bytes, summary) = stateless_input_from_cache(&cache_path)?;
+ let output = run_stateless_guest(&bytes, std::sync::Arc::new(NativeCrypto));
+ if output.len() != 43 || output[32] == 0 {
+ return Err("converted stateless input failed native validation".into());
+ }
std::fs::write(&out_path, &bytes)?;
println!(
- "wrote {out_path} ({} bytes): {} block(s) from {} starting at #{}, \
- {} transaction(s), {} gas",
+ "wrote {out_path} ({} bytes): {} block(s) from {} starting at #{}, {} transaction(s), {} gas",
bytes.len(),
summary.blocks,
summary.network,
@@ -129,109 +291,43 @@ fn main() -> Result<(), Box> {
#[cfg(test)]
mod tests {
use super::*;
- use ethrex_config::networks::HOODI_CHAIN_ID;
const CACHE: &str = "caches/cache_hoodi_1265656.json";
+ const CACHE_MISSING: &str = "caches/cache_hoodi_1265656.json is missing — run `make ethrex-real-block-converter-cache` first";
- /// This crate is pinned to Hoodi 1265656 on purpose, independently of whichever
- /// block the benchmarks currently prove: it is the one cache ethrex-replay hosts
- /// upstream, so keeping the converter's test input there costs us no hosting and
- /// cannot drift. What is under test here is the CONVERSION, not the benchmark
- /// workload — see tooling/ethrex-block-converter/README.md.
- ///
- /// `caches/` is gitignored and fetched on demand, so every test here fails on
- /// a clean checkout until the cache is downloaded. Say so instead of surfacing
- /// a bare `No such file or directory` from `unwrap()`.
- const CACHE_MISSING: &str = "caches/cache_hoodi_1265656.json is missing — run \
- `make ethrex-real-block-converter-cache` from the repo root first";
-
- /// Executes the converted block with `LambdaVmEcsmCrypto`, the `Crypto` impl
- /// the guest injects, so the block is exercised through the same trait
- /// dispatch the guest uses. Stateless re-execution ends in a post-state-root
- /// check, so any divergence from consensus fails here — on the host, with no
- /// RV64 toolchain and no proving run.
- ///
- /// It does NOT screen KZG: this crate's graph links `c-kzg` (via
- /// `ethrex-config` → `ethrex-p2p`, see Cargo.toml), so point evaluation
- /// (0x0a) resolves to a working implementation here while the guest has none.
- /// A block calling 0x0a would pass this test.
- ///
- /// `test_ethrex_real_block_native` in `tooling/ethrex-tests` is what covers
- /// 0x0a, and `no_kzg_backend_linked` there keeps it covered. That split is
- /// sufficient rather than a workaround: KZG is the only precompile in
- /// `ethrex-crypto` whose *availability* is feature-gated — the other gates
- /// swap between two working implementations.
#[test]
- fn real_block_executes_under_guest_crypto() {
- use ethrex_guest_program::l1::execution_program;
- use lambda_vm_ethrex_crypto::LambdaVmEcsmCrypto;
- use std::sync::Arc;
-
- let (program_input, _) = program_input_from_cache(CACHE).expect(CACHE_MISSING);
- execution_program(program_input, Arc::new(LambdaVmEcsmCrypto)).unwrap();
+ fn legacy_cache_is_rejected_without_amsterdam_fields() {
+ let result = stateless_input_from_cache(CACHE);
+ let Err(error) = result else {
+ panic!("the checked-in replay cache unexpectedly has Amsterdam fields");
+ };
+ if !std::path::Path::new(CACHE).exists() {
+ assert!(
+ error.to_string().contains("No such file")
+ || error.to_string().contains("os error"),
+ "{CACHE_MISSING}: {error}"
+ );
+ } else {
+ assert!(
+ error.to_string().contains("Amsterdam"),
+ "unexpected error: {error}"
+ );
+ }
}
- /// A cache whose `network` we can't map to real chain rules must be refused,
- /// not converted under substituted ones. Only the `network` field is changed
- /// here, and the result is byte-length-identical to the real fixture — which
- /// is exactly why the other tests cannot catch this on their own.
#[test]
fn unmappable_network_is_rejected() {
let mut cache: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(CACHE).expect(CACHE_MISSING)).unwrap();
cache["network"] = serde_json::json!("LocalDevnet");
-
- let path = std::env::temp_dir().join(format!(
- "ethrex_real_block_localdevnet_{}.json",
- std::process::id()
- ));
+ let path =
+ std::env::temp_dir().join(format!("ethrex_localdevnet_{}.json", std::process::id()));
std::fs::write(&path, serde_json::to_vec(&cache).unwrap()).unwrap();
- let result = program_input_from_cache(path.to_str().unwrap());
+ let result = stateless_input_from_cache(path.to_str().unwrap());
std::fs::remove_file(&path).ok();
- let Err(err) = result else {
- panic!("LocalDevnet resolves to chain_id 9 with all forks at 0; must not convert");
+ let Err(error) = result else {
+ panic!("LocalDevnet must be rejected");
};
-
- assert!(
- err.to_string().contains("unsupported network"),
- "wrong rejection reason: {err}"
- );
- }
-
- /// The serialized form is what the guest actually reads, so pin it: a
- /// change here means the rkyv layout moved and every consumer of the
- /// fixture (and its README checksum) needs regenerating.
- #[test]
- fn conversion_is_reproducible() {
- use sha2::Digest;
-
- let (program_input, summary) = program_input_from_cache(CACHE).expect(CACHE_MISSING);
- let bytes = rkyv::to_bytes::(&program_input).unwrap();
-
- assert_eq!(summary.first_block_number, 1_265_656);
- assert_eq!(summary.transactions, 11);
- assert_eq!(summary.gas_used, 4_402_947);
-
- // Asserted separately from the digest below, not covered by it. The digest
- // is exactly what a legitimate ethrex rev bump forces someone to rewrite
- // (the layout moves, this goes red, a fresh digest gets pasted in) — and at
- // that moment it stops covering the substituted-chain-config case it was
- // chosen for. This assert survives that churn.
- assert_eq!(
- program_input.execution_witness.chain_config.chain_id, HOODI_CHAIN_ID,
- "chain config is not Hoodi's — the block would replay under other rules",
- );
-
- // Digest, not length: a layout change can preserve the byte count exactly
- // (`ChainConfig` is fixed-size, and rkyv's `big_endian` feature would only
- // byte-swap in place), so `len()` cannot pin the archived form.
- let digest: String = sha2::Sha256::digest(&bytes)
- .iter()
- .map(|b| format!("{b:02x}"))
- .collect();
- assert_eq!(
- digest, "8b3fb1294a5cd7296452d4a0422b0623c2d243e84bc8a51d3f15bc3899354553",
- "fixture bytes changed — regenerate it and update the README checksum",
- );
+ assert!(error.to_string().contains("unsupported network"), "{error}");
}
}
diff --git a/tooling/ethrex-fixtures/Cargo.lock b/tooling/ethrex-fixtures/Cargo.lock
index 03e77ca4a..d755840ac 100644
--- a/tooling/ethrex-fixtures/Cargo.lock
+++ b/tooling/ethrex-fixtures/Cargo.lock
@@ -975,8 +975,8 @@ dependencies = [
[[package]]
name = "ethrex-blockchain"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"crossbeam",
@@ -1000,8 +1000,8 @@ dependencies = [
[[package]]
name = "ethrex-common"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"crc32fast",
@@ -1034,8 +1034,8 @@ dependencies = [
[[package]]
name = "ethrex-crypto"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"ark-bn254",
"ark-ec",
@@ -1063,9 +1063,12 @@ dependencies = [
"ethrex-common",
"ethrex-guest-program",
"ethrex-l2-rpc",
+ "ethrex-rlp",
"ethrex-storage",
+ "ethrex-trie",
"hex",
- "rkyv",
+ "libssz",
+ "libssz-types",
"secp256k1",
"serde_json",
"tokio",
@@ -1073,8 +1076,8 @@ dependencies = [
[[package]]
name = "ethrex-guest-program"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"ethereum-types",
@@ -1084,6 +1087,10 @@ dependencies = [
"ethrex-rlp",
"ethrex-vm",
"hex",
+ "libssz",
+ "libssz-derive",
+ "libssz-merkle",
+ "libssz-types",
"rkyv",
"serde",
"serde_with",
@@ -1092,8 +1099,8 @@ dependencies = [
[[package]]
name = "ethrex-l2-common"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"ethereum-types",
@@ -1102,7 +1109,6 @@ dependencies = [
"k256",
"lambdaworks-crypto",
"rkyv",
- "secp256k1",
"serde",
"serde_with",
"thiserror 2.0.18",
@@ -1111,8 +1117,8 @@ dependencies = [
[[package]]
name = "ethrex-l2-rpc"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"axum",
"bytes",
@@ -1143,8 +1149,8 @@ dependencies = [
[[package]]
name = "ethrex-levm"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"derive_more",
@@ -1162,8 +1168,8 @@ dependencies = [
[[package]]
name = "ethrex-metrics"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"axum",
"ethrex-common",
@@ -1178,8 +1184,8 @@ dependencies = [
[[package]]
name = "ethrex-p2p"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"aes",
"aes-gcm",
@@ -1222,8 +1228,8 @@ dependencies = [
[[package]]
name = "ethrex-rlp"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"ethereum-types",
@@ -1232,8 +1238,8 @@ dependencies = [
[[package]]
name = "ethrex-rpc"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"axum",
"axum-extra",
@@ -1272,8 +1278,8 @@ dependencies = [
[[package]]
name = "ethrex-storage"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"anyhow",
"bytes",
@@ -1294,8 +1300,8 @@ dependencies = [
[[package]]
name = "ethrex-storage-rollup"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"async-trait",
"bincode",
@@ -1310,8 +1316,8 @@ dependencies = [
[[package]]
name = "ethrex-trie"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"anyhow",
"bytes",
@@ -1330,8 +1336,8 @@ dependencies = [
[[package]]
name = "ethrex-vm"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"derive_more",
@@ -2222,18 +2228,18 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libssz"
-version = "0.2.2"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d498c0482bba87d2647ea4601ea76cf2b498065e3958798a88f49274f3ced5e9"
+checksum = "b4bdd6d63ed811ae164966de20810be780e07de784a4834ccfe6be90480c369e"
dependencies = [
"smallvec",
]
[[package]]
name = "libssz-derive"
-version = "0.2.2"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08ddfb5c969c28a4a54043e630f80c723352637bd1020f256ee3ac7a8814922b"
+checksum = "aeee1b9ac9200429f7e9830492765445989ea61c3fb9028ad5a96e1dd5f5e913"
dependencies = [
"proc-macro2",
"quote",
@@ -2242,9 +2248,9 @@ dependencies = [
[[package]]
name = "libssz-merkle"
-version = "0.2.2"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "63c6d6d5ce5d79bba66bc98c99869eedffedf7f14f0aa0915f1a62802650bdf6"
+checksum = "863eca32d1a43e5ec41106a515552efa8307768d37c68d26b7f21ff13cfee1a7"
dependencies = [
"libssz",
"sha2",
@@ -2252,9 +2258,9 @@ dependencies = [
[[package]]
name = "libssz-types"
-version = "0.2.2"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "747273ab2d923e82ed147091fe0fb3e602dd2012c872cdad5efe69e27c3b4099"
+checksum = "d4231ac301726840a3fe111f11bd4619d3c97ed155cb94c88dbf92b70e04e017"
dependencies = [
"libssz",
"libssz-merkle",
@@ -3696,7 +3702,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom 0.3.4",
+ "getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
diff --git a/tooling/ethrex-fixtures/Cargo.toml b/tooling/ethrex-fixtures/Cargo.toml
index fbbde505d..ce79d4c0d 100644
--- a/tooling/ethrex-fixtures/Cargo.toml
+++ b/tooling/ethrex-fixtures/Cargo.toml
@@ -2,24 +2,34 @@
name = "ethrex-fixtures"
version = "0.1.0"
edition = "2024"
+# This crate grew a second binary (`real_block`), which made a bare `cargo run`
+# ambiguous and broke `make regen-ethrex-fixtures` plus the commands the fixture
+# READMEs tell you to run on a rev bump. The synthetic generator is the one that
+# should answer to a bare `cargo run`; the real-block rebuild is always explicit
+# (`--bin real_block`, as the Makefile invokes it).
+default-run = "ethrex-fixtures"
# Detached workspace: keeps the heavy ethrex host deps out of the main build.
[workspace]
[dependencies]
-# Pinned to the SAME ethrex rev as the guest so the generated ProgramInput rkyv
-# layout matches what the guest deserializes. Re-pin all five manifests together
-# with scripts/set_ethrex_rev.sh.
-ethrex-blockchain = { git = "https://github.com/lambdaclass/ethrex.git", rev = "797df5540c7d35cafd69b6971a74b2a49c67d1dd", package = "ethrex-blockchain" }
-ethrex-storage = { git = "https://github.com/lambdaclass/ethrex.git", rev = "797df5540c7d35cafd69b6971a74b2a49c67d1dd", package = "ethrex-storage" }
-ethrex-common = { git = "https://github.com/lambdaclass/ethrex.git", rev = "797df5540c7d35cafd69b6971a74b2a49c67d1dd", package = "ethrex-common" }
-ethrex-guest-program = { git = "https://github.com/lambdaclass/ethrex.git", rev = "797df5540c7d35cafd69b6971a74b2a49c67d1dd", package = "ethrex-guest-program" }
-ethrex-l2-rpc = { git = "https://github.com/lambdaclass/ethrex.git", rev = "797df5540c7d35cafd69b6971a74b2a49c67d1dd", package = "ethrex-l2-rpc" }
+# Pinned to the SAME ethrex rev as the guest so generated SSZ inputs match what
+# the stateless guest deserializes. Re-pin every manifest together with
+# scripts/set_ethrex_rev.sh.
+ethrex-blockchain = { git = "https://github.com/lambdaclass/ethrex.git", rev = "8effcb0671c5d0b12fe0161ea37c174ec4466b6a", package = "ethrex-blockchain" }
+ethrex-storage = { git = "https://github.com/lambdaclass/ethrex.git", rev = "8effcb0671c5d0b12fe0161ea37c174ec4466b6a", package = "ethrex-storage" }
+ethrex-common = { git = "https://github.com/lambdaclass/ethrex.git", rev = "8effcb0671c5d0b12fe0161ea37c174ec4466b6a", package = "ethrex-common" }
+ethrex-guest-program = { git = "https://github.com/lambdaclass/ethrex.git", rev = "8effcb0671c5d0b12fe0161ea37c174ec4466b6a", package = "ethrex-guest-program" }
+ethrex-l2-rpc = { git = "https://github.com/lambdaclass/ethrex.git", rev = "8effcb0671c5d0b12fe0161ea37c174ec4466b6a", package = "ethrex-l2-rpc" }
+ethrex-rlp = { git = "https://github.com/lambdaclass/ethrex.git", rev = "8effcb0671c5d0b12fe0161ea37c174ec4466b6a", package = "ethrex-rlp" }
+# Walking the witness tries: the storage keys are `keccak(slot)`, so recovering a
+# genesis-shaped alloc from a witness needs the node types, not just the accessors.
+ethrex-trie = { git = "https://github.com/lambdaclass/ethrex.git", rev = "8effcb0671c5d0b12fe0161ea37c174ec4466b6a", package = "ethrex-trie" }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
-# Exact pin: the fixture writer and the guest/executor readers must agree on the
-# rkyv layout. Keep this in sync with executor/{Cargo.toml,programs/rust/ethrex/Cargo.toml}.
-rkyv = { version = "=0.8.16", features = ["std", "unaligned"] }
+# Exact pin: keep the SSZ implementation in sync with the guest's ethrex rev.
+libssz = "=0.3.0"
+libssz-types = "=0.3.0"
serde_json = "1"
bytes = "1"
secp256k1 = "0.30"
diff --git a/tooling/ethrex-fixtures/README.md b/tooling/ethrex-fixtures/README.md
index 7c9b00e0f..d05e18dca 100644
--- a/tooling/ethrex-fixtures/README.md
+++ b/tooling/ethrex-fixtures/README.md
@@ -1,79 +1,145 @@
# ethrex-fixtures
-Generates synthetic **ethrex block fixtures** — serialized `ProgramInput` `.bin`
-files — for the lambda-vm prover tests and benchmarks. Fully in-memory and
-offline (no RPC, no node): it builds a genesis chain, creates a block with a
-chosen number of signed ETH-transfer transactions, runs ethrex's stateless
-witness generation, and writes the rkyv-encoded `ProgramInput`.
+Generates deterministic synthetic ethrex blocks and writes the
+schema-prefixed SSZ stateless input consumed by the LambdaVM guest. The tool
+builds an in-memory chain, creates signed ETH transfers, generates the raw
+execution witness, and validates the resulting input with ethrex's native
+stateless guest before writing it.
-The ethrex dependency is pinned to the **same revision as the guest**
-(`executor/programs/rust/ethrex`), so the produced fixtures deserialize and
-execute in the guest. When you bump the guest's ethrex rev, bump the `rev` in
-this crate's `Cargo.toml` too and regenerate.
-
-## Prerequisites
-- Rust (stable) and network access (the first build fetches the pinned ethrex
- crates). **No RV64 target or sysroot needed** — this is a host tool.
-
-## How to run
+The ethrex revision is pinned to the same commit as the guest. After changing
+that pin, regenerate the committed fixtures with `make regen-ethrex-fixtures`.
```bash
cd tooling/ethrex-fixtures
-cargo run --release -- [mode]
+cargo run --release -- [same|recipients|distinct]
```
-- `` — how many ETH transfers to include in the block (`0` = empty
- block).
-- `` — where to write the `.bin` (relative to this directory).
-- `[mode]` — account diversity (optional, default `same`):
- - `same` — one funded sender (`RICH_PK`) → one fixed recipient (`0xdeadbeef`).
- - `recipients` — one funded sender → N distinct recipients (1 → N fan-out).
- - `distinct` — N distinct, genesis-funded senders → N distinct recipients
- (N independent 1-1 pairs; senders are deterministic synthetic keys injected
- into the genesis allocation). This is what the CI benchmark uses, since the
- state-trie witness for many distinct accounts is closer to a real block.
-
-It prints the output size, the number of transactions, and the mode, e.g.:
+`same` uses one funded sender, `recipients` sends to distinct recipients, and
+`distinct` uses deterministic funded senders. The standard fixtures are:
-```
-wrote ../../executor/tests/ethrex_simple_tx.bin (12745 bytes): block #1 with 1/1 transfer(s) [1 sender -> 1 recipient]
+```bash
+cargo run --release -- 0 ../../executor/tests/ethrex_empty_block.bin
+cargo run --release -- 1 ../../executor/tests/ethrex_simple_tx.bin
+cargo run --release -- 10 ../../executor/tests/ethrex_10_transfers.bin
+cargo run --release -- 4 ../../executor/tests/ethrex_bench_4.bin distinct
```
-Output is deterministic for a given `(n_transfers, mode)`.
+The generator is host-only; it needs no RV64 target or sysroot. Output is
+deterministic for a given transfer count and mode.
-## Creating blocks with different numbers of transactions
+## The benchmark block: `--bin real_block`
-Just change the first argument:
+The synthetic blocks above are transfers, which is not what a real block costs:
+a mainnet block is keccak- and trie-bound, and a prover change can move the two
+numbers in opposite directions. `real_block` produces the benchmark workload
+from a real block instead — its own transactions, its own accounts, its own
+contract code — rebuilt as an Amsterdam block the guest accepts:
```bash
-# empty block (0 transactions)
-cargo run --release -- 0 ../../executor/tests/ethrex_empty_block.bin
+cargo run --release --bin real_block --
+# or, with the cache fetched for you:
+make regen-real-block-fixture
+```
-# 1 transfer
-cargo run --release -- 1 ../../executor/tests/ethrex_simple_tx.bin
+It reads an ethrex-replay cache, installs the block's pre-state into an
+in-memory store **keyed the way the tries are keyed** — `keccak(address)` and
+`keccak(slot)`, so no key preimages are needed and every account and slot the
+block touches arrives intact — registers a parent header at the block's real
+height, replays the transactions in the block's own order through the t8n
+entry point, and validates the result through the native guest before writing.
-# 10 transfers
-cargo run --release -- 10 ../../executor/tests/ethrex_10_transfers.bin
+Output for mainnet 25453112 (38 transactions, 4,238,394 gas):
-# 50 transfers (custom)
-cargo run --release -- 50 /tmp/ethrex_50_transfers.bin
+```
+installed 132 accounts / 261 storage slots / 132 codes
+rebuilt #25453112 (38/38 txs, 3761976 gas, 10 reverted)
```
-For committed fixtures, prefer `make regen-ethrex-fixtures` from the repo root;
-it regenerates the standard fixtures and refreshes
-`executor/tests/README.md` checksums.
-
-> Note: bigger blocks cost ~4M cycles per transfer (software ecrecover
-> dominates), so they execute fine but may be too heavy to *prove* on a typical
-> machine — e.g. 10 transfers ≈ 42M cycles.
-
-## Details
-- Transactions are plain ETH transfers. In `same`/`recipients` mode they are
- signed by a funded dev account from `genesis.json` (well-known load-test key —
- not a secret); in `distinct` mode each is signed by its own synthetic key,
- funded by injecting an entry into the genesis allocation. Output is
- deterministic in all modes.
-- Currently only ETH transfers are supported. (ERC20 / contract calls would be
- a future extension.)
-- Once the upstream LambdaVM-backend ethrex PR merges, this tool can be replaced
- by `ethrex-replay custom block` on ethrex `main`.
+### Why 10 transactions revert, and why that is not a defect here
+
+Those transactions were signed with gas limits computed under Osaka. Amsterdam
+changes the gas model: cold account access goes from 2600 to 3000, and EIP-8037
+carves a state-gas reservoir out of the transaction's excess gas limit. A
+transfer sent with a limit of exactly 21,000 has no excess to carve, so it runs
+out of gas. Running the same rebuild under Osaka rules — `REAL_BLOCK_FORK=osaka`,
+which prints the totals and exits without writing, since the guest only decodes
+the Amsterdam schema — is what separates the fork from the reconstruction: on
+block 25368371 it reverts 2 transactions where Amsterdam reverts 12.
+
+Every pre-Amsterdam block loses transactions this way. A screen of twelve real
+mainnet blocks (both of our release caches plus ethrex's curated zkevm_bench
+corpus) put the revert share between 26% and 50% with no exceptions, so it is not
+a criterion for picking one.
+
+Reverting is separate from being *dropped*: a reverted transaction was applied and
+paid for its gas, while a dropped one never entered the block. This block applies
+all 38 of its transactions, and the generator refuses to write a fixture that
+applies fewer, because every other guard would still pass — a block with fewer
+transactions is a valid block, so the loss would show up only as a smaller
+benchmark. Screening candidate blocks does need the partial ones, so pass
+`REAL_BLOCK_ALLOW_DROPS=1` for that; note also that the generator is not universal
+(block 25087563 fails with `StateRootMismatch`), which is why screening is a
+required step before pinning a different block. What the screen was for is weight: the retired
+fixture cost 30.50M cycles on today's guest, and this block rebuilds to 37.14M
+(+22%), the closest of the twelve — 25368371 comes in at -33% and the next
+candidate up, 25087308, at +197%. Several blocks end up consuming *more* gas than
+they did on mainnet (+7% to +22%), because Amsterdam makes the surviving
+transactions dearer.
+
+So what this fixture is, is a *real-mix* Amsterdam block — real contract code,
+real calldata, real signatures, real trie depth — and not a replay of mainnet
+economics. For workloads whose gas limits were computed for Amsterdam, use the
+EEST benchmark fixtures; `ETHREX_BENCH_WORKLOAD_AFTER_BUMP.md` in the repository
+root has both sets measured side by side.
+
+Measured on the guest ELF at ethrex `8effcb06`: **37,137,748 cycles**, 6,003
+keccak calls, 164 ECSM calls. Fixture: 549,144 bytes.
+
+**Pin the ELF whenever you quote a cycle count.** The three counts are
+deterministic for a given ELF and input, and they move with anything that changes
+the guest — including changes that touch none of the source it compiles. Moving
+the pin from `2cb18b0b` to `8effcb06` cost **+362 cycles, 0.001 %**, with keccak
+and ECSM identical, and that was the whole difference: no `.rs` file in the
+guest's dependency graph differs between the two revs and no crates.io dependency
+moved, so what shifted is the version metadata the ethrex crates carry. Immaterial
+next to the ~1 % this workload can resolve, but it does mean an exact count
+belongs to an exact rev.
+
+The compiler is not pinned either — the guest embeds C (`secp256k1-sys`) and the
+Makefile pins target flags but not `cc` — so in principle two boxes with different
+clang majors can disagree. In practice the effect is small and not well
+characterised: the one figure recorded in this repo is 0.13 % on a different
+block, while this block came out at exactly 37,137,386 on both macOS/arm64 and the
+Linux x86-64 runner at the previous pin, from ELF binaries that were themselves
+different. Quote the rev; do not assume the machine matters, and do not assume it
+does not.
+
+### Choosing the epoch size, and what the workload costs
+
+Measured on the bench runner (`vm-benchmarks-1`, which is also the CI
+self-hosted `bench` runner: 96 cores / 125 GB) with the guest ELF at ethrex
+`2cb18b0b`, over 14 proves across two sittings:
+
+| | mean | sd | CV | peak RSS | proof | verify |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: |
+| epoch 2^22, 14 proves | **125.33 s** | 1.58 s | 1.26 % | 44.8 GiB | 790 MB | 12.0 s |
+| of those, the 5 that got the most CPU | 124.47 s | 0.43 s | 0.34 % | | | |
+
+2^22 is what `/bench`, `/bench-abba` and the GPU bench pin. The epoch trade-off
+itself was swept on the previous block (2^21 costs +13.7 % of wall to save
+12 GiB; 2^23 buys −7.5 % for +16 GiB): peak RSS is set by the epoch size rather
+than by the block, so that shape carries over even though the seconds do not.
+2^23 would take this workload past 50 GiB against the runner's 64 GiB floor,
+which is why memory and not speed picks the default.
+
+The two rows are the same binary on the same block; what separates them is how
+much of the shared box each prove got. Wall time here is a function of CPU share,
+not of the prover, so quote a spread only together with the condition it was
+measured under — `scripts/bench_abba.sh` records the CPU share of every prove and
+flags a contended batch, and its comments carry the measurement. A two-sided 95 %
+comparison resolves ~0.6 % at three runs per side on a quiet box and ~2.0 % on a
+busy one, which is why a sub-2 % claim needs `/bench N` or the ABBA tiebreaker
+rather than a re-read of a three-run table.
+
+Continuations are not optional here: monolithic proving costs ~4.9 GB of peak
+heap per million cycles on this family, so 37.14M cycles would need ~182 GB.
diff --git a/tooling/ethrex-fixtures/genesis.json b/tooling/ethrex-fixtures/genesis.json
index af3626151..6208bf936 100644
--- a/tooling/ethrex-fixtures/genesis.json
+++ b/tooling/ethrex-fixtures/genesis.json
@@ -1126,6 +1126,18 @@
"balance": "0xc097ce7bc90715b34b9f1000000000",
"nonce": "0x0"
},
+ "0x0000bff46984e3725691fa540a8c7589300d8282": {
+ "code": "0x3373fffffffffffffffffffffffffffffffffffffffe146101065760115f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff1461023457600182026001905f5b5f82111560695781019083028483029004916001019190604e565b90939004925050503660b814608957366102345734610234575f5260205ff35b8034106102345760383567ffffffffffffffff1680633b9aca001161023457633b9aca00029034031061023457600154600101600155600354806006026004015f358155600101602035815560010160403581556001016060358155600101608035815560010160a035905560b85f5f3760b85fa0600101600355005b600354600254808203806101001161011d57506101005b5f5b8181146101c3578281016006026004018160b8028154815260200181600101548152602001816002015480825260401c67ffffffffffffffff16816010018160381c81600701538160301c81600601538160281c81600501538160201c81600401538160181c81600301538160101c81600201538160081c81600101535360200181600301548152602001816004015481526020019060050154905260010161011f565b91018092146101d557906002556101e0565b90505f6002555f6003555b5f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff141561020d57505f5b6001546020828201116102225750505f610228565b01602090035b5f555f60015560b8025ff35b5f5ffd",
+ "storage": {},
+ "balance": "0x0",
+ "nonce": "0x1"
+ },
+ "0x000064d678505ad48f8ccb093bc65613800e8282": {
+ "code": "0x3373fffffffffffffffffffffffffffffffffffffffe1460cb5760115f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff1461018857600182026001905f5b5f82111560685781019083028483029004916001019190604d565b909390049250505036603014608857366101885734610188575f5260205ff35b341061018857600154600101600155600354806003026004013381556001015f35815560010160203590553360601b5f5260305f60143760445fa0600101600355005b6003546002548082038060101160df575060105b5f5b8181146101175782810160030260040181604402815460601b8152601401816001015481526020019060020154905260010160e1565b91018092146101295790600255610134565b90505f6002555f6003555b5f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff141561016157505f5b6001546002828201116101765750505f61017c565b01600290035b5f555f6001556044025ff35b5f5ffd",
+ "storage": {},
+ "balance": "0x0",
+ "nonce": "0x1"
+ },
"0x4e59b44847b379578588920ca78fbf26c0b4956c": {
"code": "0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3",
"storage": {},
diff --git a/tooling/ethrex-fixtures/src/bin/real_block.rs b/tooling/ethrex-fixtures/src/bin/real_block.rs
new file mode 100644
index 000000000..081a70000
--- /dev/null
+++ b/tooling/ethrex-fixtures/src/bin/real_block.rs
@@ -0,0 +1,402 @@
+//! Rebuild a real mainnet block as an Amsterdam block the stateless guest accepts.
+//!
+//! The retired benchmark fixture (`ethrex_mainnet_25368371.bin`) is an rkyv
+//! `ProgramInput` from before execution-specs #3278. The pinned guest only
+//! decodes schema `0x1501` (Amsterdam) and mainnet has no Amsterdam fork yet, so
+//! that block cannot be re-serialized: it has no block access list hash and no
+//! slot number, and its state carries none of the EIP-8282 predeploys Amsterdam
+//! requires. What CAN be reused is its workload — the real transactions and the
+//! real accounts they touch.
+//!
+//! This takes an ethrex-replay cache, seeds a local Amsterdam genesis with the
+//! accounts from the block's own execution witness, replays the block's
+//! transactions on top, and emits the SSZ stateless input for the resulting
+//! block. The output is NOT the mainnet block: the state root, block hash and
+//! gas schedule are this chain's. It is the same transaction mix, which is the
+//! part no synthetic block reproduces.
+//!
+//! Usage:
+//! cargo run --release --bin real_block --
+
+use bytes::Bytes;
+use ethrex_blockchain::payload::{BuildPayloadArgs, create_payload};
+use ethrex_blockchain::{Blockchain, BlockchainOptions};
+use ethrex_common::H256;
+use ethrex_common::types::block_execution_witness::{
+ ExecutionWitness, RpcExecutionWitness, amsterdam_chain_config, decode_witness_headers,
+};
+use ethrex_common::types::{AccountState, Block, ELASTICITY_MULTIPLIER, Genesis};
+use ethrex_fixtures::build_stateless_input;
+use ethrex_guest_program::crypto::{Crypto, NativeCrypto};
+use ethrex_guest_program::l1::run_stateless_guest;
+use ethrex_rlp::decode::RLPDecode;
+use ethrex_rlp::encode::RLPEncode;
+use ethrex_storage::{EngineType, Store};
+use ethrex_trie::{Nibbles, Node, NodeRef};
+use std::collections::BTreeMap;
+
+const GENESIS_JSON: &str = include_str!("../../genesis.json");
+/// Mainnet: the cached transactions are signed for it, so the local chain has to
+/// claim the same id or every signature fails.
+const CHAIN_ID: u64 = 1;
+/// Low enough that every cached transaction clears the block's base fee. The
+/// real block's was 0.119 gwei; a transaction rejected for fee reasons would
+/// silently shrink the workload.
+const GENESIS_BASE_FEE: u64 = 1_000;
+const GAS_CEIL: u64 = 60_000_000;
+
+/// Walk an embedded witness trie and return every `(32-byte key, value)` leaf it
+/// still holds. Pruned subtrees appear as `NodeRef::Hash` and are skipped: they
+/// are the proof siblings, which no transaction in the block reads.
+///
+/// Returns the number of leaves that could NOT be turned into a 32-byte key. In
+/// a hashed trie there is no such thing, so a non-zero count means a malformed
+/// witness node — and the caller must refuse to build on it. Dropping one
+/// silently loses an account or a storage slot, the transaction that reads it
+/// then sees zero and reverts, and the fixture ends up carrying less work than
+/// the block it claims to reproduce with the revert count as its only symptom.
+/// The installed-vs-collected counts cannot catch it: both are derived from this
+/// function's output, so they agree by construction.
+fn collect_leaves(node: &Node, path: Nibbles, out: &mut Vec<(H256, Vec)>) -> usize {
+ let descend = |child: &NodeRef, path: Nibbles, out: &mut Vec<(H256, Vec)>| -> usize {
+ if let NodeRef::Node(child, _) = child {
+ collect_leaves(child, path, out)
+ } else {
+ 0
+ }
+ };
+ match node {
+ Node::Branch(branch) => {
+ let mut unusable = 0;
+ for (i, child) in branch.choices.iter().enumerate() {
+ unusable += descend(child, path.append_new(i as u8), out);
+ }
+ unusable
+ }
+ Node::Extension(ext) => descend(&ext.child, path.concat(&ext.prefix), out),
+ Node::Leaf(leaf) => {
+ let bytes = path.concat(&leaf.partial).to_bytes();
+ if bytes.len() == 32 {
+ out.push((H256::from_slice(&bytes), leaf.value.clone()));
+ 0
+ } else {
+ 1
+ }
+ }
+ }
+}
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ let mut args = std::env::args();
+ let program = args.next().unwrap_or_else(|| "real_block".into());
+ let (Some(cache_path), Some(out_path)) = (args.next(), args.next()) else {
+ eprintln!("usage: {program} ");
+ std::process::exit(2);
+ };
+
+ // --- 1. cache -> real block + its execution witness --------------------
+ let cache: serde_json::Value = serde_json::from_slice(&std::fs::read(&cache_path)?)?;
+ let blocks = cache
+ .get("blocks")
+ .and_then(|b| b.as_array())
+ .ok_or("cache has no `blocks` array")?;
+ if blocks.len() != 1 {
+ return Err(format!("cache holds {} blocks; expected exactly 1", blocks.len()).into());
+ }
+ let real_block: Block = serde_json::from_value(blocks[0].clone())?;
+ let rpc_witness: RpcExecutionWitness = serde_json::from_value(
+ cache
+ .get("witness")
+ .ok_or("cache has no `witness`")?
+ .clone(),
+ )?;
+
+ let decoded_headers = decode_witness_headers(&rpc_witness.headers)
+ .map_err(|e| format!("decode witness headers: {e:?}"))?;
+ let witness: ExecutionWitness = rpc_witness
+ .clone()
+ .into_execution_witness(
+ amsterdam_chain_config(CHAIN_ID),
+ real_block.header.number,
+ &decoded_headers,
+ &NativeCrypto,
+ )
+ .map_err(|e| format!("into_execution_witness: {e:?}"))?;
+
+ let codes_by_hash: std::collections::HashMap> = witness
+ .codes
+ .iter()
+ .map(|code| (H256(NativeCrypto.keccak256(code)), code.clone()))
+ .collect();
+ // --- 2. a local Amsterdam chain to host the block ----------------------
+ // The committed genesis carries the system contracts and the two EIP-8282
+ // predeploys Amsterdam needs; only the chain id and fee floor change. The
+ // block's own accounts do NOT go in the alloc — they are installed into the
+ // tries below, which needs no key preimages.
+ let mut genesis: Genesis = serde_json::from_str(GENESIS_JSON)?;
+ let blob_schedule = genesis.config.blob_schedule;
+ genesis.config = amsterdam_chain_config(CHAIN_ID);
+ genesis.config.blob_schedule = blob_schedule;
+ // `REAL_BLOCK_FORK=osaka` executes the same block under the rules it was
+ // built for. The guest cannot consume the result (it only decodes the
+ // Amsterdam schema), so this exists purely to attribute a gas difference to
+ // the fork rather than to this tool's reconstruction.
+ let osaka_probe = std::env::var("REAL_BLOCK_FORK").as_deref() == Ok("osaka");
+ if osaka_probe {
+ genesis.config.amsterdam_time = None;
+ }
+ genesis.gas_limit = GAS_CEIL;
+ genesis.base_fee_per_gas = Some(GENESIS_BASE_FEE);
+ genesis.timestamp = real_block.header.timestamp.saturating_sub(12);
+
+ let mut accounts = Vec::new();
+ let mut unusable_leaves = 0usize;
+ if let Some(root) = &witness.state_trie_root {
+ unusable_leaves += collect_leaves(root, Nibbles::default(), &mut accounts);
+ }
+
+ let mut store = Store::new(".ethrex-real-block-tmp", EngineType::InMemory)?;
+ store.add_initial_state(genesis).await?;
+ let genesis_header = store
+ .get_block_header(store.get_latest_block_number()?)?
+ .ok_or("missing genesis header")?;
+
+ // Install the block's own pre-state into the store's tries, keyed the way the
+ // tries themselves are keyed: `keccak(address)` and `keccak(slot)`. That is
+ // what makes this faithful — a `Genesis.alloc` is keyed by the preimages, and
+ // the witness cannot give those back (they are hashes; the replay cache's
+ // `keys` field carries only the few the RPC returned, and upstream is removing
+ // it). Writing the hashed keys directly needs no preimages at all, so every
+ // account and every storage slot the block touches arrives intact.
+ let genesis_root = genesis_header.state_root;
+ // The layered opener's `put_batch` is `unimplemented!()` — the writable path is
+ // the direct one, which talks to the storage backend without the reorg overlay.
+ let mut state_trie = store.open_direct_state_trie(genesis_root)?;
+ let mut installed_accounts = 0usize;
+ let mut installed_slots = 0usize;
+ let mut installed_codes = 0usize;
+ for (hashed_address, encoded) in &accounts {
+ let mut account = AccountState::decode(encoded)
+ .map_err(|e| format!("decode account {hashed_address:#x}: {e:?}"))?;
+ if let Some(storage_root) = witness.storage_trie_roots.get(hashed_address) {
+ let mut leaves = Vec::new();
+ unusable_leaves += collect_leaves(storage_root, Nibbles::default(), &mut leaves);
+ let mut storage_trie = store.open_direct_storage_trie(
+ *hashed_address,
+ *ethrex_common::constants::EMPTY_TRIE_HASH,
+ )?;
+ for (hashed_slot, value) in &leaves {
+ storage_trie.insert(hashed_slot.0.to_vec(), value.clone())?;
+ }
+ installed_slots += leaves.len();
+ account.storage_root = storage_trie.hash(&NativeCrypto)?;
+ }
+ if let Some(code) = codes_by_hash.get(&account.code_hash) {
+ store
+ .add_account_code(ethrex_common::types::Code::from_bytecode(
+ Bytes::from(code.clone()),
+ &NativeCrypto,
+ ))
+ .await?;
+ installed_codes += 1;
+ }
+ state_trie.insert(hashed_address.0.to_vec(), account.encode_to_vec())?;
+ installed_accounts += 1;
+ }
+ let installed_root = state_trie.hash(&NativeCrypto)?;
+ if unusable_leaves > 0 {
+ return Err(format!(
+ "{unusable_leaves} witness leaf/leaves did not yield a 32-byte trie key, so \
+ that much of the block's pre-state was never installed. The transactions \
+ reading it would see zero and revert, shrinking the workload silently."
+ )
+ .into());
+ }
+
+ // Build at the real height, not at #1. The seeded accounts hold the block's
+ // own pre-state, but a contract that stores a block number and compares it
+ // against `block.number` reads nonsense on a chain that just started — and
+ // several of the block's transactions do exactly that. So register a parent
+ // header at the real parent's height carrying the genesis state root (which
+ // is the seeded state), and build on top of it. The guest only ever sees
+ // this parent and the block itself, and those two are contiguous.
+ let mut head = genesis_header.clone();
+ head.hash = Default::default();
+ head.number = real_block.header.number - 1;
+ head.parent_hash = genesis_header.hash();
+ head.timestamp = real_block.header.timestamp.saturating_sub(12);
+ head.gas_limit = GAS_CEIL;
+ head.base_fee_per_gas = Some(GENESIS_BASE_FEE);
+ head.state_root = installed_root;
+ let head_hash = head.hash();
+ let head_number = head.number;
+ store.add_block_header(head_hash, head.clone()).await?;
+ store
+ .add_block_body(head_hash, ethrex_common::types::BlockBody::empty())
+ .await?;
+ store.add_block_number(head_hash, head_number).await?;
+ store
+ .forkchoice_update(
+ vec![(head_number, head_hash)],
+ head_number,
+ head_hash,
+ None,
+ None,
+ )
+ .await?;
+ // Real blocks carry builder/MEV transactions with a zero tip cap, which the
+ // default mempool floor rejects. Dropping them would silently shrink the
+ // workload this fixture exists to reproduce.
+ let blockchain = Blockchain::new(
+ store.clone(),
+ BlockchainOptions {
+ min_tip_wei: 0,
+ ..BlockchainOptions::default()
+ },
+ );
+
+ // --- 3. replay the real transactions, in the block's own order ----------
+ // Not through the mempool: `build_payload` would re-sort by effective tip and
+ // an arbitrage transaction that ran second on mainnet reverts when it runs
+ // first. The t8n entry point takes an explicit, ordered list, reports what it
+ // could not apply instead of aborting, and seeds BLOCKHASH with the ancestor
+ // hashes the witness carries.
+ let total_txs = real_block.body.transactions.len();
+ let payload_args = BuildPayloadArgs {
+ parent: head_hash,
+ timestamp: head.timestamp + 12,
+ fee_recipient: real_block.header.coinbase,
+ random: real_block.header.prev_randao,
+ withdrawals: Some(vec![]),
+ beacon_root: Some(H256::zero()),
+ slot_number: Some(1),
+ version: 4,
+ elasticity_multiplier: ELASTICITY_MULTIPLIER,
+ gas_ceil: GAS_CEIL,
+ };
+ let skeleton = create_payload(&payload_args, &store, Bytes::new())?;
+ let mut block_hash_cache = BTreeMap::new();
+ block_hash_cache.insert(head_number, head_hash);
+ let (result, rejected, t8n_error) = blockchain.build_payload_t8n(
+ skeleton,
+ real_block.body.transactions.clone(),
+ block_hash_cache,
+ false,
+ )?;
+ if let Some(error) = t8n_error {
+ return Err(format!("payload build reported: {error}").into());
+ }
+
+ // Amsterdam always emits one `EncodedRequests` per request type (a lone type
+ // byte when the list is empty), so the count is not the signal — a non-empty
+ // payload is. The SSZ input below declares no requests, which only matches
+ // the header's `requests_hash` while every list is in fact empty.
+ let non_empty_requests = result
+ .requests
+ .iter()
+ .filter(|encoded| !encoded.is_empty())
+ .count();
+ if non_empty_requests > 0 {
+ return Err(format!(
+ "the rebuilt block produced {non_empty_requests} non-empty EIP-7685 request \
+ list(s); the SSZ input this tool writes declares none"
+ )
+ .into());
+ }
+ if osaka_probe {
+ let reverted = result.receipts.iter().filter(|r| !r.succeeded).count();
+ println!(
+ "osaka probe #{} ({}/{} txs, {} gas, {reverted} reverted) vs real {} gas",
+ result.payload.header.number,
+ result.payload.body.transactions.len(),
+ total_txs,
+ result.payload.header.gas_used,
+ real_block.header.gas_used,
+ );
+ return Ok(());
+ }
+
+ let block = result.payload;
+ let included = block.body.transactions.len();
+ // The tx mix is the whole reason this fixture exists, so a builder that applied
+ // fewer than all of them has produced a smaller workload than the block it claims
+ // to reproduce -- and every guard below still passes, because a block with fewer
+ // transactions is a perfectly valid block. The screen sets the escape: surveying
+ // candidate blocks needs the partial ones reported, not refused.
+ if included != total_txs && std::env::var_os("REAL_BLOCK_ALLOW_DROPS").is_none() {
+ return Err(format!(
+ "the payload builder applied only {included} of {total_txs} transactions; \
+ rejected: {rejected:?}. Set REAL_BLOCK_ALLOW_DROPS=1 to write the fixture \
+ anyway."
+ )
+ .into());
+ }
+
+ // --- 4. witness -> SSZ -> native validation ----------------------------
+ let witness = blockchain
+ .generate_witness_for_blocks(std::slice::from_ref(&block))
+ .await?;
+ let witness: RpcExecutionWitness = witness.try_into()?;
+ let bytes = build_stateless_input(
+ &block,
+ &witness,
+ result.block_access_list.as_ref(),
+ CHAIN_ID,
+ )?;
+ let output = run_stateless_guest(&bytes, std::sync::Arc::new(NativeCrypto));
+ if output.len() != 43 || output[32] == 0 {
+ return Err("rebuilt block failed native stateless validation".into());
+ }
+ std::fs::write(&out_path, &bytes)?;
+
+ // --- 5. report ---------------------------------------------------------
+ let reverted = result.receipts.iter().filter(|r| !r.succeeded).count();
+ let mut per_tx = String::new();
+ let mut prev_cumulative = 0u64;
+ for (i, receipt) in result.receipts.iter().enumerate() {
+ let gas = receipt.cumulative_gas_used.saturating_sub(prev_cumulative);
+ prev_cumulative = receipt.cumulative_gas_used;
+ let to = match block.body.transactions.get(i).map(|tx| tx.to()) {
+ Some(ethrex_common::types::TxKind::Call(address)) => format!("{address:#x}"),
+ Some(ethrex_common::types::TxKind::Create) => "create".to_string(),
+ None => "?".to_string(),
+ };
+ per_tx.push_str(&format!(
+ " tx {i:>2}: {:>9} gas {:<8} -> {to}\n",
+ gas,
+ if receipt.succeeded { "ok" } else { "REVERTED" }
+ ));
+ }
+ println!(
+ "real block #{} ({} txs, {} gas)",
+ real_block.header.number, total_txs, real_block.header.gas_used
+ );
+ println!(
+ "witness {} account leaves / {} codes / {} trie nodes",
+ accounts.len(),
+ rpc_witness.codes.len(),
+ rpc_witness.state.len()
+ );
+ println!(
+ "installed {installed_accounts} accounts / {installed_slots} storage slots / \
+ {installed_codes} codes, state root {installed_root:#x}"
+ );
+ println!(
+ "rebuilt #{} ({included}/{total_txs} txs, {} gas, {reverted} reverted)",
+ block.header.number, block.header.gas_used
+ );
+ print!("{per_tx}");
+ if !rejected.is_empty() {
+ println!("rejected ({} of {total_txs}):", rejected.len());
+ for entry in &rejected {
+ println!(" {entry:?}");
+ }
+ }
+ println!(
+ "gas vs real {:+.2}%",
+ (block.header.gas_used as f64 / real_block.header.gas_used as f64 - 1.0) * 100.0
+ );
+ println!("wrote {out_path} ({} bytes)", bytes.len());
+ Ok(())
+}
diff --git a/tooling/ethrex-fixtures/src/lib.rs b/tooling/ethrex-fixtures/src/lib.rs
new file mode 100644
index 000000000..00849e20f
--- /dev/null
+++ b/tooling/ethrex-fixtures/src/lib.rs
@@ -0,0 +1,155 @@
+//! Shared SSZ input construction for this crate's two generators.
+//!
+//! `build_stateless_input` is the encoder both binaries need — `ethrex-fixtures` for the
+//! synthetic blocks it builds from a funded genesis, and `real_block` for a real mainnet
+//! block rebuilt as an Amsterdam one. It used to be copied into each, which meant the
+//! copies had to be moved together on every ethrex rev bump with nothing enforcing it.
+
+use ethrex_common::types::Block;
+use ethrex_common::types::block_access_list::BlockAccessList;
+use ethrex_common::types::block_execution_witness::RpcExecutionWitness;
+use ethrex_common::types::stateless_ssz::{
+ Bytes20, ExecutionPayload, ExecutionRequests, LogsBloom, NewPayloadRequest,
+ STATELESS_INPUT_SCHEMA_ID, SszExecutionWitness, SszPublicKeys, SszStatelessInput,
+};
+use ethrex_guest_program::crypto::NativeCrypto;
+use libssz::SszEncode;
+use libssz_types::{ProgressiveList, SszList, SszVector};
+
+pub fn empty_execution_requests() -> ExecutionRequests {
+ ExecutionRequests {
+ deposits: ProgressiveList::new(),
+ withdrawals: ProgressiveList::new(),
+ consolidations: ProgressiveList::new(),
+ builder_deposits: ProgressiveList::new(),
+ builder_exits: ProgressiveList::new(),
+ }
+}
+
+pub fn build_stateless_input(
+ block: &Block,
+ witness: &RpcExecutionWitness,
+ block_access_list: Option<&BlockAccessList>,
+ chain_id: u64,
+) -> Result, Box> {
+ let header = &block.header;
+ let bal = block_access_list.ok_or("Amsterdam fixture has no block access list")?;
+ let block_access_list_hash = header
+ .block_access_list_hash
+ .ok_or("Amsterdam fixture header has no block_access_list_hash")?;
+ if bal.compute_hash(&NativeCrypto) != block_access_list_hash {
+ return Err("fixture BAL does not match block_access_list_hash".into());
+ }
+ let slot_number = header
+ .slot_number
+ .ok_or("Amsterdam fixture header has no slot_number")?;
+
+ let transactions = block
+ .body
+ .transactions
+ .iter()
+ .map(|tx| tx.encode_canonical_to_vec().into())
+ .collect::>>()
+ .into();
+ let public_keys = block
+ .body
+ .transactions
+ .iter()
+ .enumerate()
+ .map(|(i, tx)| {
+ let key = tx
+ .public_key(&NativeCrypto)
+ .map_err(|e| format!("failed to recover public key for transaction {i}: {e}"))?
+ .ok_or_else(|| format!("transaction {i} has no recoverable signature"))?;
+ SszVector::try_from(key.to_vec())
+ .map_err(|e| format!("public key for transaction {i} is invalid: {e:?}"))
+ })
+ .collect::, String>>()?;
+ let withdrawals = block
+ .body
+ .withdrawals
+ .clone()
+ .unwrap_or_default()
+ .into_iter()
+ .map(
+ |withdrawal| ethrex_common::types::stateless_ssz::Withdrawal {
+ index: withdrawal.index,
+ validator_index: withdrawal.validator_index,
+ address: Bytes20(withdrawal.address.0),
+ amount: withdrawal.amount,
+ },
+ )
+ .collect::>()
+ .into();
+ let logs_bloom: LogsBloom = SszVector::try_from(header.logs_bloom.0.to_vec())?;
+ let extra_data = SszList::try_from(header.extra_data.to_vec())?;
+ let base_fee = header.base_fee_per_gas.ok_or("fixture has no base fee")?;
+ let mut base_fee_bytes = [0u8; 32];
+ base_fee_bytes[..8].copy_from_slice(&base_fee.to_le_bytes());
+
+ let execution_payload = ExecutionPayload {
+ parent_hash: header.parent_hash.0,
+ fee_recipient: Bytes20(header.coinbase.0),
+ state_root: header.state_root.0,
+ receipts_root: header.receipts_root.0,
+ logs_bloom,
+ prev_randao: header.prev_randao.0,
+ block_number: header.number,
+ gas_limit: header.gas_limit,
+ gas_used: header.gas_used,
+ timestamp: header.timestamp,
+ extra_data,
+ base_fee_per_gas: base_fee_bytes,
+ block_hash: header.compute_block_hash(&NativeCrypto).0,
+ transactions,
+ withdrawals,
+ blob_gas_used: header.blob_gas_used.unwrap_or_default(),
+ excess_blob_gas: header.excess_blob_gas.unwrap_or_default(),
+ block_access_list: ethrex_rlp::encode::RLPEncode::encode_to_vec(bal).into(),
+ slot_number,
+ };
+ let ssz_witness = SszExecutionWitness {
+ state: ProgressiveList::from(
+ witness
+ .state
+ .iter()
+ .map(|bytes| SszList::try_from(bytes.to_vec()))
+ .collect::, _>>()?,
+ ),
+ codes: ProgressiveList::from(
+ witness
+ .codes
+ .iter()
+ .map(|bytes| SszList::try_from(bytes.to_vec()))
+ .collect::, _>>()?,
+ ),
+ headers: SszList::try_from(
+ witness
+ .headers
+ .iter()
+ .map(|bytes| SszList::try_from(bytes.to_vec()))
+ .collect::, _>>()?,
+ )?,
+ };
+ let input = SszStatelessInput {
+ new_payload_request: NewPayloadRequest {
+ execution_payload,
+ versioned_hashes: block
+ .body
+ .transactions
+ .iter()
+ .flat_map(|tx| tx.blob_versioned_hashes())
+ .map(|hash| hash.0)
+ .collect::>()
+ .into(),
+ parent_beacon_block_root: header.parent_beacon_block_root.unwrap_or_default().0,
+ execution_requests: empty_execution_requests(),
+ },
+ witness: ssz_witness,
+ chain_id,
+ public_keys: SszPublicKeys::from(public_keys),
+ };
+ let mut bytes = STATELESS_INPUT_SCHEMA_ID.to_be_bytes().to_vec();
+ input.ssz_append(&mut bytes);
+ Ok(bytes)
+}
diff --git a/tooling/ethrex-fixtures/src/main.rs b/tooling/ethrex-fixtures/src/main.rs
index 5cb60acae..e91ac0e6a 100644
--- a/tooling/ethrex-fixtures/src/main.rs
+++ b/tooling/ethrex-fixtures/src/main.rs
@@ -1,4 +1,4 @@
-//! Generate synthetic ethrex block fixtures (serialized `ProgramInput`) for the
+//! Generate synthetic ethrex block fixtures (SSZ stateless inputs) for the
//! lambda-vm prover/benchmarks — in-memory, offline, deterministic.
//!
//! Usage:
@@ -18,17 +18,20 @@
//! the LambdaVM-backend ethrex PR lands on ethrex `main` and fixtures are
//! generated via `ethrex-replay custom block` instead.
//!
-//! Pinned to the same ethrex rev as the guest, so the rkyv `ProgramInput`
-//! layout matches what the guest deserializes.
+//! Pinned to the same ethrex rev as the guest, so the SSZ layout matches what
+//! the guest deserializes.
use bytes::Bytes;
use ethrex_blockchain::payload::{BuildPayloadArgs, create_payload};
use ethrex_blockchain::{Blockchain, BlockchainOptions};
+use ethrex_common::types::block_execution_witness::RpcExecutionWitness;
use ethrex_common::types::{
EIP1559Transaction, ELASTICITY_MULTIPLIER, Genesis, GenesisAccount, Transaction, TxKind,
};
use ethrex_common::{Address, H256, U256};
-use ethrex_guest_program::l1::ProgramInput;
+use ethrex_fixtures::build_stateless_input;
+use ethrex_guest_program::crypto::NativeCrypto;
+use ethrex_guest_program::l1::run_stateless_guest;
use ethrex_l2_rpc::signer::{LocalSigner, Signable, Signer};
use ethrex_storage::{EngineType, Store};
use secp256k1::SecretKey;
@@ -107,6 +110,11 @@ async fn main() -> Result<(), Box> {
// --- 1. genesis -> in-memory store -------------------------------------
let mut genesis: Genesis = serde_json::from_str(GENESIS_JSON)?;
+ // The stateless guest consumes the Amsterdam SSZ schema. Keep the local
+ // chain's fork schedule aligned with that schema while preserving the
+ // fixture's deterministic execution rules.
+ genesis.config.amsterdam_time = Some(0);
+ genesis.config.blob_schedule.amsterdam = Some(genesis.config.blob_schedule.bpo2);
// For `distinct`, fund each synthetic sender in genesis so its tx is valid.
if mode == Mode::Distinct {
@@ -127,7 +135,7 @@ async fn main() -> Result<(), Box> {
let mut store = Store::new(".ethrex-fixtures-tmp", EngineType::InMemory)?;
store.add_initial_state(genesis).await?;
- let head_number = store.get_latest_block_number().await?;
+ let head_number = store.get_latest_block_number()?;
let head = store
.get_block_header(head_number)?
.ok_or("missing genesis header")?;
@@ -187,14 +195,15 @@ async fn main() -> Result<(), Box> {
random: H256::zero(),
withdrawals: Some(vec![]),
beacon_root: Some(H256::zero()),
- slot_number: None,
- version: 3,
+ slot_number: Some(1),
+ version: 4,
elasticity_multiplier: ELASTICITY_MULTIPLIER,
gas_ceil: 60_000_000,
};
let skeleton = create_payload(&payload_args, &store, Bytes::new())?;
let result = blockchain.build_payload(skeleton)?;
let block = result.payload;
+ let block_access_list = result.block_access_list;
let included = block.body.transactions.len();
assert_eq!(
included as u64, n_transfers,
@@ -202,12 +211,16 @@ async fn main() -> Result<(), Box> {
(check gas limit / account balance / nonces)"
);
- // --- 4. stateless witness -> ProgramInput -> rkyv ----------------------
+ // --- 4. stateless witness -> SSZ ---------------------------------------
let witness = blockchain
.generate_witness_for_blocks(std::slice::from_ref(&block))
.await?;
- let program_input = ProgramInput::new(vec![block], witness);
- let bytes = rkyv::to_bytes::(&program_input)?;
+ let witness: RpcExecutionWitness = witness.try_into()?;
+ let bytes = build_stateless_input(&block, &witness, block_access_list.as_ref(), chain_id)?;
+ let output = run_stateless_guest(&bytes, std::sync::Arc::new(NativeCrypto));
+ if output.len() != 43 || output[32] == 0 {
+ return Err("generated stateless fixture failed native validation".into());
+ }
std::fs::write(&out_path, &bytes)?;
let mode_label = match mode {
diff --git a/tooling/ethrex-tests/Cargo.lock b/tooling/ethrex-tests/Cargo.lock
index 0a86228f1..4a27fae68 100644
--- a/tooling/ethrex-tests/Cargo.lock
+++ b/tooling/ethrex-tests/Cargo.lock
@@ -693,8 +693,8 @@ dependencies = [
[[package]]
name = "ethrex-common"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"crc32fast",
@@ -714,7 +714,6 @@ dependencies = [
"libssz-types",
"lru",
"once_cell",
- "rayon",
"rkyv",
"rustc-hash",
"secp256k1",
@@ -727,8 +726,8 @@ dependencies = [
[[package]]
name = "ethrex-crypto"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"ark-bn254",
"ark-ec",
@@ -748,8 +747,8 @@ dependencies = [
[[package]]
name = "ethrex-guest-program"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"ethereum-types",
@@ -759,6 +758,10 @@ dependencies = [
"ethrex-rlp",
"ethrex-vm",
"hex",
+ "libssz",
+ "libssz-derive",
+ "libssz-merkle",
+ "libssz-types",
"rkyv",
"serde",
"serde_with",
@@ -767,8 +770,8 @@ dependencies = [
[[package]]
name = "ethrex-l2-common"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"ethereum-types",
@@ -777,7 +780,6 @@ dependencies = [
"k256",
"lambdaworks-crypto",
"rkyv",
- "secp256k1",
"serde",
"serde_with",
"thiserror 2.0.18",
@@ -786,8 +788,8 @@ dependencies = [
[[package]]
name = "ethrex-levm"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"derive_more",
@@ -796,7 +798,6 @@ dependencies = [
"ethrex-rlp",
"libssz",
"malachite",
- "rayon",
"rustc-hash",
"serde",
"strum",
@@ -805,8 +806,8 @@ dependencies = [
[[package]]
name = "ethrex-rlp"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"ethereum-types",
@@ -819,13 +820,13 @@ version = "0.1.0"
dependencies = [
"ethrex-guest-program",
"executor",
- "rkyv",
+ "libssz",
]
[[package]]
name = "ethrex-trie"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"anyhow",
"bytes",
@@ -844,8 +845,8 @@ dependencies = [
[[package]]
name = "ethrex-vm"
-version = "23.0.0"
-source = "git+https://github.com/lambdaclass/ethrex.git?rev=797df5540c7d35cafd69b6971a74b2a49c67d1dd#797df5540c7d35cafd69b6971a74b2a49c67d1dd"
+version = "26.0.0"
+source = "git+https://github.com/lambdaclass/ethrex.git?rev=8effcb0671c5d0b12fe0161ea37c174ec4466b6a#8effcb0671c5d0b12fe0161ea37c174ec4466b6a"
dependencies = [
"bytes",
"derive_more",
@@ -854,7 +855,6 @@ dependencies = [
"ethrex-crypto",
"ethrex-levm",
"ethrex-rlp",
- "rayon",
"rustc-hash",
"serde",
"thiserror 2.0.18",
@@ -1282,18 +1282,18 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libssz"
-version = "0.2.2"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d498c0482bba87d2647ea4601ea76cf2b498065e3958798a88f49274f3ced5e9"
+checksum = "b4bdd6d63ed811ae164966de20810be780e07de784a4834ccfe6be90480c369e"
dependencies = [
"smallvec",
]
[[package]]
name = "libssz-derive"
-version = "0.2.2"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08ddfb5c969c28a4a54043e630f80c723352637bd1020f256ee3ac7a8814922b"
+checksum = "aeee1b9ac9200429f7e9830492765445989ea61c3fb9028ad5a96e1dd5f5e913"
dependencies = [
"proc-macro2",
"quote",
@@ -1302,9 +1302,9 @@ dependencies = [
[[package]]
name = "libssz-merkle"
-version = "0.2.2"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "63c6d6d5ce5d79bba66bc98c99869eedffedf7f14f0aa0915f1a62802650bdf6"
+checksum = "863eca32d1a43e5ec41106a515552efa8307768d37c68d26b7f21ff13cfee1a7"
dependencies = [
"libssz",
"sha2",
@@ -1312,9 +1312,9 @@ dependencies = [
[[package]]
name = "libssz-types"
-version = "0.2.2"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "747273ab2d923e82ed147091fe0fb3e602dd2012c872cdad5efe69e27c3b4099"
+checksum = "d4231ac301726840a3fe111f11bd4619d3c97ed155cb94c88dbf92b70e04e017"
dependencies = [
"libssz",
"libssz-merkle",
diff --git a/tooling/ethrex-tests/Cargo.toml b/tooling/ethrex-tests/Cargo.toml
index f7daff1d4..9912b7dd7 100644
--- a/tooling/ethrex-tests/Cargo.toml
+++ b/tooling/ethrex-tests/Cargo.toml
@@ -3,21 +3,17 @@ name = "ethrex-tests"
version = "0.1.0"
edition = "2024"
-# Detached workspace: ethrex pins rkyv with the `unaligned` feature (a global
-# archived-layout switch), which must never feature-unify with the main
-# workspace's aligned rkyv proof format. See tests/ethrex.rs.
+# Detached workspace: the ethrex host reference has its own dependency graph,
+# separate from the main workspace. See tests/ethrex.rs.
[workspace]
[dev-dependencies]
executor = { path = "../../executor" }
-# Pinned to the SAME ethrex rev as the guest so the native reference reads the same
-# ProgramInput rkyv layout. Re-pin all five manifests together with
+# Pinned to the SAME ethrex rev as the guest so the native reference reads the
+# same SSZ stateless input and output. Re-pin every manifest together with
# scripts/set_ethrex_rev.sh.
-ethrex-guest-program = { git = "https://github.com/lambdaclass/ethrex.git", rev = "797df5540c7d35cafd69b6971a74b2a49c67d1dd", package = "ethrex-guest-program" }
-# Exact pin: the fixture writer and the guest/executor readers must agree on the
-# rkyv layout. Keep this in sync with tooling/ethrex-fixtures and
-# executor/programs/rust/ethrex/Cargo.toml.
-rkyv = { version = "=0.8.16", features = ["std", "unaligned"] }
+ethrex-guest-program = { git = "https://github.com/lambdaclass/ethrex.git", rev = "8effcb0671c5d0b12fe0161ea37c174ec4466b6a", package = "ethrex-guest-program" }
+libssz = "=0.3.0"
# Match the root workspace's optimized dev profile: these tests execute the VM.
[profile.dev]
diff --git a/tooling/ethrex-tests/tests/ethrex.rs b/tooling/ethrex-tests/tests/ethrex.rs
index 4fa53971c..8f1d74eef 100644
--- a/tooling/ethrex-tests/tests/ethrex.rs
+++ b/tooling/ethrex-tests/tests/ethrex.rs
@@ -1,22 +1,19 @@
-//! Host-reference execution tests for the ethrex guest, relocated out of the
-//! `executor` test suite: `ethrex-guest-program` pins rkyv with the
-//! `unaligned` feature (a global archived-layout switch), which would
-//! feature-unify with the main workspace's aligned rkyv and silently change
-//! the proof wire format. This crate is a detached workspace so the two rkyv
-//! configurations never meet.
+//! Host-reference and VM execution tests for the ethrex stateless guest.
//!
//! Fixtures are generated by `tooling/ethrex-fixtures`; the guest ELF comes
//! from `make compile-programs`. See `executor/tests/README.md`.
+use ethrex_guest_program::crypto::NativeCrypto;
+use ethrex_guest_program::l1::run_stateless_guest;
use executor::elf::Elf;
-use executor::vm::execution::Executor;
-use executor::vm::execution::ReturnValues;
+use executor::vm::execution::{Executor, ExecutorError, ReturnValues};
+use std::sync::Arc;
fn run_program_without_expect(
elf_path: &str,
private_inputs: Vec,
-) -> Result {
- println!("Testing {}", elf_path);
+) -> Result {
+ println!("Testing {elf_path}");
let elf_data = std::fs::read(elf_path).unwrap();
let program = Elf::load(&elf_data).unwrap();
println!("Program entry: 0x{:016x}", program.entry_point);
@@ -25,97 +22,50 @@ fn run_program_without_expect(
executor.finish()
}
-fn run_program_and_check_public_output(
- elf_path: &str,
- expected_output: Vec,
- private_inputs: Vec,
-) {
- let result =
- run_program_without_expect(elf_path, private_inputs).expect("Failed to run program");
+fn native_output(inputs: &[u8]) -> Vec {
+ let output = run_stateless_guest(inputs, Arc::new(NativeCrypto));
+ assert_eq!(output.len(), 43, "unexpected stateless output length");
+ assert_eq!(output[32], 1, "native stateless validation failed");
+ output
+}
- assert_eq!(result.memory_values, expected_output);
+fn run_fixture(name: &str) {
+ let inputs = std::fs::read(format!("{FIXTURES_DIR}/{name}")).unwrap();
+ let output = native_output(&inputs);
+ let result = run_program_without_expect(ELF_PATH, inputs).expect("failed to run guest ELF");
+ assert_eq!(result.memory_values, output);
}
const ELF_PATH: &str = "../../executor/program_artifacts/rust/ethrex.elf";
const FIXTURES_DIR: &str = "../../executor/tests";
/// Larger-block smoke test: a synthetic ethrex block with 10 ETH transfers.
-/// (The old `ethrex_hoodi.bin` real-block fixture predated the `Crypto` trait
-/// and no longer deserializes; the current real-block fixture is separate.)
-#[ignore = "heavier synthetic block (10 txs); run in the dedicated --ignored CI step"]
+#[ignore = "heavier synthetic block; run in the dedicated ignored CI step"]
#[test]
fn test_ethrex() {
- use ethrex_guest_program::crypto::NativeCrypto;
- use ethrex_guest_program::l1::{ProgramInput, execution_program};
- use rkyv::rancor::Error;
- use std::fs;
- use std::sync::Arc;
- let inputs = fs::read(format!("{FIXTURES_DIR}/ethrex_10_transfers.bin")).unwrap();
- let input = rkyv::from_bytes::(&inputs).unwrap();
- let output = execution_program(input, Arc::new(NativeCrypto)).unwrap();
- run_program_and_check_public_output(ELF_PATH, output.encode(), inputs);
+ run_fixture("ethrex_10_transfers.bin");
}
-/// Executes a stateless ethrex block containing a single (plain ETH transfer)
-/// transaction. Execution only — no proving — against the ethrex guest ELF
-/// built from the same pinned ethrex revision as the native reference.
#[test]
fn test_ethrex_simple_tx() {
- use ethrex_guest_program::crypto::NativeCrypto;
- use ethrex_guest_program::l1::{ProgramInput, execution_program};
- use rkyv::rancor::Error;
- use std::sync::Arc;
- let inputs = std::fs::read(format!("{FIXTURES_DIR}/ethrex_simple_tx.bin")).unwrap();
- let input = rkyv::from_bytes::(&inputs).unwrap();
- let output = execution_program(input, Arc::new(NativeCrypto)).unwrap();
- run_program_and_check_public_output(ELF_PATH, output.encode(), inputs);
+ run_fixture("ethrex_simple_tx.bin");
}
-/// Executes a stateless ethrex block with NO transactions (empty block).
-/// Execution only — no proving. Pins the committed `ethrex_empty_block.bin`
-/// fixture so its rkyv `ProgramInput` layout (the 0-tx edge case) is
-/// exercised and stays consistent with the guest across ethrex rev bumps.
#[test]
fn test_ethrex_empty_block() {
- use ethrex_guest_program::crypto::NativeCrypto;
- use ethrex_guest_program::l1::{ProgramInput, execution_program};
- use rkyv::rancor::Error;
- use std::sync::Arc;
- let inputs = std::fs::read(format!("{FIXTURES_DIR}/ethrex_empty_block.bin")).unwrap();
- let input = rkyv::from_bytes::(&inputs).unwrap();
- let output = execution_program(input, Arc::new(NativeCrypto)).unwrap();
- run_program_and_check_public_output(ELF_PATH, output.encode(), inputs);
+ run_fixture("ethrex_empty_block.bin");
}
-const REAL_BLOCK_FIXTURE: &str = "ethrex_mainnet_25368371.bin";
+const REAL_BLOCK_FIXTURE: &str = "ethrex_mainnet_25453112.bin";
-/// Host-only acceptance gate for the real-block fixture that
-/// `make ethrex-real-block-fixture` fetches and checksums (produced offline by
-/// `tooling/ethrex-block-converter`): the block is
-/// re-executed statelessly against its own witness, so a successful run means
-/// the recovered tries, codes and headers reproduce the header's post-state
-/// root. Needs no guest ELF, which is what keeps it runnable where the RV64
-/// toolchain isn't available.
-///
-/// Checks the *serialized artifact* specifically — that the published rkyv
-/// bytes deserialize and execute — which is why it reads the `.bin` rather
-/// than converting the cache itself.
-///
-/// It is also, in practice, the check that the block needs no KZG: this crate's
-/// dependency graph links no KZG backend, so a block calling point evaluation
-/// (0x0a) diverges from consensus here and fails. That property is incidental to
-/// the dep graph rather than declared, so `no_kzg_backend_linked` below pins it.
-/// (`tooling/ethrex-block-converter`'s parity test does NOT cover 0x0a — it links
-/// `c-kzg` transitively via `ethrex-config`.)
+/// Host-only acceptance gate for the real SSZ stateless fixture produced by
+/// `tooling/ethrex-fixtures --bin real_block`. The fixture name is pinned here
+/// as well as in the Makefile, so repointing the block needs both edits; a
+/// missed one fails loudly on the missing file rather than proving less.
#[test]
fn test_ethrex_real_block_native() {
- use ethrex_guest_program::crypto::NativeCrypto;
- use ethrex_guest_program::l1::{ProgramInput, execution_program};
- use rkyv::rancor::Error;
- use std::sync::Arc;
let inputs = std::fs::read(format!("{FIXTURES_DIR}/{REAL_BLOCK_FIXTURE}")).unwrap();
- let input = rkyv::from_bytes::(&inputs).unwrap();
- execution_program(input, Arc::new(NativeCrypto)).unwrap();
+ native_output(&inputs);
}
/// Pins the property the test above leans on: this crate's dependency graph must
@@ -136,7 +86,8 @@ fn test_ethrex_real_block_native() {
/// removes the screen.
#[test]
fn no_kzg_backend_linked() {
- use ethrex_guest_program::crypto::{Crypto, NativeCrypto};
+ use ethrex_guest_program::crypto::Crypto;
+
let result = NativeCrypto.verify_kzg_proof(&[0u8; 32], &[0u8; 32], &[0u8; 48], &[0u8; 48]);
let message = match result {
Ok(()) => "verify_kzg_proof accepted zero input".to_string(),
@@ -181,12 +132,5 @@ fn no_bls_backend_linked() {
#[ignore = "real block through the VM; unmeasured runtime, run explicitly on a build server"]
#[test]
fn test_ethrex_real_block_vm() {
- use ethrex_guest_program::crypto::NativeCrypto;
- use ethrex_guest_program::l1::{ProgramInput, execution_program};
- use rkyv::rancor::Error;
- use std::sync::Arc;
- let inputs = std::fs::read(format!("{FIXTURES_DIR}/{REAL_BLOCK_FIXTURE}")).unwrap();
- let input = rkyv::from_bytes::(&inputs).unwrap();
- let output = execution_program(input, Arc::new(NativeCrypto)).unwrap();
- run_program_and_check_public_output(ELF_PATH, output.encode(), inputs);
+ run_fixture(REAL_BLOCK_FIXTURE);
}