Skip to content

feat(l1): support frames-devnet-0 (tests-frames-devnet@v0.1.0) - #7223

Draft
ilitteri wants to merge 21 commits into
mainfrom
tests-frames-devnet-v0.1.0
Draft

feat(l1): support frames-devnet-0 (tests-frames-devnet@v0.1.0)#7223
ilitteri wants to merge 21 commits into
mainfrom
tests-frames-devnet-v0.1.0

Conversation

@ilitteri

@ilitteri ilitteri commented Aug 26, 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 frames-devnet bundle, adds the overlay and harness wiring that runs it, and brings the implementation up to the EIP-8141 revision that release fills against.

Description

The bundle 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,168 fixture files carrying 25,017 blockchain test cases (counted as top-level case keys), every one at network: Bogota, of which 71 files are the EIP-8141 subtree.

Most of this PR is the EIP-8141 revision itself. tests-frames-devnet@v0.1.0 fills against a spec in which a frame declares a gas budget per gas dimension of EIP-8037 rather than one scalar, and that reaches the wire format — so none of it is optional. Before this change ethrex could not decode a type-0x06 transaction from the release at all: every one of the 71 EIP-8141 fixture files failed, along with the one non-8141 file whose cases parametrise over the fork's transaction types. No branch implemented the revision, frames-devnet-0 included, so it is derived here from the reference implementation at the release tag, the EIP text at the revision the fixtures themselves name in _info.reference-spec-version (3ceef8d37, uniform across all 214 EIP-8141 cases), and the shipped fixtures.

One rule of the next EIP revision is deliberately not adopted. 7d1c8bfb9, three days after the one the fixtures name, counts a frame transaction's block execution gas before its EIP-3529 storage refund, per EIP-7778, while the payer keeps paying the post-refund figure. The pinned release requires the older rule and fixes it in a header — gas_settlement/storage_refund_settlement.json clears a storage slot and pins the reduced counter — so adopting the newer one here would turn that fixture red, and the release's own reference implementation documents the post-refund form too. It is recorded under Known Limitations in docs/eip-8141.md, with what adopting it will take: the frame-tx execution report separating its block-facing figure from its payer-facing one, which the regular transaction path already does.

Encoding. A frame's fourth item becomes the nested list [execution, state], and the transaction's three flat fee fields collapse into one nested fees list — in place, at the position they already occupied between signatures and blob_versioned_hashes, taking the payload from nine top-level items to seven. Both move the transaction hash and the EIP-8141 signature hash, so the nesting lives in one place that the encoder and the signature hash share rather than being spelled twice. A frame receipt's gas_used becomes [execution, state] too, and the RPC receipt reports the second dimension as stateGasUsed.

Intrinsic cost. TX_FRAME_INTRINSIC drops from 15,000 to 12,000 — equal to TX_BASE, because a frame transaction prices signature recovery per signature entry, so the recovery component of the base cost instead covers payer settlement, which regular transactions do not price separately. The intrinsic gains TX_VALUE_COST (6,000) per value-bearing frame whose explicit target is not the sender, covering the recipient balance write and the transfer log; a frame paying its own sender moves no funds between accounts and is not charged. Both anchor the calldata floor as well as the execution cost.

Derived anchors. standard_gas_limit spans both dimensions, and max_gas compares it against the calldata floor plus the state budgets, because the floor binds the execution dimension alone — a floor-bound frame transaction pays the floor plus its state gas in full, unlike the single-dimension flow where the floor caps the whole transaction. EIP-7825's per-transaction cap likewise bounds the execution dimension alone. Block capacity is reserved exactly per dimension: a frame transaction names its state budgets explicitly, so it reserves max(intrinsic + Σ execution, calldata_floor) against the block's execution dimension and Σ state against its state dimension, rather than charging its whole limit against both as the EIP-8037 reservoir model does for transaction types that cannot name a state budget of their own.

Metering. Each frame runs against a state-gas pool seeded from its declared budget. The dimensions are independent, so a charge past the pool is an out-of-gas halt rather than a spill into execution gas, and leftover state gas does not fund execution; a frame transaction therefore carries no reservoir at all. Because an EIP-8037 storage refill may reverse a charge an earlier frame paid, the owner of each outstanding charge is tracked and a cross-frame refill lowers the owner's receipt in place — returning the gas to the payer at settlement without granting the executing frame budget it never declared. Frame entry, and atomic-batch entry, snapshot the receipts, the approval fields, the pool and the ownership map; a batch unroll re-appends the executed frames' receipts with their state gas zeroed and logs dropped, since the state those charges paid for is exactly what the unroll discards.

