Skip to content

feat(l1,levm): implement EIP-8250 keyed nonces for frame transactions#6906

Draft
ilitteri wants to merge 122 commits into
mainfrom
eip-8250
Draft

feat(l1,levm): implement EIP-8250 keyed nonces for frame transactions#6906
ilitteri wants to merge 122 commits into
mainfrom
eip-8250

Conversation

@ilitteri

@ilitteri ilitteri commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Motivation

EIP-8141 frame transactions (tx type 0x06) carry a single linear sender nonce, which serializes all of a sender's frame transactions through one replay-protection domain. EIP-8250 (Draft, Standards Track: Core; requires EIP-8141) replaces that single nonce with a keyed nonce model so a shared sender can run replay-independent transactions on disjoint key sets.

This PR lands EIP-8250 keyed nonces on top of the EIP-8141 frame-transaction machinery, activating under the same Fork::Hegota. See docs/eip-8250.md for the full implementation notes and divergence list.

Description

The frame-transaction envelope's nonce: u64 is replaced by nonce_keys: Vec<U256> (1..=16 strictly-increasing uint256 keys) plus a single nonce_seq: u64. For every selected key the protocol checks current_nonce_seq(sender, key) == nonce_seq and, on payment approval, advances each key's sequence by one. Key 0 is the legacy linear nonce domain (the account's nonce); non-zero keys live in the new NONCE_MANAGER predeploy.

Envelope and encodings (crates/common/types/transaction.rs)

  • FrameTransaction now holds nonce_keys / nonce_seq (rkyv via Map<U256Wrapper>); RLP encode/decode, compute_sig_hash, serde (nonceKeys / nonceSeq), and GenericTransaction updated accordingly.
  • Transaction::nonce() returns nonce_seq for frame txs (used for mempool ordering / RPC; key-0 txs use nonce_seq as the account's linear nonce).
  • nonce_keys_hash() helper: keccak256(be32(len) ‖ be32(k0) ‖ …).
  • frame_tx_nonce_manager() helper returns the predeploy address (0x…8250).
  • validate_static_constraints() enforces: nonce_keys count in 1..=16, strictly increasing, and nonce_seq != u64::MAX.

NONCE_MANAGER predeploy (crates/vm/system_contracts.rs, crates/vm/backends/levm/mod.rs, crates/vm/backends/mod.rs)

  • New NONCE_MANAGER_PREDEPLOY at address 0x…8250, active_since_fork = Hegota, runtime bytecode 0x6000600 0fd (PUSH1 0 PUSH1 0 REVERT) — non-callable by users; it exists only as a protocol-managed storage namespace. Slot layout: keccak256(left_pad_32(sender) ‖ uint256_to_bytes32(key)).
  • install_nonce_manager_code() installs the predeploy (nonce 1, code + code_hash, BAL-recorded) at the Hegota boundary, hooked alongside the existing EIP-8141 install_expiry_verifier_code() on both Hegota install paths (fork >= Fork::Hegota). The install is idempotent: it early-returns when the account already holds the expected runtime bytecode, so exactly one account update is produced (at the first Hegota block) and none after.

VM validation and consumption (crates/vm/levm/src/vm.rs, crates/vm/levm/src/opcode_handlers/frame_tx.rs, crates/vm/levm/src/gas_cost.rs)

  • current_nonce_seq(sender, key): key 0 reads the account's linear nonce; non-zero keys read NONCE_MANAGER[slot].
  • Keyed-nonce validation in the frame-tx validation prefix: for each selected key, current_nonce_seq == nonce_seq else NonceMismatch.
  • consume_keyed_nonces(sender) replaces the increment_account_nonce(sender) call in both APPROVE_PAYMENT and APPROVE_EXECUTION_AND_PAYMENT handlers: key 0 increments the account's linear nonce; non-zero keys write nonce_seq + 1 to NONCE_MANAGER storage and charge KEYED_NONCE_FIRST_USE_GAS (20000) on the first use of a key (slot 0 -> nonzero, matching SSTORE storage-creation cost).
  • FrameTxContext captures legacy_sender_nonce at tx entry for the new TXPARAM.

TXPARAM additions (crates/vm/levm/src/opcode_handlers/frame_tx.rs)

  • 0x01 re-pointed from nonce to nonce_seq.
  • 0x0C = pre-state legacy sender nonce; 0x0D = len(nonce_keys); 0x0E = nonce_keys_hash; 0x10 = nonce_keys[0].
  • 0x0B (len(signatures)) is kept from the EIP-8141 allocation.

Mempool policy (crates/blockchain/blockchain.rs)

  • The public mempool admits only key-0 frame transactions (nonce_keys == [0]); other keysets are rejected with InvalidFrameTransaction. The spec permits this minimal policy. Non-zero-key frame txs can still be included by a block builder and are validated in full at block execution. Multi-key / non-zero-key mempool admission needs per-keyset pending/replacement tracking that is deferred.

Tests

  • validate_static_rejects_bad_nonce_keys — empty, > 16, non-strictly-increasing keysets, and nonce_seq == u64::MAX are rejected.
  • nonce_keys_hash_matches_spec_formulanonce_keys_hash() matches the BE-length-prefixed keccak formula.
  • nonce_manager_constants_match_spec — predeploy address 0x8250, runtime bytecode, and active_since_fork == Hegota.
  • RLP round-trip coverage for nonce_keys / nonce_seq, plus the existing EIP-8141 frame-tx static-constraint and SENDER-only-value tests carried forward.

How to Test

# Static envelope constraints, nonce_keys_hash, RLP round-trip
cargo test -p ethrex-common --lib -- nonce_keys nonce_seq

# NONCE_MANAGER predeploy constants (address / bytecode / fork)
cargo test -p ethrex-vm --lib -- nonce_manager

# Keyed-nonce VM validation + consumption (levm)
cargo test -p ethrex-levm

# Public-mempool key-0 admission gate
cargo test -p ethrex-blockchain

Notes / Divergences

All items below are documented in docs/eip-8250.md.

  1. TXPARAM nonce_keys[0] at 0x10, not 0x0B. The EIP-8250 draft assigns 0x0B to nonce_keys[0], but EIP-8141 already defines (and ethrex ships) 0x0B = len(signatures) with no replacement index. ethrex keeps 0x0B = len(signatures) and places nonce_keys[0] at the free index 0x10. An authoritative 8141-family TXPARAM registry is needed upstream.
  2. NONCE_MANAGER address 0x…8250. The draft leaves the address TBD; ethrex uses the 0x…<EIP> convention (matching EXPIRY_VERIFIER = 0x8141).
  3. Atomic-batch consumption durability (to validate). EIP-8250 mandates that keyed-nonce consumption be journaled outside the frame-revert journal and outside any SENDER atomic-batch snapshot ("MUST NOT be reverted by a later frame revert, by skipping frames, or by restoring an atomic-batch snapshot"). In this PR, key-0 consumption uses the existing EIP-8141 increment_account_nonce path and non-zero-key writes use the standard backed-up storage path; both are therefore reverted by an enclosing atomic-batch revert. Making consumption escape the batch snapshot cleanly — without disturbing payer-balance rollback, EIP-7928 BAL accounting, or sender-CREATE nonce semantics — is a consensus design decision tracked for Hegotá devnet + cross-client interop validation.
  4. Mempool scope. Only nonce_keys == [0] is admitted to the public pool (see Description); multi-key / non-zero-key admission is deferred.

The nonce_seq overflow guard (nonce_seq != u64::MAX static rejection; checked_add(1) at consumption) is in place so the sequence advance cannot wrap.

Related

  • EIP-8250 (keyed nonces for frame transactions) — docs/eip-8250.md.
  • Builds on EIP-8141 frame transactions (tx type 0x06); this PR targets the eip-8141-1 base branch.
  • Activates under Fork::Hegota, shared with the EIP-8141 frame-transaction and EXPIRY_VERIFIER machinery.

Checklist

  • Updated STORE_SCHEMA_VERSION (crates/storage/lib.rs) if the PR includes breaking changes to the Store requiring a re-sync. (N/A — execution-layer logic; no Store schema change.)

juanbono and others added 30 commits March 12, 2026 11:42
Add full support for EIP-8141 frame transaction type (0x06):

- Core types: Frame, FrameTransaction, FrameMode, FrameReceipt with
  RLP encode/decode, canonical encoding, and JSON serialization
- New opcodes: APPROVE (0xAA), TXPARAMLOAD (0xB0), TXPARAMSIZE (0xB1),
  TXPARAMCOPY (0xB2) for frame transaction context access
- Frame execution engine: sequential frame execution with per-frame
  state isolation, VERIFY enforcement, and gas accounting
- APPROVE scopes: sender-only (0), payer-only (1), combined (2)
- Node integration: RPC acceptance, mempool validation, receipt pipeline
  with payer and frameReceipts fields
- Clippy-clean: safe casts via try_from, checked arithmetic, no unwrap
Add FrameEntry struct and serialize the frames array in
eth_getTransactionByHash responses for type 6 transactions,
so Blockscout can index and display frame composition.
…ounting

Cherry-picked crates/ changes from bbd8c96 (demo content excluded).
Covers the full architecture (RPC → mempool → payload → VM → receipt),
transaction format, frame modes, all four new opcodes (APPROVE,
TXPARAMLOAD, TXPARAMSIZE, TXPARAMCOPY), gas accounting, execution flow,
receipt format, and mempool handling.

Documents 10 spec gaps and how ethrex fills them: ORIGIN behavior,
APPROVE gas cost, transient storage clearing, APPROVE output data,
access list pre-warming, SSTORE refunds, receipt trie encoding,
ENTRY_POINT collision, failed SENDER frame behavior, and double-APPROVE
protection.

Includes known limitations and a file reference table.
Frame transactions previously always returned status 1 (success) at the
top level, even when SENDER frames reverted. This made reverted frame
transactions appear as successful in block explorers.

Now the top-level receipt succeeded field reflects SENDER frame outcomes:
if any SENDER frame reverts, the receipt status is 0 (matching standard
Ethereum semantics where status means execution outcome, not inclusion).
Per-frame receipts still provide granular detail via frameReceipts.

Updated the spec gap documentation in docs/eip-8141.md accordingly.
…numbering, opcode restructuring

Mode field: Change Frame.mode from FrameMode enum to u32 bitfield where bits 0-7
carry execution mode (0=DEFAULT, 1=VERIFY, 2=SENDER), bits 8-9 carry APPROVE scope
restriction, and bit 10 carries the atomic batch flag. Add helpers execution_mode(),
scope_restriction(), is_atomic_batch(). Update RLP encode/decode for variable-length
integer mode. Add validate_static_constraints() for reserved modes and atomic batch
validation.

APPROVE scopes: Renumber from 0/1/2 to 1/2/3 (0 is now invalid). Add scope
restriction enforcement from mode bits 8-9. Extract APPROVE side effects into shared
pub fn apply_approve() for reuse by default code.

Opcodes: Rename TXPARAMLOAD→TXPARAM (gas 3→2). Update TXPARAM param table with
0x12=gas_limit, 0x13=mode(lower 8 bits), 0x14=len(data), 0x15=status,
0x16=scope(bits 8-9), 0x17=atomic_batch(bit 10). Replace TXPARAMSIZE with
FRAMEDATALOAD (0xB1): offset+frameIndex→32-byte word, gas 3. Replace TXPARAMCOPY
with FRAMEDATACOPY (0xB2): memOffset+dataOffset+length+frameIndex, gas=CALLDATACOPY.
VERIFY frames return zero for both new opcodes.
Frame transaction receipts now encode as [tx_type, cumulative_gas_used, payer,
[frame_receipts]] without top-level succeeded or logs fields. On decode,
succeeded is derived from frame receipts (true if all frames succeeded).
Standard transaction receipts are unchanged.
When a frame targets an address with no deployed code, the protocol now
executes built-in default code instead of a no-op CALL:

- VERIFY: reads signature_type from data[0], supports secp256k1 (0x0)
  and P256 (0x1). Verifies signature, calls apply_approve() on success.
- SENDER: RLP-decodes frame.data as [[target, value, data], ...] and
  executes each subcall with msg.sender = tx.sender.
- DEFAULT: reverts.

This allows existing EOA users to send frame transactions (with batching
and gas sponsorship) without deploying a smart account contract.
Consecutive SENDER frames with the atomic batch flag (bit 10 of mode)
form all-or-nothing groups. A batch-level state snapshot is taken before
the first frame. If any frame reverts, the snapshot is restored (reverting
all prior frames in the batch) and remaining frames are skipped with
status=0 and gas_used=gas_limit.
The default code for EOA SENDER frames constructed a CallFrame with
should_transfer_value and msg_value set correctly, but run_execution()
doesn't act on those fields — only generic_call in system.rs does the
actual balance transfer.

Add explicit balance validation before execution and vm.transfer() after
successful execution, matching what generic_call does at system.rs:860
and system.rs:933.

Without this fix, subcalls with non-zero value in the RLP call list
would report success but never move ETH.
1. Atomic batch revert cleared ALL logs instead of just batch logs.
   Used all_logs.truncate(batch_logs_start) instead of retain(|_| false).

2. APPROVE deducted max_fee_per_gas * total_gas_limit but refund used
   effective_gas_price, causing permanent ETH loss equal to the
   difference. Changed compute_tx_cost to use effective_gas_price,
   matching standard EIP-1559 deduction behavior.

3. Default SENDER code balance check ran after mem::replace on the
   call frame. Early return on insufficient balance leaked the swapped
   frame state. Moved balance check before the frame swap.

4. Atomic batch gas undercharge: rewrite loop used exclusive range
   (batch_start_idx..frame_idx), skipping the failing frame. Changed
   to inclusive range (batch_start_idx..=frame_idx) so all batch
   frames including the failing one are charged full gas_limit.
…bitmasks

13 spec changes implemented:

1. Frame encoding: mode (u8) and flags (u8) are separate fields.
   RLP: [mode, flags, target, gas_limit, data]
2. New opcode FRAMEPARAM (0xB3) for frame-scoped params.
   Takes (param, frameIndex). Params 0x00-0x07.
3. TXPARAM takes one arg (param only). Params 0x00-0x0A.
   Frame-indexed params moved to FRAMEPARAM.
4. FRAME_TX_PER_FRAME_COST = 475 added to gas accounting.
5. MAX_FRAMES reduced from 1000 to 64.
6. Scope bitmask semantics: 0x1=PAYMENT, 0x2=EXECUTION, 0x3=both.
7. P256 address domain separator: keccak(0x01||qx||qy)[12:]
8. secp256k1 high-s rejection in default code.
9. ecrecover zero-address check in default code.
10. EIP-7702 delegation awareness comment in frame loop.
11. tx.sender != zero address validation.
12. VERIFY frames must have non-zero scope in flags.
13. flags bits 3-7 must be zero (reserved).
…tion

1. Log duplication → receipts-root divergence.
   After each successful frame, the execution loop called
   substate.commit_backup() (which merges child logs into parent), then
   substate.extract_logs() (walks parent+current) and re-added every
   returned log via add_log(). Logs compounded across frames:
   frame_receipts[1].logs = [L1], frame_receipts[2].logs = [L1,L1,L2],
   frame_receipts[3].logs = [L1,L1,L2,L1,L1,L2,L3], ... Those duplicated
   logs feed the per-frame receipts encoded into the receipts root.
   Fix: add Substate::current_logs() returning only the sub-substate's
   own logs, snapshot it before commit_backup() and use that for the
   frame's receipt. Removed the extract_logs+re-add loop in both sites
   (vm.rs bytecode frame path and SENDER default multicall).

2. Free money + nonce replay via restore_cache_state().
   Default-code frames run on the outer CallFrame without a swap, so
   APPROVE's increment_nonce / decrease_balance recorded the payer's
   original info into the outer call-frame backup. When a later frame
   failed (e.g., malformed RLP in a SENDER frame), restore_cache_state()
   walked that accumulated backup and reverted the APPROVE deltas, but
   ctx.payer_approved stayed true — so the tx was marked successful,
   hit the refund path, and credited the refund to the restored balance.
   Net effect: sender gains balance, nonce reverts → unbounded replay.
   Fix: clear current_call_frame.call_frame_backup at the start of each
   non-batch frame (and at batch entry), so a failing frame's restore
   only undoes that frame's own effects.

3. Atomic batch atomicity bypass.
   Successful in-batch bytecode frames wrote account/storage deltas to
   the swapped-in inner CallFrame's backup, which was dropped on
   swap-back without merging into the parent. Substate::revert_backup()
   only rolls back logs/transient/access-list — not account state. So on
   batch revert, restore_cache_state() found nothing to undo and earlier
   frames' transfers/storage-writes persisted, while the receipt reported
   all batch frames as reverted. Fix: merge the finished frame's backup
   into the parent on success inside an atomic batch (both in the
   top-level bytecode path in vm.rs and in the SENDER default multicall
   in frame_tx.rs), so batch-level restore_cache_state() can undo the
   committed-but-recoverable deltas.

Validated end-to-end against an ethrex dev node: before, logs
duplicated, sender gained 47,336,368,707,000 wei on a reverted tx
with nonce intact, and an atomic batch transferred 1,000,000 wei while
reporting full revert. After the fix: each frame receipt has exactly
one log, sender correctly pays gas and nonce advances, recipient
balance unchanged on batch revert.

Added inline unit tests in vm.rs covering the Substate::current_logs()
invariant that Fix 1 depends on.
Introduces a Phase 0 fork-activation gate symmetrical to the existing
EIP-4844 (Cancun) and EIP-7702 (Prague) precedents. Before this change,
frame transactions were unconditionally accepted by the mempool and
executed by LEVM regardless of ChainConfig.amsterdam_time, which would
be consensus-divergent on any chain where Amsterdam has not activated.

- Adds TxValidationError::FrameTxPreFork variant mirroring Type3TxPreFork
  and Type4TxPreFork at errors.rs:122-138.
- Adds MempoolError::FrameTxPreFork for admission-path rejection.
- validate_transaction rejects frame txs pre-Amsterdam before the
  existing is_frame_tx balance-skip branch fires.
- fill_transactions in payload.rs skips any frame tx that reached the
  builder while the payload timestamp is still pre-Amsterdam and drops
  it from the mempool to avoid retry loops.
- execute_frame_tx in vm.rs returns FrameTxPreFork when the VM's
  active fork is below Fork::Amsterdam, covering block-execution.
- Adds six unit tests exercising pre/post-fork admission, fork
  boundary, devnet epoch 0, and RLP round-trip invariance.
When a VERIFY frame calls APPROVE with scope 0x1 or 0x3 but the
resolved payer cannot cover max_fee * total_gas_limit + blob cost,
vm.decrease_account_balance returns InternalError::Underflow. Before
this change, that underflow propagated as VMError::Internal(Underflow),
which should_propagate() treats as a critical VM fault rather than a
normal frame-level revert.

Per EIP-8141 the correct behaviour is for the VERIFY frame to revert
cleanly so that subsequent frames are never executed and the sender's
nonce increment is restored via the outer restore_cache_state() path.
This change wraps both APPROVE_PAYMENT (0x1) and
APPROVE_PAYMENT_AND_EXECUTION (0x3) call sites to map
InternalError::Underflow -> VMError::RevertOpcode while still
forwarding every other underlying error as an internal fault.

Adds three unit tests asserting the error-mapping invariant directly
on the underlying pattern. The end-to-end "underfunded paymaster
reverts cleanly" path is covered by demos/eip8141/backend/test-findings.mjs.
… spec

Adds two related static-validation checks inside
FrameTransaction::validate_static_constraints:

- M1: each frame's gas_limit must not exceed i64::MAX (2**63-1). The
  spec declares frame gas_limit as a signed-range value; an attacker
  who crafts a larger u64 would otherwise slip past the u64 typing and
  produce negative-signed arithmetic downstream in gas accounting.

- M2: the cumulative sum of frame.gas_limit across all frames must
  also stay within i64::MAX. A u128 accumulator is used so the
  running addition itself cannot overflow; the comparison rejects
  tx-level totals above 2**63-1.

Both are pre-execution checks on the incoming transaction, so a
malformed tx never reaches the mempool or the VM dispatcher.

Includes four unit tests covering a single-frame bound violation,
a cumulative-bound violation, an exact-boundary accept, and the
pre-existing "frames must be 1..=64" check remaining the primary
error when frames is empty.
Replaces the previous `offset.as_u64() as usize` / `index_to_usize(offset.as_u64())?`
patterns in OpFrameDataLoadHandler and OpFrameDataCopyHandler with a
new private helper `u256_to_offset(U256) -> Option<usize>`. The helper
only accepts a U256 whose upper three 64-bit limbs are zero and whose
low limb fits in usize on the target platform.

On None, FRAMEDATALOAD pushes a zero word onto the stack and
FRAMEDATACOPY zero-fills the destination memory range — both
behaviours match the EIP-8141 spec requirement that out-of-range
offsets point past the end of the frame's data rather than raising
an exceptional halt.

The previous as_u64() truncation silently wrapped large offsets to
their low 64 bits, potentially reading garbage from the frame's data
or bypassing OOB checks entirely. After this change any large offset
is treated uniformly as past-the-end.

Adds two helper-level unit tests covering in-range and out-of-range
U256 values.
Pure refactor. Adds three named constants alongside the existing
FRAME_TX_INTRINSIC_COST / FRAME_TX_PER_FRAME_COST in
crates/common/types/transaction.rs:

- FRAME_TX_ENTRY_POINT_U64 (= 0xaa) — the u64 form of the
  ENTRY_POINT address used as caller for DEFAULT/VERIFY frames
  per EIP-8141.
- frame_tx_entry_point() — helper returning the `Address` form.
- FRAME_TX_MAX_FRAMES (= 64) — maximum frame count per spec.

Replaces the inline `Address::from_low_u64_be(0xaa)` at
crates/vm/levm/src/vm.rs:675 and the `> 64` check at
crates/common/types/transaction.rs validate_static_constraints with
the named constants. No behavioural change.
Per the EIP-8141 default-code spec (see /tmp/eip-8141-spec.md around
line 405-409), a SENDER-mode frame whose resolved target is not
tx.sender must succeed with empty output. It targets an empty-code
account and behaves like a plain EVM CALL to an EOA — any ETH transfer
carried by `frame.value` has already been applied by the outer frame
entry in execute_frame_tx (the H1 value-field workstream wires that
part up).

Before this change, crates/vm/levm/src/opcode_handlers/frame_tx.rs
short-circuited such frames with `Ok((false, 0, Vec::new()))`, which
marked the frame as reverted and blocked the pending value-transfer
use case (spec Example 1a) from succeeding once H1 lands.

Switching to `Ok((true, 0, Vec::new()))` keeps the frame's gas_used
at zero (no default-code work ran) and lets the outer refund pipeline
credit the remainder back to the payer. The H1 wiring will then cause
the plain ETH transfer to move value as expected.

Adds four unit tests covering the H3 invariant: empty-data and
non-empty-data non-sender targets both return (true, 0, []); the
SENDER multicall path still returns a non-zero gas_used; DEFAULT mode
against an empty-code target continues to return the (false, 0, [])
revert shape so the relaxation does not accidentally widen.
Renames unit tests to describe the behaviour under test and rewrites
production-code comments to cite EIP-8141 by name or explain intent
directly. Drops references to transient planning labels that do not
belong in the repository.

Also removes four tests in opcode_handlers/frame_tx.rs tests module
that merely asserted fabricated tuple literals against themselves —
they did not exercise the production path and provided no real
coverage. The retained tests are the underflow-mapping and
u256_to_offset unit tests, which do exercise real behaviour.

No production-logic changes.
Extend the EIP-8141 Frame struct with a U256 value between gas_limit and
data so the RLP encoding matches the spec's 6-tuple [mode, flags, target,
gas_limit, value, data]. The RLP encoder, decoder, and the FrameEntry
serde mirror used for RPC output are updated in lockstep. The VERIFY
elision path in compute_sig_hash keeps the new field so signatures cover
the committed value; explicit tests for that come in a follow-up commit.
Add a static-validation rule so only SENDER frames may carry a non-zero
value, matching EIP-8141 spec line 140. The signature-hash elision path
already preserves value on VERIFY frames (added in the struct commit),
and a new test pins that behavior so a future refactor cannot silently
let a signer elide the committed value.
Extend the FRAMEPARAM handler so contracts can read the committed value
of any frame through the EIP-8141 parameter table (spec line 287). The
0x08 arm returns the frame's U256 value verbatim; non-SENDER frames
already enforce value==0 at static validation, so the new arm never
reveals a smuggled value.
Perform the EIP-8141 top-level value transfer in the outer frame loop
before the bytecode / default-code dispatch. The sender's balance is
checked first; on insufficient funds the frame reverts immediately with
the per-frame backup unwound (spec lines 346-347). On success the
transfer runs through the same substate/backup machinery that covers any
inner state change, so a later run_execution revert or an atomic-batch
revert undoes the transfer cleanly.

The bytecode branch now passes frame.value as msg_value so the contract
sees the correct CALLVALUE, but keeps should_transfer_value at false
because the outer loop has already moved the funds. The default-code
SENDER target-not-sender branch was already made a no-op in the preceding
stack, so the outer transfer is the sole delivery path there.
Pin the invariant that any state change produced by an in-batch frame
(including the outer-owned frame.value transfer and its EIP-7708 log)
is undone by the batch-level revert_backup call when a later frame
fails. The test drives the Substate primitive directly so the backup/
revert path cannot regress without breaking this assertion.
Before this change, `execute_frame_tx` fetched the resolved target's code
with `db.get_account_code(target)`. For an EIP-7702-delegated EOA that
returns the 23-byte delegation indicator (`0xef01 00 || auth_address`),
which is non-empty, so the CallFrame branch ran the indicator as EVM
bytecode — the first opcode `0xef` is INVALID and halted the frame,
consuming the whole `gas_limit`. Any frame tx targeting a 7702-delegated
account failed unconditionally.

EIP-8141 §Execution step 1 (lines 348-351) instead requires: if
`resolved_target` has a delegation indicator, execute per EIP-7702's
delegated-code semantics. The delegatee's code runs, but ADDRESS and
storage stay tied to the delegator. Default code runs only when the
target has NEITHER code NOR a delegation.

Fix: swap the fetch for `utils::eip7702_get_code`, thread the resolved
`code_address` into `CallFrame::new` (with `to` still being the
delegator), tighten the default-code guard to
`bytecode.is_empty() && !is_delegation_7702` so a delegation to an
empty delegatee returns empty-code success rather than falling into
default-code, and mirror default_hook.rs by recording the delegatee in
the BAL recorder when delegation is followed. access_cost is
intentionally discarded, matching default_hook.rs at tx entry — EIP-8141
does not explicitly bill the 7702 access cost for resolved_target at
frame entry, and a frame entry from ENTRY_POINT / tx.sender is the
analogue of a top-level tx entry, not an intermediate CALL opcode.

Covered by four inline unit tests under
`frame_tx_7702_delegation_tests` that pin the spec decision table
(§5 of the mitigation plan) by invoking eip7702_get_code on the four
target shapes: a 7702-delegated EOA with a non-empty delegatee
(routes to CallFrame branch, returns delegatee's code), a delegation
to an empty delegatee (routes to CallFrame branch, NOT default code),
a plain EOA (routes to default code), and a normal contract (routes
to CallFrame branch unchanged). This is the tightest regression guard
against the 0xef-as-opcode halt that motivated the fix.
Reverts the Phase 0 commit 9d391e5. The gate was correct per the
EIP-8141 spec's `requires: 7997` header, but the Ethereum-package
Kurtosis config activates Osaka (`fulu_fork_epoch: 0`) without setting
Amsterdam, which left frame-tx admission blocked on every local
reproduction of the devnet. Rather than keep patching the ethereum-
package genesis template and the bash-mode dev block-producer's V4
engine API path to unstick Amsterdam-era blocks, the gate is removed
until the fork plumbing is in place in the surrounding tooling.

Removes the `FrameTxPreFork` variants from both the mempool and the
VM error enums, the mempool-admission guard, the payload-builder skip
+ pool removal, the `execute_frame_tx` pre-fork early return, and the
four Amsterdam-specific unit tests. The two general-purpose frame-tx
tests introduced alongside Phase 0 (`frame_transaction_rlp_roundtrip_
preserves_fields` and `frame_transaction_variant_is_exposed_on_
transaction_enum`) stay — they exercise behaviour independent of the
fork gate.

All other mitigation commits (H3 target relaxation, H4 revert-on-
underflow, M1/M2 gas bounds, M3 offset safety, L2 named constants,
H1 value field + 6-field RLP, H2 7702 delegation resolution) remain
in place.
…RLP batch path

Per spec d56e73ad (commit d3a30ad4 "remove the rlp call batch for default
account"), the SENDER mode in default code now reverts unconditionally.
Remove execute_default_sender and the SenderCall RLP struct, collapse
execute_default_code to a two-arm match, and drop the now-unused sender
parameter at the call site. Document the EIP-3607 bypass in execute_frame_tx
per the new "Transaction origination" subsection (commit d56e73ad).
…ipped frames + cleanup

Spec commit 235d5056 ("Frames cleanup") introduced:
  - A new frame-receipt status code 0x3 for frames skipped due to a failed
    atomic batch.
  - Gas refund for skipped frames (spec line 185: "Since skipped frames are not
    executed, the gas value allotted to them is refunded at the end of the
    transaction"). Previously these frames were charged their full gas_limit.
  - A single `payer: Option<Address>` transaction-scoped variable replacing
    the previous `payer_approved: bool` flag.

Changes:
  - FrameReceipt.status: bool -> u8 with named constants FRAME_RECEIPT_STATUS_
    FAILURE (0), _SUCCESS (1), and _SKIPPED (3). The RLP encoding is unchanged
    for the existing 0/1 values.
  - FrameTxContext drops the redundant payer_approved bool; payer_address is
    the single source of truth (matches the spec's `payer != None` check).
  - execute_frame_tx's atomic-batch skip path now records (SKIPPED, 0, []) and
    does NOT add the skipped frame's gas_limit to total_gas_used, so it flows
    through the standard `sum(gas_limit) - total_gas_used` refund.
  - The pre-failure + failing frames in a failed batch keep status FAILURE
    (full gas charged), matching prior behavior; only post-failure skipped
    frames change.
  - FRAMEPARAM(0x05) returns the raw status code, naturally extending the
    existing 0/1 contract to also surface 0x3 for skipped frames.
  - Top-level tx success now requires every SENDER frame's status to be
    SUCCESS (failure or skipped counts as a reverted top-level execution).
  - RpcFrameReceipt.status: bool -> u8 with a new serde_utils::u8::hex_str
    helper so the JSON output preserves hex byte encoding.

Tests:
  - test_frame_receipt_skipped_status_rlp_roundtrip: SKIPPED roundtrips through
    RLP intact.
  - test_frame_receipt_skipped_disqualifies_succeeded: a SKIPPED frame causes
    Receipt.succeeded = false on decode.
edg-l and others added 16 commits June 16, 2026 09:40
…ts, docs; fix fee-bump availability pre-filter
# Conflicts:
#	crates/vm/levm/src/opcode_handlers/environment.rs
#	crates/vm/levm/src/opcode_handlers/system.rs
#	crates/vm/levm/src/vm.rs
- execute_frame_tx: delete self-destructed accounts at tx end (mirror
  finalize hook); append any EIP-7708 burn logs to the aggregate logs
- derive frame-tx top-level succeeded from ALL frames so encode matches
  the consensus-receipt decode (frame_receipt carries no mode)
- mempool: frame tx now evicts a same-(sender,nonce) non-frame predecessor
  instead of orphaning it
- fork_choice: run revalidate_frame_txs_after_block on spawn_blocking
- docs/eip-8141: unify succeeded semantics; add regression test
@github-actions github-actions Bot added L1 Ethereum client levm Lambda EVM implementation labels Jun 24, 2026
@github-actions

github-actions Bot commented Jun 24, 2026

Copy link
Copy Markdown

Lines of code report

Total lines added: 205
Total lines removed: 0
Total lines changed: 205

Detailed view
+-------------------------------------------------------+-------+------+
| File                                                  | Lines | Diff |
+-------------------------------------------------------+-------+------+
| ethrex/crates/blockchain/blockchain.rs                | 3003  | +8   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/common/types/transaction.rs             | 4907  | +73  |
+-------------------------------------------------------+-------+------+
| ethrex/crates/vm/backends/levm/mod.rs                 | 2802  | +31  |
+-------------------------------------------------------+-------+------+
| ethrex/crates/vm/backends/mod.rs                      | 381   | +1   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/gas_cost.rs                 | 893   | +1   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/opcode_handlers/frame_tx.rs | 444   | +9   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/vm.rs                       | 2205  | +60  |
+-------------------------------------------------------+-------+------+
| ethrex/crates/vm/system_contracts.rs                  | 117   | +22  |
+-------------------------------------------------------+-------+------+

@github-actions

github-actions Bot commented Jun 24, 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.529 ± 0.026 2.497 2.562 1.15 ± 0.05
main_levm_BubbleSort 2.226 ± 0.087 2.152 2.357 1.01 ± 0.06
pr_revm_BubbleSort 2.526 ± 0.039 2.491 2.613 1.14 ± 0.05
pr_levm_BubbleSort 2.208 ± 0.088 2.154 2.386 1.00

Benchmark Results: ERC20Approval

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Approval 822.3 ± 11.5 806.8 843.5 1.01 ± 0.02
main_levm_ERC20Approval 817.8 ± 13.0 802.1 835.5 1.00 ± 0.02
pr_revm_ERC20Approval 816.8 ± 6.4 807.9 830.3 1.00 ± 0.02
pr_levm_ERC20Approval 816.5 ± 13.5 802.5 841.4 1.00

Benchmark Results: ERC20Mint

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Mint 111.4 ± 1.9 109.7 115.9 1.01 ± 0.02
main_levm_ERC20Mint 125.5 ± 4.1 120.1 132.2 1.13 ± 0.04
pr_revm_ERC20Mint 110.6 ± 0.9 109.2 111.7 1.00
pr_levm_ERC20Mint 123.4 ± 4.6 119.4 134.3 1.12 ± 0.04

Benchmark Results: ERC20Transfer

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Transfer 195.3 ± 4.2 191.9 203.4 1.00 ± 0.03
main_levm_ERC20Transfer 206.2 ± 1.0 205.3 208.9 1.06 ± 0.02
pr_revm_ERC20Transfer 194.5 ± 2.9 191.5 200.1 1.00
pr_levm_ERC20Transfer 207.4 ± 2.4 204.4 210.9 1.07 ± 0.02

Benchmark Results: Factorial

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Factorial 176.9 ± 11.5 172.7 209.6 1.02 ± 0.07
main_levm_Factorial 192.4 ± 1.4 190.5 194.7 1.11 ± 0.01
pr_revm_Factorial 173.7 ± 0.6 172.7 174.7 1.00
pr_levm_Factorial 194.5 ± 8.4 189.3 216.3 1.12 ± 0.05

Benchmark Results: FactorialRecursive

Command Mean [s] Min [s] Max [s] Relative
main_revm_FactorialRecursive 1.262 ± 0.029 1.230 1.310 1.01 ± 0.03
main_levm_FactorialRecursive 7.961 ± 0.086 7.870 8.064 6.40 ± 0.13
pr_revm_FactorialRecursive 1.244 ± 0.020 1.206 1.276 1.00
pr_levm_FactorialRecursive 8.042 ± 0.025 8.011 8.086 6.47 ± 0.11

Benchmark Results: Fibonacci

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Fibonacci 167.8 ± 11.3 161.7 199.9 1.00
main_levm_Fibonacci 176.9 ± 3.2 174.1 182.5 1.05 ± 0.07
pr_revm_Fibonacci 170.8 ± 20.9 163.0 230.2 1.02 ± 0.14
pr_levm_Fibonacci 177.1 ± 2.8 174.8 183.1 1.06 ± 0.07

Benchmark Results: FibonacciRecursive

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_FibonacciRecursive 667.1 ± 10.9 647.1 689.5 1.20 ± 0.02
main_levm_FibonacciRecursive 558.3 ± 5.2 552.9 566.2 1.00 ± 0.01
pr_revm_FibonacciRecursive 659.6 ± 6.9 646.8 670.4 1.18 ± 0.02
pr_levm_FibonacciRecursive 556.9 ± 6.5 549.3 569.9 1.00

Benchmark Results: ManyHashes

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ManyHashes 7.1 ± 0.1 6.9 7.3 1.00
main_levm_ManyHashes 7.8 ± 0.1 7.7 8.0 1.11 ± 0.02
pr_revm_ManyHashes 7.1 ± 0.2 6.9 7.6 1.01 ± 0.03
pr_levm_ManyHashes 7.8 ± 0.2 7.7 8.4 1.11 ± 0.03

Benchmark Results: MstoreBench

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_MstoreBench 218.5 ± 7.3 213.9 236.0 1.38 ± 0.05
main_levm_MstoreBench 158.1 ± 1.4 156.5 160.2 1.00 ± 0.01
pr_revm_MstoreBench 218.6 ± 9.1 213.5 242.9 1.39 ± 0.06
pr_levm_MstoreBench 157.8 ± 1.4 156.1 160.4 1.00

Benchmark Results: Push

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Push 244.3 ± 1.2 242.9 246.0 1.24 ± 0.01
main_levm_Push 197.7 ± 1.2 196.2 200.2 1.00 ± 0.01
pr_revm_Push 254.1 ± 29.1 243.6 336.9 1.29 ± 0.15
pr_levm_Push 197.3 ± 0.9 196.3 199.0 1.00

Benchmark Results: SstoreBench_no_opt

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_SstoreBench_no_opt 139.9 ± 1.7 138.5 142.5 1.62 ± 0.02
main_levm_SstoreBench_no_opt 86.9 ± 0.9 86.0 88.5 1.01 ± 0.01
pr_revm_SstoreBench_no_opt 139.4 ± 1.9 137.8 142.3 1.61 ± 0.02
pr_levm_SstoreBench_no_opt 86.4 ± 0.2 86.2 86.8 1.00

Replace the single linear sender nonce of an EIP-8141 frame transaction (tx type
0x06) with a keyed-nonce model: the envelope carries `nonce_keys` (1..=16 strictly
increasing uint256 keys) plus a single `nonce_seq`. For each selected key the
protocol checks `current_nonce_seq(sender, key) == nonce_seq` and advances it on
payment approval. Key 0 is the legacy linear-nonce domain (the account nonce);
non-zero keys live in the NONCE_MANAGER predeploy at 0x…8250, installed at the
Hegota boundary (nonce = max(existing, 1), balance preserved, idempotent).

Adds the envelope/RLP/rkyv/serde changes, the predeploy and its install, keyed-nonce
validation (including the rule that key 0 is valid only as the sole key) and
consumption, the new TXPARAM indices, key-0-only public-mempool admission, and unit
+ integration tests. Activates under Fork::Hegota. See docs/eip-8250.md.
ilitteri added 3 commits June 26, 2026 12:58
current_nonce_seq read a full U256 NONCE_MANAGER slot but returned low_u64(),
dropping the high 192 bits before the keyed-nonce equality check. The spec compares
the full uint256, so a crafted slot value with non-zero high bits could truncate to
a low u64 that spuriously matches the declared nonce_seq. Map any value above
u64::MAX to u64::MAX, which can never equal a valid nonce_seq (static validation
rejects nonce_seq == u64::MAX), guaranteeing the intended mismatch.
…lity)

EIP-8250 requires the five effects of a successful payment APPROVE (nonce
consumption, payer recording, the max-cost debit, first-use gas, approval
flags) to be durable together against a frame revert, skipped frames, or an
atomic-batch snapshot restore. The atomic-batch clause only bites when the
APPROVE itself runs inside a batch. Making all five survive that revert
needs the balance debit re-asserted as an idempotent delta across nested
reverts, coherent with the end-of-tx refund and EIP-7928 BAL — unpinned by
the draft and unvalidated cross-client. Shipping only part of it (nonce and
flags durable, debit reverted) strands the authorization over an un-charged
payer and mints the refund.

So reject a payment-scoped APPROVE whose frame is in an atomic batch:
the APPROVE reverts, payer stays unset, the tx is invalid. execute_frame_tx
never enforced the mempool's prefix structure, so a crafted block could
otherwise reach this at consensus; the mempool already bans the batch flag
in the validation prefix where payment is granted. The legitimate case —
payment in a non-batch frame, a later SENDER batch reverting — keeps its
consumption natively, because an independent frame's committed state is
absorbed into the tx-level backup and is outside the batch's revert scope.

Full in-batch-payment durability stays deferred for cross-client interop
validation (docs/eip-8250.md divergence #3).

Ported onto eip-8250 from the consensus-core branch. This branch charges
the payer at the effective gas rate (compute_tx_cost) rather than the max
cost, so the guard comments and divergence #3 say "balance debit" instead
of "max-cost debit"; the tests' FrameTransaction constructor omits the
recent_root_references field, which does not exist here (no EIP-8272).
The EIP-8250 commit replaced the envelope's single nonce with
nonce_keys + nonce_seq, changing the canonical RLP (c180 07 for
nonce_keys=[0], nonce_seq=7 where the old layout had the plain nonce)
and therefore the sig_hash, but the golden constants were never
regenerated on this branch, leaving the test permanently red. The new
bytes match the verified hegota-devnet golden minus the EIP-8272
recent_root_references field that this branch does not carry.
Base automatically changed from eip-8141-1 to main July 3, 2026 14:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L1 Ethereum client levm Lambda EVM implementation

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants