From 80bc8d51fdb95a78d3f74c034764d5e0c96895d2 Mon Sep 17 00:00:00 2001 From: spencer-tb Date: Thu, 9 Jul 2026 15:45:48 +0100 Subject: [PATCH 1/2] fix(spec-specs, tests): exclude unloaded recipient from the BAL (cherry picked from commit 89d15f3d447aec9ae5189b143d7a106cfd73c0c5) --- src/ethereum/forks/amsterdam/utils/message.py | 6 +- .../test_authorization_oog.py | 21 +++-- .../test_block_access_lists_eip7702.py | 91 +++++++++++++++++++ .../test_cases.md | 1 + 4 files changed, 105 insertions(+), 14 deletions(-) diff --git a/src/ethereum/forks/amsterdam/utils/message.py b/src/ethereum/forks/amsterdam/utils/message.py index 0c442e007d5..89e7d68a53c 100644 --- a/src/ethereum/forks/amsterdam/utils/message.py +++ b/src/ethereum/forks/amsterdam/utils/message.py @@ -17,7 +17,7 @@ from ethereum.state import Address -from ..state_tracker import get_account, get_code +from ..state_tracker import get_account from ..transactions import Transaction from ..vm import BlockEnvironment, Message, TransactionEnvironment from ..vm.precompiled_contracts.mapping import PRE_COMPILED_CONTRACTS @@ -63,9 +63,7 @@ def prepare_message( elif isinstance(tx.to, Address): current_target = tx.to msg_data = tx.data - code = get_code( - tx_env.state, get_account(tx_env.state, tx.to).code_hash - ) + code = Bytes(b"") code_address = tx.to else: raise AssertionError("Target must be address or empty bytes") diff --git a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_authorization_oog.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_authorization_oog.py index 2d9cf09f282..8e595164f42 100644 --- a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_authorization_oog.py +++ b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_authorization_oog.py @@ -162,10 +162,10 @@ def test_set_delegation_oog_charge_point( state. The sender pays the full ``gas_limit`` and its nonce is not rolled back. - The recipient and both authorities were accessed before the halt - (the recipient at inclusion, the authorities during validation), so - per EIP-7928 all three must still appear in the block access list, - with no recorded changes. + Both authorities are read during authorization validation before + the halt, so per EIP-7928 they still appear in the block access + list with no recorded changes. The recipient is only loaded by the + top-frame dispatch, which the halt precedes, so it must be absent. """ gas_costs = fork.gas_costs() sender_initial_balance = 10**18 @@ -230,10 +230,11 @@ def test_set_delegation_oog_charge_point( } # An implementation recording accesses only for dispatched frames - # would drop these entries and fork on the BAL hash. + # would drop the authority entries; one recording the recipient at + # inclusion would add it. Either forks on the BAL hash. expected_block_access_list = BlockAccessListExpectation( account_expectations={ - recipient: BalAccountExpectation.empty(), + recipient: None, first.authority: BalAccountExpectation.empty(), second.authority: BalAccountExpectation.empty(), } @@ -284,9 +285,9 @@ def test_set_delegation_oog_rolls_back_first_auth( The creation-first case is covered by ``test_set_delegation_oog_charge_point[new_account]``. - The recipient and both authorities were accessed before the halt, - so per EIP-7928 all three must still appear in the block access - list, with no recorded changes. + Both authorities are read during validation before the halt and + stay in the block access list with no recorded changes; the + recipient, never loaded before the halt, must be absent. """ gas_costs = fork.gas_costs() sender_initial_balance = 10**18 @@ -330,7 +331,7 @@ def test_set_delegation_oog_rolls_back_first_auth( expected_block_access_list = BlockAccessListExpectation( account_expectations={ - recipient: BalAccountExpectation.empty(), + recipient: None, first.authority: BalAccountExpectation.empty(), second.authority: BalAccountExpectation.empty(), } diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7702.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7702.py index 7bfd0d43efa..e60859931cf 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7702.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7702.py @@ -22,6 +22,7 @@ Initcode, Op, RecipientType, + StateTestFiller, Transaction, Withdrawal, compute_create_address, @@ -31,6 +32,10 @@ ) from ...prague.eip7702_set_code_tx.spec import Spec as Spec7702 +from ..eip2780_reduce_intrinsic_tx_gas.helpers import ( + AuthorizationAction, + build_authorization, +) from .spec import ref_spec_7928 REFERENCE_SPEC_GIT_PATH = ref_spec_7928.git_path @@ -547,6 +552,92 @@ def test_bal_7702_top_frame_delegation_oog( ) +@pytest.mark.parametrize( + "outcome", + [ + pytest.param("oog", id="oog_at_authorization_charge"), + pytest.param("success", id="success"), + ], +) +def test_bal_7702_recipient_excluded_on_authorization_oog( + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, + outcome: str, +) -> None: + """ + Ensure ``tx.to`` enters the BAL only when authorization processing + completes. + + The single authorization is starved at its opening ``NEW_ACCOUNT`` + charge, halting the transaction before the top-frame dispatch loads + the recipient: the recipient must be absent from the BAL, while the + authority -- read during authorization validation -- stays in it + with no recorded changes. + """ + sender = pre.fund_eoa() + recipient = pre.deploy_contract(code=Op.STOP) + + auth = build_authorization(pre, AuthorizationAction.CREATES_ACCOUNT) + authorization_list = [auth.authorization] + + intrinsic_regular = fork.transaction_intrinsic_cost_calculator()( + recipient_type=RecipientType.CONTRACT, + authorization_list_or_count=authorization_list, + return_cost_deducted_prior_execution=True, + ) + + recipient_expectation: BalAccountExpectation | None + expected_authority: Account | None + if outcome == "oog": + # The authorization runs out at its opening NEW_ACCOUNT state + # charge, drawn from gas_left under the zero state reservoir. + gas_limit = intrinsic_regular + fork.gas_costs().NEW_ACCOUNT - 1 + recipient_expectation = None + authority_expectation = BalAccountExpectation.empty() + expected_authority = auth.original_account + else: + top_frame_regular = fork.transaction_top_frame_gas_calculator()( + recipient_type=RecipientType.CONTRACT, + authorizations=authorization_list, + ) + top_frame_state = fork.transaction_top_frame_state_gas( + recipient_type=RecipientType.CONTRACT, + authorizations=authorization_list, + ) + gas_limit = intrinsic_regular + top_frame_regular + top_frame_state + recipient_expectation = BalAccountExpectation.empty() + authority_expectation = BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], + code_changes=[ + BalCodeChange( + block_access_index=1, + new_code=auth.applied_account.code, + ) + ], + ) + expected_authority = auth.applied_account + + tx = Transaction( + sender=sender, + to=recipient, + authorization_list=authorization_list, + gas_limit=gas_limit, + ) + + state_test( + pre=pre, + tx=tx, + post={sender: Account(nonce=1), auth.authority: expected_authority}, + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + recipient: recipient_expectation, + auth.authority: authority_expectation, + } + ), + ) + + def test_bal_7702_invalid_nonce_authorization( pre: Alloc, blockchain_test: BlockchainTestFiller, diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index db48db7c53a..6bcc98b121a 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -51,6 +51,7 @@ | `test_bal_7702_delegation_clear` | Ensure BAL captures clearing of EOA delegation | Alice first delegates to `Oracle`, then in second tx clears delegation by authorizing to `0x0` address. Each transaction sends 10 wei to Bob. Two variants: (1) Self-funded: Alice sends both 7702 txs herself. (2) Sponsored: `Relayer` sends both 7702 txs on Alice's behalf. | BAL **MUST** include Alice: first tx has `code_changes` (delegation designation `0xef0100\|\|address(Oracle)`), `nonce_changes`. Second tx has `code_changes` (empty code - delegation cleared), `nonce_changes`. Bob: `balance_changes` (receives 10 wei on each tx). For sponsored variant, BAL **MUST** also include `Relayer`: `nonce_changes` for both transactions. `Oracle` and `0x0` address **MUST NOT** be present in BAL - accounts are never accessed. | ✅ Completed | | `test_bal_7702_delegated_storage_access` | Ensure BAL captures storage operations when calling a delegated EIP-7702 account | Alice has delegated her account to `Oracle`. `Oracle` contract contains code that reads from storage slot `0x01` and writes to storage slot `0x02`. Bob sends 10 wei to Alice (the delegated account), which executes `Oracle`'s code. | BAL **MUST** include Alice: `balance_changes` (receives 10 wei), `storage_changes` for slot `0x02` (write operation performed in Alice's storage), `storage_reads` for slot `0x01` (read operation from Alice's storage). Bob: `nonce_changes` (sender), `balance_changes` (loses 10 wei plus gas costs). `Oracle` (account access). | ✅ Completed | | `test_bal_7702_top_frame_delegation_oog` | Ensure the delegation target of a delegated `tx.to` enters the BAL only when gas covers the top-frame delegation charge ([EIP-2780](https://eips.ethereum.org/EIPS/eip-2780) runtime charge) | `target` holds a pre-existing delegation to `delegated_to`. Sender sends a transaction to `target`. Parametrized: (1) `oog_at_delegation_charge`: gas limit is one short of intrinsic + top-frame delegation charge, (2) `success`: gas covers the charge and the delegated `STOP` runs. | For case (1): BAL **MUST** include `target` (recipient touch) but **MUST NOT** include `delegated_to` (the charge fails before the target is accessed). For case (2): BAL **MUST** include both `target` and `delegated_to` with empty changes. Sender always has `nonce_changes`. | ✅ Completed | +| `test_bal_7702_recipient_excluded_on_authorization_oog` | Ensure `tx.to` enters the BAL only when authorization processing completes ([EIP-2780](https://eips.ethereum.org/EIPS/eip-2780) runtime charges precede the recipient load) | Sender sends a transaction with one authorization (fresh authority) to a `STOP` contract. Parametrized: (1) `oog_at_authorization_charge`: gas limit is one short of the authorization's opening `NEW_ACCOUNT` charge, (2) `success`: gas covers the top-frame charges and the delegation applies. | For case (1): BAL **MUST NOT** include the recipient (the halt precedes its load) but **MUST** include the authority with empty changes (read during validation). For case (2): BAL **MUST** include the recipient with empty changes and the authority with `nonce_changes` and `code_changes`. | ✅ Completed | | `test_bal_7702_invalid_nonce_authorization` | Ensure BAL handles failed authorization due to wrong nonce | `Relayer` sends sponsored transaction to Bob (10 wei transfer succeeds) but Alice's authorization to delegate to `Oracle` uses incorrect nonce, causing silent authorization failure | BAL **MUST** include Alice with empty changes (account access), Bob with `balance_changes` (receives 10 wei), Relayer with `nonce_changes`. **MUST NOT** include `Oracle` (authorization failed, no delegation) | ✅ Completed | | `test_bal_7702_invalid_chain_id_authorization` | Ensure BAL handles failed authorization due to wrong chain id | `Relayer` sends sponsored transaction to Bob (10 wei transfer succeeds) but Alice's authorization to delegate to `Oracle` uses incorrect chain id, causing authorization failure before account access | BAL **MUST** include Bob with `balance_changes` (receives 10 wei), Relayer with `nonce_changes`. **MUST NOT** include Alice (authorization fails before loading account) or `Oracle` (authorization failed, no delegation) | ✅ Completed | | `test_call_into_self_delegating_set_code` | Self-delegation degenerate one-hop case (companion to `test_call_into_chain_delegating_set_code`). File: `tests/prague/eip7702_set_code_tx/test_set_code_txs.py`. Parametrized over `@pytest.mark.with_all_call_opcodes`. | `auth_signer` auths itself as its own delegation target. `entry_address` issues `call_opcode(auth_signer)`. EVM resolves once: `auth_signer`'s code is the designator pointing back to `auth_signer`; the second hop is not followed, so the `0xef0100...` bytecode runs as legacy code → INVALID → returns 0. | `auth_signer` **MUST** appear with `nonce_changes` and `code_changes` (delegation designator to itself). `entry_address` **MUST** have `storage_reads=[0]` (no-op SSTORE demoted). No additional delegation-target entry is created because the target coincides with the authority. | ✅ Completed | From fed59ea616e7a77b72e0568268277ded8c3daf54 Mon Sep 17 00:00:00 2001 From: spencer-tb Date: Thu, 9 Jul 2026 16:42:41 +0100 Subject: [PATCH 2/2] refactor(spec-specs): represent unresolved top-frame code as None --- src/ethereum/forks/amsterdam/utils/message.py | 2 +- src/ethereum/forks/amsterdam/vm/__init__.py | 2 +- src/ethereum/forks/amsterdam/vm/interpreter.py | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/ethereum/forks/amsterdam/utils/message.py b/src/ethereum/forks/amsterdam/utils/message.py index 89e7d68a53c..a0387240d0b 100644 --- a/src/ethereum/forks/amsterdam/utils/message.py +++ b/src/ethereum/forks/amsterdam/utils/message.py @@ -63,7 +63,7 @@ def prepare_message( elif isinstance(tx.to, Address): current_target = tx.to msg_data = tx.data - code = Bytes(b"") + code = None code_address = tx.to else: raise AssertionError("Target must be address or empty bytes") diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index 673c7439e84..62a5abdd305 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -151,7 +151,7 @@ class Message: value: U256 data: Bytes code_address: Optional[Address] - code: Bytes + code: Optional[Bytes] depth: Uint should_transfer_value: bool is_static: bool diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index 85f31906b87..3b492d73c6d 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -305,8 +305,6 @@ def prepare_dispatch(evm: Evm) -> None: ) else: message.code = recipient_code - evm.code = message.code - evm.valid_jump_destinations = get_valid_jump_destinations(message.code) def process_message(message: Message) -> Evm: @@ -328,16 +326,14 @@ def process_message(message: Message) -> Evm: if message.depth > STACK_DEPTH_LIMIT: raise StackDepthLimitError("Stack depth limit reached") - code = message.code - valid_jump_destinations = get_valid_jump_destinations(code) evm = Evm( pc=Uint(0), stack=[], memory=bytearray(), - code=code, + code=Bytes(b""), gas_left=message.gas, state_gas_left=message.state_gas_reservoir, - valid_jump_destinations=valid_jump_destinations, + valid_jump_destinations=set(), logs=(), refund_counter=0, running=True, @@ -374,6 +370,10 @@ def process_message(message: Message) -> Evm: evm.error = error return evm + assert message.code is not None + evm.code = message.code + evm.valid_jump_destinations = get_valid_jump_destinations(message.code) + snapshot = copy_tx_state(tx_state) # Execute message code and handle errors