Semantics the revision changes. The resolved target's warm or cold access is charged at frame entry for every frame, codeless VERIFY included, and priced before the target is warmed — so a frame that cannot afford its entry never reads its target, and nothing about that target reaches the block access list. A revival's EIP-8037 NEW_ACCOUNT cost is charged to the state dimension. A frame targeting an active precompile dispatches it in every mode, VERIFY included. An APPROVE refusal reverts the current call frame instead of halting exceptionally, with the memory expansion charged before the guards, following RETURN semantics for the designated return-data region. Approval scope is statically rejected on every frame of an atomic batch, and an expiry verifier frame must declare no state gas.

Opcodes. SIGDATACOPY (0xB5) takes over the signature-bytes copy that SIGPARAM selector 0x04 used to perform, which makes SIGPARAM static-arity over selectors 0x000x03 — and 0x03 now halts for a protocol-validated entry, whose raw bytes are not introspectable and so have no length to report. FRAMEPARAM gains 0x09 (state gas limit), 0x0A and 0x0B (the frame's receipt usage per dimension), and TXPARAM gains 0x0C (the executing frame's remaining state gas).

The validation-prefix budget splits with the dimensions: the prefix's execution budgets plus the signature cost against MAX_VERIFY_GAS (100,000), its state budgets against the separate and much larger MAX_VERIFY_STATE_GAS (500,000). State gas does not measure a node's simulation work, so it must not consume the budget that bounds it.

The in-repo tests move with the model: ten of the EIP-8141 unit tests were asserting the one it replaced. Six declared no state budget on frames that create a storage slot, which the revision turns into an out-of-state-gas halt rather than a spill into execution gas. Three covered SIGPARAM selector 0x04. One failed on static validity rather than gas — an expiry verifier frame runs protocol code that creates no state, so the revision rejects one that declares any state budget. Two further tests were fixed although they were passing, vacuously: one asserts a stored value of zero, so its SSTORE was writing zero over zero and the assertion held whether or not the frame ran, and the other could not tell a REVERT that discards state from an SSTORE that never happened. The three selector tests move to SIGDATACOPY, keeping their intents, and a new one pins the removal, since a handler that still accepted 0x04 would read the wrong operand count off the stack and nothing else would notice. golden_frame_tx_rlp_and_sig_hash is repinned to the seven-item payload — its expected bytes derived from the reference's field order rather than read back out of the encoder — and the receipt's nested gas_used gets a byte vector of its own. Both are byte vectors rather than round-trips for the same reason: an encoder and decoder that dropped a nesting level together would still agree with each other, so only a fixed expectation catches it.

None of this is reachable off the frame path, so no pre-Hegota behavior changes. The per-frame state-gas pool is None for every other transaction type, and the two shared EIP-8037 helpers enter their new branch only when it is Some, falling through to the existing reservoir logic otherwise; the only place the pool is ever populated is inside execute_frame_tx. SIGDATACOPY joins the opcode table only where the other frame opcodes do, and the existing "frame opcode must be invalid at Osaka/Amsterdam" test now pins 0xB5 alongside them.

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. The fixture-to-ethrex frame conversion saturates every scalar it narrows instead of panicking, so a fixture carrying an out-of-range mode, flags, scheme, chain id, nonce or frame gas limit reaches the validity check that rejects it for that field rather than aborting the run on the way there; a saturated value cannot pass silently, because the block is rebuilt from these fields and its transactions root is checked against the header the fixture ships.
  • 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 release fills every fork through Bogota, so the bundle carries far more than this track runs — its blockchain format alone holds 122,824 cases across 25 for_<fork> subtrees, and it also ships blockchain_tests_engine/, blockchain_tests_engine_x/, state_tests/, transaction_tests/ and blockchain_tests_sync/. Only for_bogota/ (25,017 cases) is overlaid. The other 24 cover forks the mainline tests@v20.0.1 and Amsterdam tests-glamsterdam-devnet@v8.1.1 pins already supply — every one of those 24 subtree names is already populated by another pin — and all three pins extract into the single vectors/eest/ root, so taking them from this bundle would override those pins rather than add coverage. The non-blockchain formats each need a Bogota mapping in their own harness and can follow separately; hive's frames/frames-quick workflows are the release's designated engine-format vehicle in the meantime.

Ported from frames-devnet-0, keeping the dev branch's reasoning — the implementation the released fixtures exercise that main did not yet carry:

  • Frame entry access charge (dev 666703940): EIP-8141 Rationale — the target's cold/warm access cost is charged inside the frame's own budget, plus the EIP-7702 delegation access behind it. Warmth lands in the frame's substate backup so a failed frame leaves nothing warm.
  • 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 the reference's charge_value_transfer_to_non_alive_account at frame entry, found as a gasUsed chain split against another client.
  • 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.
  • Frame-tx fee fields widened to U256 (dev 999bb95dd, c2161f3f2): EIP-8141 bounds the fee fields at 2**256 and the release uses the room on a transaction it expects to 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. One addition needed guarding rather than widening: calculate_gas_price_for_tx computes min(max_priority_fee + fee_per_gas, max_fee_per_gas), and since U256's Add panics on overflow instead of wrapping, a frame transaction carrying a priority fee near 2**256 — decodable, because the EIP bounds the field only there — would panic during block execution. It saturates now, which is the same value rather than an approximation: a sum past U256::MAX is above max_fee_per_gas by construction, so the enclosing min clamps to the cap either way.
  • Clippy conformance on the entry charge (dev 8ce49c97b) and .gitignore for *.tar.gz.part partial downloads (dev 1d8a5ed58).

