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); }