Skip to content

feat(l1): support frames-devnet-0 (tests-frames-devnet@v0.0.0) - #7183

Draft
ilitteri wants to merge 13 commits into
mainfrom
tests-frames-devnet-v0.0.0
Draft

feat(l1): support frames-devnet-0 (tests-frames-devnet@v0.0.0)#7183
ilitteri wants to merge 13 commits into
mainfrom
tests-frames-devnet-v0.0.0

Conversation

@ilitteri

@ilitteri ilitteri commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Motivation

First main-branch test track for the frames stream. Until now the EIP-8141 spec-test work lived only on the frames-devnet-0 dev branch; main carries the frame-transaction implementation (#6326, #7004, #7068, #7073, #7089) but nothing runs the upstream fixtures against it. This PR pins the first released bundle, tests-frames-devnet@v0.0.0, and ports from the dev branch exactly the implementation the released fixtures exercise.

Description

The release refills the whole suite at the Bogota pseudo-fork (Amsterdam + EIP-8141, activated by bogotaTime — ethrex's Fork::Hegota), so the new for_bogota/ subtree re-covers every other Amsterdam EIP with frame transactions active, not just the 8141 tests: 3,137 fixture files carrying 24,916 blockchain test cases (counted as top-level case keys), every one at network: Bogota.

Track wiring, following the Amsterdam overlay's shape:

  • tooling/ef_tests/.fixtures_url_frames pins the release (fixtures_frames-devnet.tar.gz).
  • make frames-vectors overlays for_bogota/ onto vectors/eest, keyed on the release URL like the Amsterdam overlay, fetching through the retrying download.sh. download-test-vectors and test-levm depend on it, so the existing "Run Blockchain EF tests" CI job covers the track with no workflow change — the same way the Amsterdam bundle is covered.
  • Harness: a Bogota fork config (Amsterdam + hegota_time), type-0x06 fixture deserialization (including the "0x"-as-absent encoding of an implicit frame target and an ARBITRARY signature's signer), and mappings for the exception names the release introduces: TYPE_6_INVALID_{FRAME_FORMAT,SIGNATURE}, plus GASPRICE_OVERFLOW/PRIORITY_OVERFLOW, whose fixtures surface at RLP decoding because their 33-byte type-0x06 fees are 2**256 or more — beyond the bound EIP-8141 puts on fees — and so do not fit the U256 fields. (GASLIMIT_PRICE_PRODUCT_OVERFLOW needed no new mapping and no decode tolerance: its name→exception mapping pre-exists on main, and its fixtures' fee values fit U256 — legacy gas_price included — so they decode fine and are rejected at execution, exactly as the same fixtures already pass in main's other fork subtrees.) The two-pass parallel BAL check now runs for Amsterdam and later rather than Amsterdam exactly.
  • Hive: make run-hive-eels-frames{,-rlp,-quick} plus patch-hive-frames-fork, which adds bogotaTime to hive's ethrex genesis mapper in the clone (the mapper stops at Amsterdam upstream; without it every EIP-8141 payload runs pre-fork and fails).

Scope note: the bundle also ships blockchain_tests_engine/, state_tests/, and sync/transaction subtrees; this track overlays the blockchain format only, matching the dev branch's wiring, with the hive frames/frames-quick workflows as the release's designated engine-format vehicle. In-process engine/state overlays (each needs a Bogota mapping in its harness) can follow separately.

Implementation ported from frames-devnet-0, each commit keeping the dev branch's reasoning; everything is reachable only through type-0x06 execution or the Hegota-gated frame path, so no pre-Hegota behavior changes:

  • Frame entry access charge (dev 666703940): EIP-8141 Rationale — the target's cold/warm access cost is charged inside the frame's own gas limit, plus the EIP-7702 delegation access behind it. Warmth lands in the frame's substate backup so a failed frame leaves nothing warm; the EIP-7928 recorder learns of the target read regardless of how the frame ends.
  • BAL reconciliation when a frame reverts on its own (dev b24600017): a reverting non-batch frame restores a recorder checkpoint, mirroring the atomic-batch unroll, so the block access list agrees with the state the block carries (write-then-read re-files as a read).
  • EIP-8037 NEW_ACCOUNT state cost when a frame revives a dead target (dev 8817d7911): matches EELS charge_value_transfer_to_non_alive_account at frame entry, found as a gasUsed chain split against Nethermind.
  • Run a frame's target through the EVM unless it is a codeless VERIFY target (dev d39f4f547): only a codeless VERIFY target takes the protocol default code; a SENDER/DEFAULT frame to a precompile must run it. Also orders the EIP-7702 delegate read after the entry charge is known affordable (eip7702_peek_delegation), so an unaffordable frame files no delegate in the BAL.
  • Frame-tx fee fields widened to U256 (dev 999bb95dd): EIP-8141 bounds the fee fields at 2256 and the release's admission_constraints[max_cost_within_bound] uses the room (a 2232 max_fee_per_gas on a transaction that must be valid); ethrex's u64 fields could not decode it. The two shared accessors widen with the fields; GenericTransaction and the feeHistory reward still narrow, saturating rather than panicking. The L2 fee-arithmetic test narrows at its read site (dev c2161f3f2).
  • Clippy conformance on the entry charge (saturating_add, dev 8ce49c97b) and .gitignore for *.tar.gz.part partial downloads (dev 1d8a5ed58).

Deliberately left on the dev branch (not needed by any released fixture):

  • Post-v0.0.0 EIP revisions (dev 57a44764b, 1e4f60cf5): approval-context restore on frame revert, atomic-batch approval-scope static rejection, the relaxed banned-opcode list, payer warming at APPROVE. The released suite passed on the dev branch before these landed; they follow with the next release that fills them.
  • Mempool-admission work (validation-prefix SLOTNUM ban, post-prefix rules, replacement/eviction, sponsored-VERIFY targeting) and RPC receipt to reporting: invisible to blockchain fixtures.
  • The pre-release local uv fill pipeline and the frames-devnet-0.yaml kurtosis config (depends on the dev-only glamsterdam-devnet-8 base config).

STORE_SCHEMA_VERSION is untouched: nothing here changes a storage codec — the frame-receipt storage encoding and its flush call sites were already on main.

CI housekeeping picked up along the way: grafana.com and leastauthority.com joined lychee.toml's bot-blocked-hosts exclude list. Both serve fine in a browser but 403/time out from CI runners, and they were failing the docs Link Check on pages this PR does not touch (the monitoring docs and the audits index).

Local test run

  • make -C tooling/ef_tests/blockchain test-levm (Bogota overlay included): 18,017 / 18,017, 0 ignored — 14,880 pre-existing + 3,137 for_bogota (isolated -- for_bogota re-run: 3,137 / 3,137, matching the dev branch's count for this release).
  • make -C tooling/ef_tests/blockchain test-stateless: 3,218 / 3,218.
  • make -C tooling/ef_tests/engine test: 11,020 / 11,020 (75,641 fixture cases), 0 failed.
  • cargo test --workspace --exclude 'ethrex-l2*' --exclude ethrex-prover --exclude ethrex-guest-program: 1,870 / 1,870 across 48 suites.
  • cargo clippy --workspace --all-targets -- -D warnings: clean in both workspaces; cargo fmt --all -- --check: clean in both workspaces.

ilitteri and others added 11 commits August 20, 2026 12:49
Two halves: the execution rule ethrex was missing, and the harness needed to
see it fail.

EIP-8141 Rationale: "Cold/warm access costs for the frame's target account are
charged within the frame's own gas_limit through the normal EVM warm/cold
accounting, not through the per-frame cost." ethrex charged nothing and left the
target cold, so a frame read its target for free and every later frame re-paid
the cold price for an account an earlier frame had already touched -- gas the
receipts trie and the header gasUsed both carry. The charge (and the EIP-7702
delegation access that follows the indicator) now comes out of the frame's own
budget, and a frame that cannot afford to be entered forfeits its gas limit
without executing.

Three details the reference implementation pins, none of them obvious from the
EIP text:

- A VERIFY frame whose target has no code runs the protocol default code
  *instead of* an EVM. It builds no gas meter, so it neither pays the entry
  charge nor leaves its target warm. A SENDER or DEFAULT frame to a codeless
  account is not special: it runs an EVM over empty code and pays.
- The target is warmed inside the frame's substate backup, so a failed frame
  contributes no warmth to later frames -- the shared journal absorbs a frame's
  accesses only when it succeeds.
- A frame whose sender cannot fund its value never starts and spends nothing,
  where ethrex charged the full frame gas limit.

Entering a frame reads the target account, so the EIP-7928 recorder is told
about it too, before the frame runs and regardless of how it ends.

Harness side, so `tests/amsterdam/eip8141_frame_transactions` from
ethereum/execution-specs#3047 can run: a `Bogota` fork (Amsterdam +
hegota_time, the pseudo-fork EELS fills these fixtures with), type-0x06
fixture deserialization including the `"0x"`-as-absent encoding of an implicit
frame target and an ARBITRARY signature's signer, and the two
TYPE_6_INVALID_{FRAME_FORMAT,SIGNATURE} exception names, whose structural
rules ethrex enforces in the decoder. The two-pass parallel BAL check now runs
for Amsterdam and later rather than Amsterdam exactly.

32 of the 36 frame-transaction fixtures pass; the 4 that remain are BAL
mismatches on frames that revert or halt.
Cherry-picked from the hegota-devnet branch, where it was found by a devnet
builder that stopped producing blocks.

An EIP-8141 frame that reverts outside an atomic batch rolls back its state
changes, its state gas and its logs, but left the EIP-7928 recorder holding the
writes it had already recorded. The builder therefore produced a block whose
access list disagreed with the state the block contains, and the block failed
its own BAL validation on re-execution.

Take a recorder checkpoint at frame entry and restore it whenever the frame
fails, mirroring what the atomic-batch unroll already does. `restore` re-files a
freshly-written slot as a read and leaves `touched_addresses` alone, so every
access the frame made is still reported and only the reverted changes go.

The write-then-read case is why the slot could vanish from the access list
altogether rather than merely being over-reported: `record_storage_read`
suppresses a read for a slot that is already written, so dropping the write
without re-filing it as a read left the slot in neither storage_changes nor
storage_reads.

This is the last of the frame-transaction spec-test failures: all 36 fixture
files from ethereum/execution-specs#3047 (415 filled cases) now pass.

[ported to main: the regression test passes `None` for the stateless_validator
parameter VM::new gained on main, the one-line adjustment frames-devnet-0 made
in db75e42 when it merged main]
The frame entry charge used `+`, which levm's arithmetic_side_effects lint
rejects; both operands are bounded by the frame's gas limit by construction, so
`saturating_add` states that without changing behaviour.

[ported to main from frames-devnet-0 commit 8ce49c9; that commit also fixed
two identity_op sites in the eth/72 cell-request path, which exists only on the
devnet branch and stays there]
…d target

Found by the ethrex + Nethermind devnet, not by a fixture: the first frame
transaction that paid a fresh address split the chain on the header `gasUsed`,
ethrex reporting 22_910 against Nethermind's 119_766.

EELS charges `charge_value_transfer_to_non_alive_account` inside
`create_evm_from_frame`, so a frame whose value transfer revives an account that
is not alive pays the NEW_ACCOUNT state cost at entry, out of its own gas limit
and before it runs. ethrex charged nothing, so it billed the frame a bare cold
access (3_000) where Nethermind billed the whole 100_000 gas limit -- the charge
is larger than the limit that frame declared, so on their side the frame never
ran at all.

The frame's state-gas reservoir starts empty, so there is nothing to draw the
charge from and it spills into the frame's execution gas in full. It also lands
in the state dimension, after the per-frame baseline, so a frame that fails
rolls it back and contributes none of it -- matching a frame that never created
the account.

No spec-test fixture covers this: every `tests/amsterdam/eip8141_frame_transactions`
case that moves value moves it to an account the `pre` state already funds. All
14_900 blockchain fixtures still pass.

The two existing tests that transferred to a code-less EOA were written before
the charge existed and targeted an unseeded address, which is exactly the case
that now costs more than their 50_000-gas frames could pay. They are about the
default-code path, so their recipient is now seeded alive, and the revival case
gets its own test covering both arms: unaffordable (frame forfeits its limit,
account stays dead) and affordable (frame succeeds, billed cold access plus the
state charge).
…arget

Two consensus bugs, both from the same over-broad branch, both caught by
the frame-transaction fixtures added to execution-specs#3047 after this
branch was cut.

EIP-8141 sends every frame except one through a top-level call: only a
VERIFY frame whose resolved target has no code runs the protocol default
code instead. ethrex took the default-code path for *any* codeless target,
which is indistinguishable for a plain EOA -- empty code returns success
having spent nothing -- but wrong for a precompile, whose dispatch happens
inside that call. A SENDER or DEFAULT frame targeting one reported success
with `gas_used` of zero and no output, where the precompile should have run
and been billed. Fixed by branching on the frame's mode and the target's own
code rather than on emptiness alone.

The second is the order of the entry charges. The delegate of an EIP-7702
target was read before the frame was known to afford that access, so a frame
whose gas could not cover it still filed the delegate in the EIP-7928 access
list. The receipts cannot contradict that -- an unaffordable designation and
a failure inside the delegate's code both forfeit the whole frame gas limit
-- so the access list is the only place it shows. `eip7702_peek_delegation`
already exists for exactly this ordering and is what the CALL-family handlers
use; the frame path now uses it too, and resolves the delegate only once the
charge is paid.

Repins `.frames_spec_rev` to cd654d9a, which carries the target-resolution
and block-access-list cases that caught both. All 44 frame fixtures pass,
and 14908 blockchain fixtures overall.

[ported to main: the .frames_spec_rev repin is dropped -- the local-fill
pipeline never lands on main, since tests-frames-devnet@v0.0.0 ships these
fixtures as a release and a later commit pins that instead. The docs rewrite of
the frame-warmth section (originally from frames-devnet-0 commit 5b401e9)
comes along here because it describes the entry-charge behaviour this commit
and its predecessor implement; the old text documented the pre-charge world]
The Makefile fetches the Amsterdam bundle to `$(ARTIFACT).part` first so a
failed download cannot truncate the previous one, but `*tests*.tar.gz` does
not match the `.part` suffix, so a 698 MB partial was committable -- and was
committed, then removed a commit later. GitHub rejects any pushed blob over
100 MB whether or not the tip still references it.
…uite

The `tests-frames-devnet@v0.0.0` release refills the whole suite at the
`Bogota` pseudo-fork, so it re-covers every Amsterdam EIP with frame
transactions active, not only the EIP-8141 tests. 3137 blockchain fixtures,
of which two failed.

EIP-8141 bounds a frame transaction's fee fields at 2**256, and one fixture
uses that room: `admission_constraints[max_cost_within_bound]` names a
`max_fee_per_gas` of 2**232 and expects the transaction to be *valid*, since
`max_cost` still fits. ethrex held both fee fields in `u64`, so it could not
decode the transaction at all and rejected the block another client accepts.
The fields are now `U256`, and the two shared accessors widen with them; most
frame call sites were already wrapping them in `U256::from`, so they lose the
wrapper rather than gain one. `GenericTransaction` and the `feeHistory` reward
still narrow, and saturate rather than call `U256::as_u64`, which panics: both
report a fee a payer must be able to cover, so a value that large cannot
change either answer.

The other failure was the harness: three overflow exception names the release
introduces (`GASPRICE_OVERFLOW`, `PRIORITY_OVERFLOW`, and for a frame
transaction `GASLIMIT_PRICE_PRODUCT_OVERFLOW`) surface at RLP decoding rather
than at validation, because the operands themselves exceed `u64`. The last of
those is already mapped for other transaction types as a validation-time
error, so both surfaces are accepted.

Pipeline: `.fixtures_url_frames` pins the release and `make frames-vectors`
overlays it, keyed on the URL like the Amsterdam overlay, replacing the local
`uv run fill` of an unreleased revision. `make run-hive-eels-frames{,-rlp,-quick}`
run it under hive.

Hive needs one patch to be useful: its ethrex client maps
`HIVE_<FORK>_TIMESTAMP` onto genesis fields and stops at Amsterdam, so the
Bogota timestamp EEST sets reaches the client as nothing, frame transactions
stay pre-fork, and every EIP-8141 test fails while the rest of the Bogota
suite passes -- 24785 passed against 159 failed on the public dashboard, the
159 being exactly the EIP-8141 payload count. `patch-hive-frames-fork` adds
`bogotaTime` to the mapper in the clone, the same way run-hive-build-block
already patches `ethrex.sh`. The one-line fix belongs upstream in
ethereum/hive.

3137/3137 Bogota fixtures, 18001 blockchain fixtures overall, full workspace
suite, clippy -D warnings and fmt clean in both workspaces.

[ported to main: the frames overlay fetches through .github/scripts/download.sh
like the Amsterdam overlay now does, instead of the raw curl-to-.part this
commit carried before main gained the retrying downloader; the RPC-receipt and
mempool replacement/eviction test hunks widened tests that exist only on
frames-devnet-0 and stay there with the mempool rules they cover]
Widening a frame transaction's fee fields to U256 widened the shared accessors
with them, which broke the workspace under --features l2: the L2 fee-settlement
integration test does its arithmetic in u64. Narrow at the read site, where the
assumption that an L2 EIP-1559 transaction the test signs itself fits in u64 is
local and checked, rather than widening the arithmetic.
grafana.com answers 403 to non-browser agents and leastauthority.com times
out from CI runners; both serve fine in a browser. They failed the Link
Check job on pages this branch does not touch (the monitoring docs and the
audits index), so add them to the existing bot-blocked-hosts exclude list.
Widening a frame transaction's fee fields to U256 left harness comments
attributing every fee-overflow rejection to `u64` fields: the type-0x06
overflow fixtures actually carry 33-byte values that fail the `U256` decode
(EIP-8141 bounds fees below 2**256), while every other type still bounds
fees at `u64` (the gas_limit * price fixtures carry a 31-byte price). Also
stop claiming the type-0x06 decoder enforces every structural frame rule —
most such fixtures reject only when the frame executes.
The fee-overflow comment in the blockchain test runner said the oversized
`gas_limit * price` fixtures fail at RLP decoding. Only the legacy-tx one
does (31-byte price, alone beyond `u64`); the frame-tx product-overflow
fixture carries a 2^255 fee that fits `U256`, decodes fine, and is caught
at validation without consulting the decode-tolerance arm — the same
decode-vs-validation split as the high-nonce fixtures.

The EIP-8141 doc's section 5 credited the `warmth/` fixtures with pinning
the unfunded-value rule ("never starts, and spends nothing"). No released
fixture covers that case — the release's affordability fixtures exercise
the entry charge, whose outcome is the opposite (full gas-limit forfeit) —
so cite the reference implementation, which reports gas_used = 0 before it
ever builds a gas meter, and scope the pinned-by sentence to the charge and
warmth-isolation rules.
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown

⚠️ Known Issues — intentionally skipped tests

Source: docs/known_issues.md

rpc-compat log-bearing cases excluded

Where: KNOWN_EXCLUDED_TESTS in .github/scripts/check-hive-results.sh counts out
eight hive rpc-compat cases — the four eth_getLogs cases, eth_getBlockReceipts/get-block-receipts-latest,
and three eth_getTransactionReceipt cases. They are exactly the cases whose recorded
response contains at least one log object; every case with an empty log array still runs.
Note this leaves eth_getLogs with no rpc-compat coverage at all, since all four of its
cases are in the set.

Why: ethrex populates blockTimestamp on log objects, as geth, besu, nethermind, reth
and erigon all do. hive's rpc-compat compares responses byte-exactly (jsondiff.FullMatch;
the lenient checkJSONStructure path applies only to cases upstream marks speconly), and
the corpus is pinned to execution-apis d08382ae (2025-02-10), whose recordings predate the
field — it entered the schema in execution-apis#639 and the fixtures in #846 (2026-07-22).
So the extra key cannot match, and this is a property of the pin rather than of the response.

The pin cannot move, and this is not temporary. The pin sits one commit before
execution-apis#627, which moved the test chain to a pre-merge genesis: the current corpus has
~36 proof-of-work blocks before its terminal total difficulty. ethrex does not support
pre-merge chains and will not, so importing that chain.rlp fails at block 1 —
validate_block_header has no pre-London base-fee path. Every revision carrying
blockTimestamp in its fixtures also carries that chain, so there is no revision that
satisfies both. Nor can the corpus be patched locally: rpc-compat's Dockerfile clones
ethereum/execution-apis by hard-coded URL, so the branch buildarg cannot point at a fork.

Coverage: the field itself is pinned by
block_timestamp_is_on_the_log_and_not_on_the_receipt in
crates/networking/rpc/types/receipt.rs, which asserts it is present on each log and absent
from the receipt level.

Removal: delete the entries if ethrex ever gains pre-merge chain import, or if upstream
marks these cases speconly so they are type-checked instead of compared byte-for-byte.


The stateless schema id does not identify the encoding

Where: STATELESS_INPUT_SCHEMA_ID in crates/common/types/stateless_ssz.rs.

Upstream keeps the stateless input schema id at 0x1501
(fork_index 0x15 << 8 | revision 0x01) across incompatible body changes. Three
encodings have now shipped under it: tests-zkevm@v0.6.2, then #3248 + #3278,
then #3356, which moved state, codes and public_keys from SszList to
ProgressiveList. ethrex speaks the last one.

The consequence is that the 2-byte prefix cannot be used to detect a stale or
mismatched bundle. A wrong-dialect input is accepted by the id check and then
fails later — in SSZ decode, or on a root that does not match — rather than being
rejected up front for what it is. only_amsterdam_schema_id_decodes therefore
proves less than its name suggests.

Worth raising upstream: a revision field that does not move across a body change
provides no version negotiation at all.


ZisK guest program hash changes with the unsync_cell gate

Where: crates/common/types/block.rs, transaction.rs.

The gate on the single-threaded unsync_cell::OnceCell moved from
all(feature = "eip-8025", target_arch = "riscv64") to
all(feature = "zisk", target_arch = "riscv64") when the eip-8025 feature was removed.

The guest ELFs were previously built --features "<zkvm>-build-elf,ci", which never enabled
eip-8025, so they compiled the atomic once_cell variant. bin/zisk/Cargo.toml does enable
ethrex-common/zisk, so the ZisK guest now compiles the unsafe impl Sync cell instead.
That changes the ELF bytes and therefore the program hash and verification key.

This is intended (the guest is single-threaded, so the unsync cell is sound and cheaper), but it
is a VK change rather than a no-op refactor, and the diffstat presents it as a file rename
(eip8025_cell.rsunsync_cell.rs). Anyone pinning a ZisK VK across this change must
re-register it. The stateless-validator crate now forwards ethrex-common/zisk from its own
zisk feature so the two ZisK guests do not disagree on the cell type.


Release signing key is an unprotected repository secret

Where: .github/workflows/tag_release.yaml.

MINISIGN_SECRET_KEY is a plain repository secret. There is no environment: on
finalize-release or dry-run-release-assets, and gh api repos/lambdaclass/ethrex/rulesets
shows only branch-targeted rulesets, so the github.ref_type == 'tag' condition is a workflow
check rather than an enforced boundary: anyone who can push a tag can reach the signing key.

This is a repository-settings change, not a code change, so it is recorded here rather than
fixed in the tree. Recommended:

  1. Move MINISIGN_SECRET_KEY / MINISIGN_PASSWORD into a GitHub Environment with required
    reviewers, and add environment: to the two jobs that sign.
  2. Add a ruleset targeting refs/tags/v* restricting who may create release tags.

Until then, the compromise of that key is silent and durable: signatures would still verify
against the committed .github/minisign.pub.

@github-actions github-actions Bot added the L1 Ethereum client label Aug 21, 2026
@github-actions

Copy link
Copy Markdown

Lines of code report

Total lines added: 73
Total lines removed: 0
Total lines changed: 73

Detailed view
+-------------------------------------------------------+-------+------+
| File                                                  | Lines | Diff |
+-------------------------------------------------------+-------+------+
| ethrex/crates/common/types/transaction.rs             | 5071  | +2   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/eth/fee_market.rs        | 239   | +4   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/vm/backends/levm/mod.rs                 | 3796  | +1   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/opcode_handlers/frame_tx.rs | 507   | +2   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/vm.rs                       | 2247  | +64  |
+-------------------------------------------------------+-------+------+

@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

Review Summary

This PR implements EIP-8141 (Frame Transactions) support, introducing a new transaction type with U256-bounded fee fields and complex multi-frame execution semantics. The implementation is thorough and well-tested, with careful attention to gas accounting, state isolation, and EIP-7928 Block Access List (BAL) correctness.

Critical Issues

1. Potential overflow in gas price calculation

  • File: crates/vm/backends/levm/mod.rs, line ~3817
  • Issue: Ok(min(max_priority_fee + fee_per_gas, max_fee_per_gas)) can overflow if max_priority_fee is close to U256::MAX (which EIP-8141 permits).
  • Fix: Use saturating_add:
    Ok(min(max_priority_fee.saturating_add(U256::from(fee_per_gas)), max_fee_per_gas))

Security & Correctness

2. Frame entry gas accounting
The implementation correctly handles the complex frame entry semantics:

  • Cold/warm access charges for frame targets (EIP-8141 Rationale)
  • EIP-7702 delegation access costs
  • EIP-8037 NEW_ACCOUNT charges for value transfers to dead addresses
  • Forfeiture of full gas limit when entry is unaffordable

3. BAL (Block Access List) isolation
Item 2 in crates/vm/levm/src/vm.rs correctly implements checkpoint/restore for the BAL recorder when frames revert, preventing reverted frames from polluting the access list with writes that didn't persist. This is critical for block validation correctness.

4. Substate isolation
The PR properly pushes substate backups before frame execution and manages commit/revert logic correctly, ensuring failed frames don't leak warmth or state changes.

Code Quality

5. Type safety
The widening of max_fee_per_gas() and max_priority_fee() to Option<U256> is correctly propagated throughout the codebase, with appropriate conversions at system boundaries (RPC, tests).

6. Test coverage
Excellent test coverage including:

  • Frame entry charge affordability (sender_frame_reviving_a_dead_target_pays_new_account)
  • BAL reconciliation for reverted frames (reverted_frame_refiles_its_writes_as_reads_in_the_bal)
  • Atomic batch semantics

Minor Suggestions

7. Documentation
The docs/eip-8141.md file is comprehensive. Consider adding a note about the overflow risk in calculate_gas_price_for_tx if saturating arithmetic is used.

8. Hive patch workaround
The patch-hive-frames-fork target in the Makefile is a reasonable temporary measure until Hive upstream adds Bogota fork support, but should be tracked in an issue for removal.

Verdict

Approve with minor fix required. Address the saturating addition in calculate_gas_price_for_tx to prevent potential overflow with maximum U256 fee values. The rest of the implementation is production-ready and demonstrates careful attention to Ethereum protocol semantics.


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Review: PR 7183 — frames-devnet-0 (tests-frames-devnet@v0.0.0)

This is a large, well-documented spec-conformance PR porting frame-transaction implementation pieces from a dev branch to satisfy the first released EIP-8141 fixture bundle. Overall the engineering quality is high: comments consistently explain why (spec citations, EELS cross-references, prior devnet divergences found against Nethermind), and the changes are backed by extensive new unit tests plus claimed 18,017/18,017 EF fixture passes.

Correctness

crates/vm/levm/src/vm.rs — frame entry access charge / EIP-8037 NEW_ACCOUNT / BAL reconciliation (lines ~1798–2160)

This is the substantive logic change. I traced the ordering carefully:

  • push_backup() now happens before target/delegate warming, so a reverted/unaffordable frame correctly loses all warmth it picked up (revert_backup() undoes add_accessed_address calls made after the backup point).
  • The BAL checkpoint (frame_bal_checkpoint) is taken after record_touched_address(target) (and after the delegate's touch, when affordable), so on failure the "read happened" record survives while storage writes made during actual execution are rolled back via recorder.restore(checkpoint). This matches the documented intent and is exercised by the new reverted_frame_refiles_its_writes_as_reads_in_the_bal test.
  • state_gas_used_at_frame_entry is captured after computing entry_state_gas but the increment is applied conditionally before the baseline snapshot in source order — I confirmed the actual sequencing (baseline capture happens right after the conditional increment site, guarded by the same !frame_entry_unaffordable/!value_transfer_reverted conditions) does produce a correct rollback on frame failure. This is dense code; worth having a second engineer independently re-trace given how easy it'd be to get the ordering subtly wrong in a rebase.

I did not find a logic bug here, but flag it as the highest-risk section to re-verify manually since it reimplements gas/warmth/state-dimension accounting across several interacting conditions (runs_default_verify_code, value_transfer_reverted, frame_entry_unaffordable, delegation presence).

Fee-field widening (u64U256) across transaction.rs, fee_market.rs, levm/mod.rs, frame_tx.rs

Consistently applied. I grepped every call site of max_fee_per_gas()/max_priority_fee() across the workspace and confirmed each caller either stays in U256 or explicitly narrows with u64::try_from(..).unwrap_or(u64::MAX)/.unwrap() (test-only) — no silent truncating as u64 casts were introduced. The narrowing choices are all in non-consensus paths (RPC GenericTransaction, feeHistory reward, L2 test helper), so a saturated/panicking narrowing can't affect block validation.

tooling/ef_tests/blockchain/test_runner.rs: test.network == Fork::Amsterdamtest.network >= Fork::Amsterdam relies on Fork's derived Ord matching declaration order; Bogota is declared immediately after Amsterdam as the last variant, so this is correct and I confirmed it's the only such comparison in the file (no other == Amsterdam gate was missed).

One inconsistency found (reported via findings): in tooling/ef_tests/blockchain/types.rs, the new From<Transaction> for FrameTransaction impl saturates chain_id, nonce, and frame gas_limit (per its own comment, "the scalar fields saturate rather than panic"), but Frame::mode, Frame::flags, and FrameSignature::scheme still use a bare .try_into().unwrap(). If a released fixture ever carries an out-of-u8-range value for one of these through the JSON Transaction conversion path (rather than the raw-RLP path already handled by exception_in_rlp_decoding), the test binary panics instead of failing one case. Low production risk (test-only code, and today's fixture set apparently doesn't hit it), but worth either saturating these too or tightening the comment's claim.

Security / consensus

  • No changes touch trie/RLP wire-format consensus rules directly except the FrameTransaction field width change, which is bounds-checked at decode (mentioned in PR body: overflow values are rejected during RLP decoding, and the new deserialize.rs/types.rs exception mappings correctly route GASPRICE_OVERFLOW/PRIORITY_OVERFLOW/GASLIMIT_PRICE_PRODUCT_OVERFLOW to fail at decode rather than validation).
  • deserialize_empty_as_none_address handles decode errors via serde::de::Error::custom rather than panicking, and validates address byte length — good defensive parsing of external fixture data.
  • Fork::Bogota config (hegota_time: Some(0)) is additive and only affects the ef_tests harness, not production fork-config code.

Minor/style

  • Makefile's patch-hive-frames-fork target does git checkout then sed, with a grep -q ... || { echo ...; exit 1; } guard against silent no-op patches — good defensive scripting, mirrors the existing run-hive-build-block pattern as claimed.
  • Reused eip7702_peek_delegation/eip7702_get_code split (peek without warming, then warm only if affordable) rather than duplicating delegation-resolution logic — good reuse, matches the existing CALL-opcode pattern per its doc comment.
  • frame_gas_after_entry is computed unconditionally even along the frame_entry_unaffordable early-return path where it's unused — harmless, negligible.

Non-code

  • docs/eip-8141.md updates read as accurate corrections against the newly-implemented behavior (notably self-correcting a prior claim that tx.sender was cold, which it isn't).
  • lychee.toml bot-host exclusions are a reasonable, scoped CI fix unrelated to the main change.

Overall: solid, well-reasoned port with strong test backing. The one concrete, fixable issue is the mode/flags/scheme .unwrap() inconsistency in the fixture-conversion code.


Automated review by Claude (Anthropic) · sonnet · custom prompt

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown

Benchmark Results Comparison

No significant difference was registered for any benchmark run.

Detailed Results

Benchmark Results: BubbleSort

Command Mean [s] Min [s] Max [s] Relative
main_revm_BubbleSort 2.178 ± 0.076 2.119 2.355 1.00
main_levm_BubbleSort 2.258 ± 0.053 2.170 2.338 1.04 ± 0.04
pr_revm_BubbleSort 2.301 ± 0.041 2.249 2.380 1.06 ± 0.04
pr_levm_BubbleSort 2.258 ± 0.038 2.190 2.312 1.04 ± 0.04

Benchmark Results: ERC20Approval

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Approval 745.8 ± 11.5 729.5 767.0 1.02 ± 0.02
main_levm_ERC20Approval 806.5 ± 15.1 788.9 836.2 1.10 ± 0.03
pr_revm_ERC20Approval 734.2 ± 9.7 715.9 745.0 1.00
pr_levm_ERC20Approval 815.5 ± 26.2 789.2 866.6 1.11 ± 0.04

Benchmark Results: ERC20Mint

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Mint 101.6 ± 2.7 98.3 106.5 1.00
main_levm_ERC20Mint 139.4 ± 1.5 136.6 141.5 1.37 ± 0.04
pr_revm_ERC20Mint 106.3 ± 1.4 104.6 108.2 1.05 ± 0.03
pr_levm_ERC20Mint 143.8 ± 2.1 141.1 147.6 1.42 ± 0.04

Benchmark Results: ERC20Transfer

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Transfer 181.3 ± 10.1 171.8 197.7 1.00 ± 0.07
main_levm_ERC20Transfer 210.8 ± 6.2 201.6 221.6 1.16 ± 0.06
pr_revm_ERC20Transfer 181.3 ± 6.7 171.4 194.1 1.00
pr_levm_ERC20Transfer 217.7 ± 7.3 208.9 236.5 1.20 ± 0.06

Benchmark Results: Factorial

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Factorial 176.7 ± 2.9 172.8 182.4 1.03 ± 0.02
main_levm_Factorial 196.4 ± 4.1 188.4 202.3 1.14 ± 0.03
pr_revm_Factorial 172.2 ± 1.7 170.1 174.0 1.00
pr_levm_Factorial 199.7 ± 8.4 192.4 217.2 1.16 ± 0.05

Benchmark Results: FactorialRecursive

Command Mean [s] Min [s] Max [s] Relative
main_revm_FactorialRecursive 1.302 ± 0.021 1.267 1.339 1.00 ± 0.02
main_levm_FactorialRecursive 6.153 ± 0.089 5.983 6.265 4.73 ± 0.11
pr_revm_FactorialRecursive 1.300 ± 0.022 1.258 1.329 1.00
pr_levm_FactorialRecursive 6.238 ± 0.093 6.071 6.341 4.80 ± 0.11

Benchmark Results: Fibonacci

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Fibonacci 150.5 ± 5.5 143.0 163.5 1.01 ± 0.04
main_levm_Fibonacci 174.6 ± 7.2 162.6 191.6 1.18 ± 0.05
pr_revm_Fibonacci 148.5 ± 3.0 142.7 151.6 1.00
pr_levm_Fibonacci 166.9 ± 3.8 160.0 172.7 1.12 ± 0.03

Benchmark Results: FibonacciRecursive

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_FibonacciRecursive 690.3 ± 16.9 666.6 715.0 1.29 ± 0.04
main_levm_FibonacciRecursive 534.0 ± 10.6 511.7 549.1 1.00
pr_revm_FibonacciRecursive 713.1 ± 16.0 684.5 734.6 1.34 ± 0.04
pr_levm_FibonacciRecursive 545.1 ± 5.1 537.3 553.5 1.02 ± 0.02

Benchmark Results: ManyHashes

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ManyHashes 6.6 ± 0.4 6.2 7.3 1.04 ± 0.07
main_levm_ManyHashes 7.9 ± 0.3 7.4 8.3 1.25 ± 0.06
pr_revm_ManyHashes 6.3 ± 0.2 6.1 6.6 1.00
pr_levm_ManyHashes 7.7 ± 0.3 7.3 8.5 1.22 ± 0.06

Benchmark Results: MstoreBench

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_MstoreBench 215.6 ± 12.2 208.1 249.8 1.42 ± 0.08
main_levm_MstoreBench 152.1 ± 3.0 149.0 159.7 1.00 ± 0.02
pr_revm_MstoreBench 204.6 ± 2.1 201.2 209.1 1.35 ± 0.02
pr_levm_MstoreBench 151.4 ± 0.9 150.4 153.0 1.00

Benchmark Results: Push

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Push 231.0 ± 4.2 223.6 238.0 1.30 ± 0.03
main_levm_Push 177.4 ± 2.9 172.9 183.3 1.00
pr_revm_Push 226.8 ± 4.5 219.9 234.2 1.28 ± 0.03
pr_levm_Push 178.1 ± 7.0 171.9 191.7 1.00 ± 0.04

Benchmark Results: SstoreBench_no_opt

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_SstoreBench_no_opt 118.4 ± 1.6 115.8 120.5 1.31 ± 0.02
main_levm_SstoreBench_no_opt 91.4 ± 1.6 88.5 93.9 1.01 ± 0.02
pr_revm_SstoreBench_no_opt 121.8 ± 11.0 115.5 152.6 1.35 ± 0.12
pr_levm_SstoreBench_no_opt 90.4 ± 1.1 88.5 92.4 1.00

The blockchain runner tolerated a decode failure for fixtures expecting
GASLIMIT_PRICE_PRODUCT_OVERFLOW on the claim that their 31-byte legacy
gasPrice does not fit a u64 field. The claim was wrong: LegacyTransaction's
gas_price is U256, so the fixture decodes fine and is rejected at
execution — the identical fixture already passes exactly that way in the
for_amsterdam/for_cancun/for_osaka/for_prague subtrees, none of which has
a tolerance for it. The disjunct was dead code for every current fixture
and, worse, would have silently absorbed a future decode regression for
the five copies of the fixture across five fork subtrees where main today
fails loudly.

expects_fee_overflow now accepts only FeeOverflow, i.e. GASPRICE_OVERFLOW
and PRIORITY_OVERFLOW, whose 33-byte (2^256 or more) type-0x06 fees
genuinely cannot decode into the U256 fee fields. The runner comment and
the FeeOverflow doc now state the true split. Suite unchanged by the
removal: 18,017 passed / 0 failed / 0 ignored.

@MegaRedHand MegaRedHand left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's review the change to U256 for max_fee_per_gas

@github-project-automation github-project-automation Bot moved this to In Progress in ethrex_l1 Aug 21, 2026
@ilitteri
ilitteri marked this pull request as draft August 21, 2026 19:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L1 Ethereum client

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

2 participants