Deliberately left on the dev branch, because no fixture this track runs reaches it:

  • Mempool-admission work: post-prefix rules, replacement/eviction, sponsored-VERIFY targeting. These live under the EIP's "Validation Trace Rules", whose preamble scopes the section to what a public mempool node must simulate and reject, so they govern admission rather than block validity.
  • RPC receipt to reporting.
  • The pre-release local fill pipeline and the frames-devnet-0.yaml kurtosis config, which depends on the dev-only glamsterdam-devnet-8 base config.

docs/eip-8141.md is repinned to the revision the code now implements and rewritten where the revision falsified it — the transaction and receipt wire encodings, the gas anchors, the per-frame metering, the frame-entry charge, the atomic-batch unroll note, and the FRAMEPARAM/SIGPARAM tables. Two known limitations replace the note that said the revision was unimplemented. The first is that the validation-prefix banned-opcode list has not been re-synced: ethrex still bans the four opcodes the revision relaxed (ORIGIN, BLOBHASH, TLOAD, TSTORE), which can only over-reject, but it does not ban SLOTNUM, which is the direction a local peer policy should not err in. The second is the schema version, below.

STORE_SCHEMA_VERSION stays at 3, and that was checked rather than assumed. A persisted frame receipt genuinely does change shape here — Receipt::encode_storage carries frame_receipts, and gas_used goes from a scalar to a nested pair — so the question is real. Three things decide it. The affected records exist only on a chain that activates the pseudo-fork, and the only shipped configurations that do are the local devnet fixtures, so no mainnet or public-testnet database can hold one. The same revision changes the type-0x06 transaction encoding, which means a database holding frame transactions is undecodable at the block level as well: a schema migration cannot rescue it, and bumping the version would advertise an upgrade path that does not exist, while the framework's compile-time invariant would demand a migration function for unsalvageable data. And the frame-receipt storage layout was introduced the same way in the first place, in #6326, which added it without a bump. The devnet restarts from a new genesis with each spec revision, which is the actual remedy. crates/storage/ is untouched by this PR.

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).

How to test

make -C tooling/ef_tests/blockchain test-levm       # includes the new for_bogota overlay
make -C tooling/ef_tests/blockchain test-stateless
make -C tooling/ef_tests/engine test
cargo test --workspace --exclude 'ethrex-l2*' --exclude ethrex-prover --exclude ethrex-guest-program

# the track's subtree alone, once the overlay is in place
cd tooling/ef_tests/blockchain && cargo test --profile release-fast for_bogota

make frames-vectors fetches and overlays the bundle on its own; it is a prerequisite of test-levm, and it is keyed on the release URL, so re-running it after a pin change re-fetches and re-extracts rather than reusing whatever for_bogota/ is on disk.

Results from this branch:

  • make -C tooling/ef_tests/blockchain test-levm: 18,089 passed / 0 failed / 0 ignored — 14,921 pre-existing plus the 3,168 new for_bogota files.
  • make -C tooling/ef_tests/blockchain test-stateless: 3,218 passed / 0 failed.
  • make -C tooling/ef_tests/engine test: 11,040 fixtures passed / 0 failed over 79,515 fixture cases, plus 11 harness unit tests (1 ignored, a pre-existing timing benchmark).
  • cargo test --workspace --exclude 'ethrex-l2*' --exclude ethrex-prover --exclude ethrex-guest-program: 1,915 passed / 0 failed across 48 suites (11 ignored, all pre-existing).
  • cargo clippy --workspace --all-targets -- -D warnings and the same in tooling/: clean. cargo fmt --all -- --check, both workspaces: clean.

Checklist

  • STORE_SCHEMA_VERSION (crates/storage/lib.rs) stays at 3, and that was checked rather than assumed: a persisted frame receipt genuinely does change shape, but only a chain that activates the pseudo-fork can hold one, no migration could rescue such a database because the same revision also changes the type-0x06 transaction encoding, and crates/storage/ is untouched by this PR. The full reasoning is in the Description above.

Dev-branch delivery to frames-devnet-0 skipped: the cherry-pick conflicts — needs a human (the loop never resolves conflicts).

@github-actions

