Conversation
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.
…grate frame-tx tests to test/
…ts, docs; fix fee-bump availability pre-filter
…elper, clone-on-Copy, hex grouping)
…e-tx fee-bump; frame-tx cleanups
# 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
Lines of code reportTotal lines added: Detailed view |
Benchmark Results ComparisonNo significant difference was registered for any benchmark run. Detailed ResultsBenchmark Results: BubbleSort
Benchmark Results: ERC20Approval
Benchmark Results: ERC20Mint
Benchmark Results: ERC20Transfer
Benchmark Results: Factorial
Benchmark Results: FactorialRecursive
Benchmark Results: Fibonacci
Benchmark Results: FibonacciRecursive
Benchmark Results: ManyHashes
Benchmark Results: MstoreBench
Benchmark Results: Push
Benchmark Results: SstoreBench_no_opt
|
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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. Seedocs/eip-8250.mdfor the full implementation notes and divergence list.Description
The frame-transaction envelope's
nonce: u64is replaced bynonce_keys: Vec<U256>(1..=16 strictly-increasinguint256keys) plus a singlenonce_seq: u64. For every selected key the protocol checkscurrent_nonce_seq(sender, key) == nonce_seqand, on payment approval, advances each key's sequence by one. Key0is the legacy linear nonce domain (the account'snonce); non-zero keys live in the newNONCE_MANAGERpredeploy.Envelope and encodings (
crates/common/types/transaction.rs)FrameTransactionnow holdsnonce_keys/nonce_seq(rkyv viaMap<U256Wrapper>); RLP encode/decode,compute_sig_hash, serde (nonceKeys/nonceSeq), andGenericTransactionupdated accordingly.Transaction::nonce()returnsnonce_seqfor frame txs (used for mempool ordering / RPC; key-0 txs usenonce_seqas 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_keyscount in1..=16, strictly increasing, andnonce_seq != u64::MAX.NONCE_MANAGER predeploy (
crates/vm/system_contracts.rs,crates/vm/backends/levm/mod.rs,crates/vm/backends/mod.rs)NONCE_MANAGER_PREDEPLOYat address0x…8250,active_since_fork = Hegota, runtime bytecode0x6000600 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-8141install_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 linearnonce; non-zero keys readNONCE_MANAGER[slot].current_nonce_seq == nonce_seqelseNonceMismatch.consume_keyed_nonces(sender)replaces theincrement_account_nonce(sender)call in bothAPPROVE_PAYMENTandAPPROVE_EXECUTION_AND_PAYMENThandlers: key 0 increments the account's linear nonce; non-zero keys writenonce_seq + 1toNONCE_MANAGERstorage and chargeKEYED_NONCE_FIRST_USE_GAS(20000) on the first use of a key (slot0 -> nonzero, matching SSTORE storage-creation cost).FrameTxContextcaptureslegacy_sender_nonceat tx entry for the new TXPARAM.TXPARAM additions (
crates/vm/levm/src/opcode_handlers/frame_tx.rs)0x01re-pointed fromnoncetononce_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)nonce_keys == [0]); other keysets are rejected withInvalidFrameTransaction. 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, andnonce_seq == u64::MAXare rejected.nonce_keys_hash_matches_spec_formula—nonce_keys_hash()matches the BE-length-prefixed keccak formula.nonce_manager_constants_match_spec— predeploy address0x8250, runtime bytecode, andactive_since_fork == Hegota.nonce_keys/nonce_seq, plus the existing EIP-8141 frame-tx static-constraint and SENDER-only-value tests carried forward.How to Test
Notes / Divergences
All items below are documented in
docs/eip-8250.md.nonce_keys[0]at0x10, not0x0B. The EIP-8250 draft assigns0x0Btononce_keys[0], but EIP-8141 already defines (and ethrex ships)0x0B = len(signatures)with no replacement index. ethrex keeps0x0B = len(signatures)and placesnonce_keys[0]at the free index0x10. An authoritative 8141-family TXPARAM registry is needed upstream.NONCE_MANAGERaddress0x…8250. The draft leaves the addressTBD; ethrex uses the0x…<EIP>convention (matchingEXPIRY_VERIFIER = 0x8141).increment_account_noncepath 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-CREATEnonce semantics — is a consensus design decision tracked for Hegotá devnet + cross-client interop validation.nonce_keys == [0]is admitted to the public pool (see Description); multi-key / non-zero-key admission is deferred.The
nonce_seqoverflow guard (nonce_seq != u64::MAXstatic rejection;checked_add(1)at consumption) is in place so the sequence advance cannot wrap.Related
docs/eip-8250.md.0x06); this PR targets theeip-8141-1base branch.Fork::Hegota, shared with the EIP-8141 frame-transaction and EXPIRY_VERIFIER machinery.Checklist
STORE_SCHEMA_VERSION(crates/storage/lib.rs) if the PR includes breaking changes to theStorerequiring a re-sync. (N/A — execution-layer logic; noStoreschema change.)