github-actions Bot commented Aug 26, 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 26, 2026
@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown

Lines of code report

Total lines added: 557
Total lines removed: 0
Total lines changed: 557

Detailed view
+---------------------------------------------------------+-------+------+
| File                                                    | Lines | Diff |
+---------------------------------------------------------+-------+------+
| ethrex/crates/common/types/receipt.rs                   | 975   | +91  |
+---------------------------------------------------------+-------+------+
| ethrex/crates/common/types/transaction.rs               | 5208  | +139 |
+---------------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/rlpx/eth/eth68/receipts.rs | 272   | +11  |
+---------------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/rlpx/eth/eth70/receipts.rs | 319   | +6   |
+---------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/eth/fee_market.rs          | 239   | +4   |
+---------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/types/receipt.rs           | 334   | +3   |
+---------------------------------------------------------+-------+------+
| ethrex/crates/vm/backends/levm/mod.rs                   | 3818  | +23  |
+---------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/opcode_handlers/frame_tx.rs   | 564   | +59  |
+---------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/opcodes.rs                    | 624   | +3   |
+---------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/vm.rs                         | 2401  | +218 |
+---------------------------------------------------------+-------+------+

@github-actions

github-actions Bot commented Aug 26, 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.999 ± 0.015 2.974 3.023 1.16 ± 0.01
main_levm_BubbleSort 2.588 ± 0.035 2.565 2.684 1.00 ± 0.01
pr_revm_BubbleSort 3.039 ± 0.019 3.021 3.075 1.18 ± 0.01
pr_levm_BubbleSort 2.580 ± 0.015 2.569 2.621 1.00

Benchmark Results: ERC20Approval

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Approval 980.1 ± 9.7 969.9 998.3 1.01 ± 0.01
main_levm_ERC20Approval 971.0 ± 6.5 959.4 979.8 1.01 ± 0.01
pr_revm_ERC20Approval 1011.2 ± 4.2 1005.6 1016.6 1.05 ± 0.01
pr_levm_ERC20Approval 965.9 ± 7.5 957.2 982.0 1.00

Benchmark Results: ERC20Mint

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Mint 132.4 ± 2.3 130.5 138.3 1.00
main_levm_ERC20Mint 150.4 ± 1.5 149.2 153.9 1.14 ± 0.02
pr_revm_ERC20Mint 134.6 ± 1.1 133.1 135.9 1.02 ± 0.02
pr_levm_ERC20Mint 150.3 ± 1.9 148.6 154.4 1.14 ± 0.02

Benchmark Results: ERC20Transfer

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Transfer 230.5 ± 2.3 228.0 235.7 1.00
main_levm_ERC20Transfer 239.6 ± 1.8 237.5 242.4 1.04 ± 0.01
pr_revm_ERC20Transfer 237.9 ± 2.6 235.6 244.0 1.03 ± 0.02
pr_levm_ERC20Transfer 241.9 ± 1.8 239.9 244.6 1.05 ± 0.01

Benchmark Results: Factorial

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Factorial 222.1 ± 0.6 221.2 223.0 1.00
main_levm_Factorial 252.7 ± 1.0 251.2 254.6 1.14 ± 0.01
pr_revm_Factorial 223.0 ± 2.2 220.7 227.7 1.00 ± 0.01
pr_levm_Factorial 252.1 ± 1.7 250.3 255.1 1.14 ± 0.01

Benchmark Results: FactorialRecursive

Command Mean [s] Min [s] Max [s] Relative
main_revm_FactorialRecursive 1.565 ± 0.040 1.506 1.622 1.00
main_levm_FactorialRecursive 8.485 ± 0.054 8.446 8.615 5.42 ± 0.14
pr_revm_FactorialRecursive 1.587 ± 0.054 1.440 1.636 1.01 ± 0.04
pr_levm_FactorialRecursive 8.499 ± 0.021 8.474 8.526 5.43 ± 0.14

Benchmark Results: Fibonacci

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Fibonacci 202.5 ± 1.5 199.8 204.1 1.00
main_levm_Fibonacci 219.4 ± 6.1 211.6 228.7 1.08 ± 0.03
pr_revm_Fibonacci 222.0 ± 61.9 200.8 398.1 1.10 ± 0.31
pr_levm_Fibonacci 215.9 ± 4.2 211.5 224.8 1.07 ± 0.02

Benchmark Results: FibonacciRecursive

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_FibonacciRecursive 851.7 ± 8.9 840.8 873.1 1.22 ± 0.02
main_levm_FibonacciRecursive 699.6 ± 5.1 693.2 706.6 1.00
pr_revm_FibonacciRecursive 836.8 ± 11.3 825.0 853.2 1.20 ± 0.02
pr_levm_FibonacciRecursive 705.3 ± 12.6 691.9 728.0 1.01 ± 0.02

Benchmark Results: ManyHashes

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ManyHashes 8.3 ± 0.1 8.2 8.4 1.00
main_levm_ManyHashes 9.2 ± 0.1 9.0 9.4 1.11 ± 0.02
pr_revm_ManyHashes 8.4 ± 0.2 8.3 8.9 1.01 ± 0.02
pr_levm_ManyHashes 9.4 ± 0.7 9.1 11.4 1.13 ± 0.09

Benchmark Results: MstoreBench

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_MstoreBench 260.4 ± 4.0 256.7 268.0 1.35 ± 0.02
main_levm_MstoreBench 194.7 ± 4.7 190.8 206.8 1.01 ± 0.03
pr_revm_MstoreBench 259.5 ± 4.6 256.2 269.0 1.35 ± 0.03
pr_levm_MstoreBench 192.6 ± 1.7 190.2 196.8 1.00

Benchmark Results: Push

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Push 289.0 ± 0.9 287.2 290.0 1.21 ± 0.01
main_levm_Push 239.2 ± 3.1 237.0 246.9 1.00 ± 0.01
pr_revm_Push 288.1 ± 0.6 286.9 289.0 1.21 ± 0.00
pr_levm_Push 238.1 ± 0.7 237.3 239.1 1.00

Benchmark Results: SstoreBench_no_opt

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_SstoreBench_no_opt 169.6 ± 5.5 160.1 180.1 1.66 ± 0.05
main_levm_SstoreBench_no_opt 102.6 ± 0.5 101.9 103.4 1.00 ± 0.01
pr_revm_SstoreBench_no_opt 168.7 ± 2.6 166.7 175.2 1.65 ± 0.03
pr_levm_SstoreBench_no_opt 102.3 ± 0.4 101.6 103.1 1.00

ilitteri and others added 21 commits August 27, 2026 18:57
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 - 1` -- 29 bytes of `0xff` -- 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, and the three overflow exception names the
release introduces split two ways. `GASPRICE_OVERFLOW` and `PRIORITY_OVERFLOW`
name type-0x06 fixtures whose fee field carries 2**256 or more -- past the
bound the EIP puts on the field, and so past what the `U256` holding it can
represent. Those transactions cannot be decoded at all, so the two names map
to a decode-time rejection and the runner accepts that surface, the same shape
as the `NONCE_IS_MAX` case beside it. `GASLIMIT_PRICE_PRODUCT_OVERFLOW` needs
neither a mapping nor a tolerance: the name is already mapped for the other
transaction types, and its fixtures' values fit the fields that hold them -- a
legacy `gas_price` is `U256` as well -- so those blocks decode and are rejected
when the transaction is validated, exactly as the copies of the same fixture in
the other forks' subtrees already are.

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, 18058 blockchain fixtures overall on this base,
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 requests 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.
tests-frames-devnet@v0.1.0 fills against a revised EIP-8141 whose frame
encoding ethrex does not speak: a frame's single gas field becomes a nested
[execution, state] pair, the transaction's three fee fields become a nested
fees list moved after signatures, and a frame receipt's gas_used becomes an
{execution, state} pair. Measured on this branch, the v0.1.0 pin fails 72 of
18,089 blockchain tests -- exactly the files carrying a type-0x06 transaction
-- with InvalidBlock(InvalidBody(TransactionsRootNotMatch)), which is what a
re-encoding mismatch looks like in that harness.

The track therefore pins tests-frames-devnet@v0.0.0, the newest release this
implementation conforms to, and the doc now states what adopting the revision
needs: the encoding changes and the STORE_SCHEMA_VERSION bump the receipt
change forces, the intrinsic cost drop from 15,000 to 12,000 and the derived
gas anchors that move with the second dimension, the per-frame state-gas pools
with cross-frame refill attribution, the new SIGDATACOPY opcode and the
static-arity SIGPARAM it leaves behind, and the four semantic changes that
ship alongside them.
…flow

Widening a frame transaction's fee fields to U256 left one addition
unguarded. `calculate_gas_price_for_tx` computes
`min(max_priority_fee + fee_per_gas, max_fee_per_gas)`, and with
`max_priority_fee` now a U256 that sum can leave the type: EIP-8141's static
constraints bound the fee fields only at 2**256, so a decodable frame
transaction can carry a priority fee near U256::MAX. U256's `Add` panics on
overflow rather than wrapping, and this runs during per-transaction env setup
in block execution, so the panic would take the node down on a block a peer
can hand it.

Saturating is not an approximation here, it is the same answer: a sum past
U256::MAX is above `max_fee_per_gas` by construction, so `min` clamps to the
cap either way. This matches how `calc_effective_gas_price` keeps the same
arithmetic in range by clamping before it adds.

No behavior change off the frame path -- every other transaction type still
holds u64 fees behind `U256::from`, so the sum stays below 2**65.
The header pinned `c55786f42`, but SIGPARAM's copy branch takes its operands
in CALLDATACOPY's order, which is `4a9ad32cf` -- one revision later. Pin the
revision the code actually implements, and name the two the code does not:
`5276aa8cb` (ban SLOTNUM during validation-prefix execution) is a public-mempool
admission rule and never reaches block validation, and `064f49621` is an
editorial link pass.
The fee list did not move: at both revisions it sits between `signatures` and
`blob_versioned_hashes`. What changed is that its three flat fields collapse
into one nested list, which is what takes the payload from nine top-level items
to seven. Say that, and give the item counts, so the note is enough to size the
decoder work without re-deriving it from the fixtures.
`tests-frames-devnet@v0.1.0` fills against a revised EIP-8141 in which a frame
declares a gas budget per gas dimension of EIP-8037 rather than one scalar. That
reaches the wire format, so nothing about the revision is optional: ethrex could
not decode a type-`0x06` transaction from the release at all.

Encoding. A frame's fourth item becomes the nested list `[execution, state]`, and
the transaction's three flat fee fields collapse into one nested `fees` list in
place, between `signatures` and `blob_versioned_hashes` -- taking the payload from
nine top-level items to seven. Both moves the transaction hash and the EIP-8141
signature hash, so `fees()` is the single definition the encoder and the signature
hash share. A frame receipt's `gas_used` likewise becomes `[execution, state]`, and
the RPC receipt reports the second dimension as `stateGasUsed`.

Intrinsic cost. `TX_FRAME_INTRINSIC` drops from 15,000 to 12,000 -- equal to
`TX_BASE`, because a frame transaction prices signature recovery per signature
entry, so the recovery component of the base cost instead covers payer settlement.
The intrinsic gains `TX_VALUE_COST` (6,000) per value-bearing frame whose explicit
target is not the sender, covering the recipient balance write and the transfer
log. Both anchor the calldata floor as well as the execution cost.

Derived anchors. `standard_gas_limit` spans both dimensions; `max_gas` compares it
against the calldata floor plus the state budgets, because the floor binds the
execution dimension alone -- a floor-bound transaction pays the floor plus its
state gas in full. EIP-7825's per-transaction cap bounds the execution dimension
alone, and block capacity is reserved exactly per dimension rather than through the
reservoir model, which exists for transaction types that cannot name a state budget
of their own.

Metering. Each frame runs against a state-gas pool seeded from its declared budget.
The dimensions are independent, so a charge past the pool is an out-of-gas halt
rather than a spill, and leftover state gas does not fund execution. A frame
transaction therefore carries no reservoir. Because a storage refill may reverse a
charge an *earlier* frame paid, the owner of each outstanding charge is tracked and
the refill lowers the owner's receipt in place -- returning the gas to the payer at
settlement without granting the executing frame budget it never declared. Frame
entry, and atomic-batch entry, snapshot the receipts, the approval fields, the pool
and the ownership map; a batch unroll re-appends the executed frames' receipts with
their state gas zeroed and logs dropped, since the state those charges paid for is
exactly what the unroll discards.

Semantics the revision changes: the resolved target's warm or cold access is
charged at entry for every frame, codeless `VERIFY` included, and priced before the
target is warmed -- so a frame that cannot afford its entry never reads its target
and nothing about it reaches the block access list. A revival's `NEW_ACCOUNT` cost
is charged to the state dimension. A frame targeting an active precompile
dispatches it in every mode. An `APPROVE` refusal reverts the current call frame
instead of halting, with the memory expansion charged first, following `RETURN`
semantics for the designated return-data region. Approval scope is statically
rejected on every frame of an atomic batch, and an expiry verifier frame must
declare no state gas.

The validation-prefix budget splits with the dimensions: the prefix's execution
budgets plus the signature cost against `MAX_VERIFY_GAS`, its state budgets against
the separate, much larger `MAX_VERIFY_STATE_GAS`. State gas does not measure a
node's simulation work, so it must not consume the budget that bounds it.

Nothing here is reachable off the frame path. The per-frame pool is `None` for every
other transaction type, so the two shared EIP-8037 helpers fall through to the
existing reservoir logic unchanged, and `SIGDATACOPY` joins the opcode table only
where the other frame opcodes do -- the table test now pins 0xB5 invalid at Osaka
and Amsterdam alongside them.

Also corrects three comments that gave the skipped-frame receipt status as 3. The
constant has been 2, and the reference enum at the release tag confirms it.
The release supersedes `v0.0.0` and fills every fork through Bogota against the
revised EIP-8141. `for_bogota` grows from 3,137 fixture files to 3,168, and the
EIP-8141 subtree from 45 files to 71 -- new coverage for the state-gas dimension,
`SIGDATACOPY`, the frame-entry access charge and the signature-validation
precompiles staying out of the block access list.

`make -C tooling/ef_tests/blockchain test-levm` passes 18,089 of 18,089: the 3,168
Bogota files and the 14,921 that were already green.

Repins the doc's spec target to `7d1c8bfb9`, the EIP-8141 head when the release was
cut, and rewrites the sections the revision falsified -- the wire encoding, the gas
anchors, the per-frame metering, the frame-entry charge, and the `FRAMEPARAM` and
`SIGPARAM` tables. Two limitations replace the note that said the revision was
unimplemented:

The validation-prefix banned-opcode list has not been re-synced. ethrex still bans
the four opcodes the revision relaxed, which can only over-reject, but does not ban
`SLOTNUM`, which is the direction a local peer policy should not err in. The rules
govern mempool admission rather than block validity, so no blockchain fixture
exercises the difference.

`STORE_SCHEMA_VERSION` stays at 3 even though a persisted frame receipt changed
shape. The affected records exist only on a chain that activates the pseudo-fork,
which no shipped network configuration does apart from the local devnet fixtures;
the same revision changes the transaction encoding, so a database holding frame
transactions is undecodable at the block level too and a migration could not rescue
it; and the frame-receipt storage layout was introduced the same way, in #6326,
without a bump.
`golden_frame_tx_rlp_and_sig_hash` locks the type-`0x06` payload at the byte
level, and the two-dimensional gas model moved every byte of it: each frame's
fourth item is now the nested list `[execution, state]` and the three fee fields
are one nested `fees` list, so the payload carries seven top-level items instead
of nine. Repinned to the revised shape, along with the signature hash the same
encoding feeds.

Both expected values were derived from the reference's `FrameTransaction` and
`Frame` field order rather than read back out of the encoder: the payload
hand-encodes to 176 bytes (`f8b0`), and the signature hash is keccak256 over
`0x06` and the same payload with the empty-msg signature's bytes elided, which
is what `compute_frame_signature_hash` specifies.

The comment above them claimed the values were "the current canonical output",
which is circular, and that no external reference vectors existed, which the
release ends. It now says where the bytes come from and why a byte vector is the
right assertion: this crate's encoder and decoder would agree with each other on
a flattened payload, so the round-trip below cannot catch a dropped nesting
level -- only the vector can.

`frame_receipt_gas_used_encodes_as_a_nested_pair` is new, for the same reason on
the receipt side. Every other test in `receipt.rs` builds its expectation with
the same encoder it exercises, so none of them would notice `gas_used`
flattening back to a scalar; outside this crate only the receipts root would,
through the spec-test fixtures.
Ten of the in-repo EIP-8141 tests were asserting the gas model the two-dimensional
revision replaced. Six of them declared `state: 0` on frames that create a storage
slot: the dimensions are independent now, so a state charge draws on the frame's
own state budget and cannot spill into its execution gas, and those SSTOREs run
out of state gas. Two of the six still carried a comment saying the state cost
"spills into the frame's regular gas", which is exactly the replaced model.

The frames that create a slot now declare `SSTORE_SET_STATE_GAS`, the cost the
file already computes for that transition.

Two further tests are changed although they were *passing*, because they were
passing vacuously and would have kept passing untouched:
`sigparam_0x01_returns_the_scheme` asserts the stored value is
`FRAME_SIG_SCHEME_ARBITRARY`, which is 0, so its SSTORE was writing 0 over 0 and
the assertion held whether or not the frame executed; and
`reverted_frame_reports_no_state_gas` could not tell a REVERT that discards state
from an SSTORE that never ran.

`state_gas_reservoir_does_not_leak_across_frames` becomes
`state_gas_does_not_leak_across_frames`: there is no reservoir on the frame path
any more. What it asserts is unchanged and is what the per-frame budget enforces.

One of the ten failed on static validity rather than gas.
`timestamp_inside_expiry_verifier_is_allowed` builds its frame with the shared
observer helper, which budgets both dimensions, and an expiry verifier frame runs
protocol code that creates no state, so the revision rejects one declaring any
state gas. That frame is now built inline with no state budget; the helper is left
alone, since every other observer test wants both.

The remaining three covered SIGPARAM's copy selector `0x04`, which is gone -- the
copy is `SIGDATACOPY`'s job, which is what makes SIGPARAM static-arity. They move
to the opcode that performs the copy, keeping their intents (copies bytes,
zero-fills past the end, operands in `CALLDATACOPY` order). A new test pins the
removal itself: a handler that still accepted `0x04` would read the wrong operand
count off the stack and nothing else here would notice.
The header's spec-tests note pointed at an anchor that no longer exists: the
banned-opcode limitation was reworded when the two-dimensional gas model landed,
and the link kept the old heading's slug.

The file-reference tables and the out-of-frame halt list also predate
`SIGDATACOPY`, so they named every frame opcode except the new one. Its gas entry
records that it shares `framedatacopy()`; the reference gives the two opcodes the
same base and the same per-word cost, so there is no separate constant to cite.
The spec-target header named EIP-8141 @ `7d1c8bfb9`, derived from the EIP's
commit history as the head when `tests-frames-devnet@v0.1.0` was cut. The
fixtures say otherwise, and they say it outright: all 214 EIP-8141 cases in the
pinned release carry `_info.reference-spec-version = 3ceef8d37`, the commit
immediately before `7d1c8bfb9`, with no variance across the 71 files. Quote the
fixtures rather than the commit graph.

Being one revision out also made the header's completeness claim false. It said
a single rule of the target revision was unimplemented -- the validation-prefix
banned-opcode list -- when `7d1c8bfb9`'s own normative change is a second. That
commit adds EIP-7778 to the EIP's `requires` list and counts the execution
dimension a frame transaction contributes to the block *before* its EIP-3529
storage refund, while the payer keeps paying the post-refund figure. ethrex puts
one post-refund total in both `ExecutionReport::gas_used`, which the block
counters read, and `gas_spent`, which receipts read. So the header now lists two
unimplemented rules and the new one gets a Known Limitations section.

Record the rule rather than implement it, because the pinned release requires
the older one and fixes it in a header:
`gas_settlement/storage_refund_settlement.json` clears a pre-existing storage
slot and pins the reduced counter at `gasUsed = 0xf4ec`. Accounting the frame
path pre-refund fails exactly that fixture, `GasUsedMismatch(74316, 62700)`,
the 11,616 delta being one uncapped single-slot clear refund, and changes no
other EIP-8141 fixture. The release's reference implementation carries the same
asymmetry deliberately: at the release tag `settle_frame_transaction_gas`
describes the execution dimension as the post-refund usage less the attributed
state gas, while the regular-transaction settlement beside it cites EIP-7778 for
counting pre-refund gas. The change belongs with the release that fills against
`7d1c8bfb9`, and it needs the frame-tx report to separate its block-facing
figure from its payer-facing one, the way `refund_sender` already does on the
regular path.
`From<Transaction> for FrameTransaction` documents itself as staying total, so
that a fixture carrying a deliberately out-of-range field reaches the validity
check that rejects it for that field rather than aborting the harness on the way
there. Three fields did not follow the rule: a frame's `mode` and `flags` and a
signature's `scheme` are `U256` on the fixture side and `u8` in ethrex, and
converted with `.unwrap()`, so any value past 255 panics.

Saturate them like the other scalars rather than narrow the comment to excuse
them, because the comment's own safety argument covers them unchanged: the runner
rebuilds the block from these fields and `validate_block_body` recomputes the
transactions root against the header the fixture ships, so a field altered by
saturation fails the root check loudly instead of passing quietly.

The fixtures this guards are already on the path and already at the boundary. A
block whose transaction ships as raw RLP is taken from the fixture's
`rlp_decoded` view, which this conversion consumes, and the invalid-frame-format
fixtures there declare `mode`, `flags` and `scheme` up to exactly `0xff` -- the
last value that fits. Nothing in the release exceeds it, so the suite is
unchanged at 18,089.
…spelled out

The two-dimensional gas model changed the consensus frame receipt from
`[status, gas_used, logs]` to `[status, [execution_gas, state_gas], logs]`, but
three places that expand that layout inline kept the scalar: the Receipt Format
section of `docs/eip-8141.md`, which is the definition the rest of the section's
`frame_receipt` token refers back to; its restatement in the argument for
deriving `succeeded` from every frame's status; and the doc comment on
`receipts70_frame_receipt_matches_the_eip8141_wire_form`. Every other site names
the token instead of expanding it, so it needed no change.

The section's one-line gloss on the gas -- "the frame's gas, not accounting for
refunds" -- was wrong in a second way once the field became a pair, because the
two dimensions do not treat refunds alike. The reference at the release tag
documents the execution dimension as "not reduced by refunds. Final once the
frame completes" and the state dimension as "attributed to the frame after all
refills and rollbacks applied so far", which is exactly what lets a later frame's
cross-frame refill lower an earlier frame's entry in place. Say that instead.
@ilitteri
ilitteri force-pushed the tests-frames-devnet-v0.1.0 branch from 144a138 to 18bb089 Compare August 28, 2026 01:27
@ilitteri ilitteri changed the title test(l1): bump frames fixtures to tests-frames-devnet@v0.1.0 (incomplete) feat(l1): support frames-devnet-0 (tests-frames-devnet@v0.1.0) Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L1 Ethereum client

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant