diff --git a/packages/testing/src/execution_testing/test_types/block_access_list/modifiers.py b/packages/testing/src/execution_testing/test_types/block_access_list/modifiers.py index 2777f5c5e27..be29df9ac51 100644 --- a/packages/testing/src/execution_testing/test_types/block_access_list/modifiers.py +++ b/packages/testing/src/execution_testing/test_types/block_access_list/modifiers.py @@ -736,6 +736,64 @@ def transform(bal: BlockAccessList) -> BlockAccessList: return transform +def remove_slot_change( + address: Address, slot: int, block_access_index: int +) -> Callable[[BlockAccessList], BlockAccessList]: + """ + Remove a single slot change entry at a given block access index, while + keeping any other slot_changes entries for that same slot intact. + + Unlike `remove_storage`, which drops all storage_changes for an + account, this targets one entry within one slot's slot_changes list. + Useful for testing that a slot's earliest recorded change must match + the transaction that actually performed it. + """ + + def transform(bal: BlockAccessList) -> BlockAccessList: + found_address = False + found_slot = False + found_index = False + new_root = [] + for account_change in bal.root: + if account_change.address == address: + found_address = True + new_account = account_change.model_copy(deep=True) + for storage_slot in new_account.storage_changes: + if storage_slot.slot != slot: + continue + found_slot = True + remaining = [ + change + for change in storage_slot.slot_changes + if change.block_access_index != block_access_index + ] + if len(remaining) != len(storage_slot.slot_changes): + found_index = True + storage_slot.slot_changes = remaining + new_root.append(new_account) + else: + new_root.append(account_change) + + if not found_address: + raise ValueError( + f"Address {address} not found in BAL to remove slot change" + ) + if not found_slot: + raise ValueError( + f"Storage slot {slot} not found in storage_changes of " + f"account {address}" + ) + if not found_index: + raise ValueError( + f"Block access index {block_access_index} not found in " + f"storage slot {slot} of account {address}" + ) + + return BlockAccessList(root=new_root) + + return transform + + def reverse_accounts() -> Callable[[BlockAccessList], BlockAccessList]: """Reverse the order of accounts in the BAL.""" @@ -831,6 +889,7 @@ def transform(bal: BlockAccessList) -> BlockAccessList: "modify_code", # Block access index modifiers "swap_bal_indices", + "remove_slot_change", # Duplicate entry modifiers (uniqueness constraint testing) "duplicate_nonce_change", "duplicate_balance_change", diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_invalid.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_invalid.py index 4a06f085c70..33f0019e2ef 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_invalid.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_invalid.py @@ -57,6 +57,7 @@ remove_balances, remove_code, remove_nonces, + remove_slot_change, remove_storage, remove_storage_reads, reverse_accounts, @@ -1730,3 +1731,378 @@ def test_bal_invalid_engine_payload_encoding( ) ], ) + + +@pytest.mark.valid_from("Amsterdam") +@pytest.mark.exception_test +def test_bal_invalid_noop_storage_change( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """ + Test that clients reject a BAL storage change whose post-value equals + the value already present at the start of the transaction. + + Oracle only reads storage slot 0 (value stays 0x42 throughout). A + canonical BAL builder never records a storage_changes entry for a + slot whose value never actually changes. The BAL is corrupted by + appending a spurious round-trip storage change with a post_value + equal to the untouched value. + """ + alice = pre.fund_eoa() + oracle = pre.deploy_contract(code=Op.SLOAD(0), storage={0: 0x42}) + + tx = Transaction(sender=alice, to=oracle) + + blockchain_test( + pre=pre, + # The block reverts and the post state remains unchanged. + post=pre, + blocks=[ + Block( + txs=[tx], + exception=BlockException.INVALID_BLOCK_ACCESS_LIST, + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange( + block_access_index=1, post_nonce=1 + ) + ], + ), + oracle: BalAccountExpectation( + storage_reads=[0], + ), + } + ).modify( + append_storage( + address=oracle, + slot=0, + change=BalStorageChange( + block_access_index=1, post_value=0x42 + ), + ) + ), + ) + ], + ) + + +@pytest.mark.valid_from("Amsterdam") +@pytest.mark.exception_test +@pytest.mark.parametrize( + "modifier", + [ + pytest.param( + lambda oracle, code: append_change( # noqa: ARG005 + account=oracle, + change=BalBalanceChange(block_access_index=1, post_balance=0), + ), + id="noop_balance_change", + ), + pytest.param( + lambda oracle, code: append_change( # noqa: ARG005 + account=oracle, + change=BalNonceChange(block_access_index=1, post_nonce=1), + ), + id="noop_nonce_change", + ), + pytest.param( + lambda oracle, code: append_change( + account=oracle, + change=BalCodeChange(block_access_index=1, new_code=code), + ), + id="noop_code_change", + ), + ], +) +def test_bal_invalid_noop_value_change( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + modifier: Callable, +) -> None: + """ + Test that clients reject a BAL balance/nonce/code change entry whose + post-value equals the account's real, unchanged value. + + Oracle legitimately appears in the BAL only via a storage read (slot + 0 is read, not written). Its balance (0), nonce (1), and code are + never touched by the transaction. The BAL is corrupted by appending + a change entry for one of these fields whose post-value equals the + account's actual unchanged value -- distinct from the wrong-value + corruption covered elsewhere, since here post == pre exactly. + """ + alice = pre.fund_eoa() + code = Op.SLOAD(0) + oracle = pre.deploy_contract(code=code, storage={0: 0x42}) + + tx = Transaction(sender=alice, to=oracle) + + blockchain_test( + pre=pre, + # The block reverts and the post state remains unchanged. + post=pre, + blocks=[ + Block( + txs=[tx], + exception=BlockException.INVALID_BLOCK_ACCESS_LIST, + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange( + block_access_index=1, post_nonce=1 + ) + ], + ), + oracle: BalAccountExpectation( + storage_reads=[0], + ), + } + ).modify(modifier(oracle=oracle, code=code)), + ) + ], + ) + + +@pytest.mark.valid_from("Amsterdam") +@pytest.mark.exception_test +def test_bal_invalid_missing_storage_write( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """ + Test that clients reject a BAL that omits a storage write that was + actually performed. + + Writer's storage slot 0 goes from 0 (default) to 1. The BAL is + corrupted by removing the account's storage_changes entirely. + """ + alice = pre.fund_eoa() + writer = pre.deploy_contract(code=Op.SSTORE(0, 1)) + + tx = Transaction(sender=alice, to=writer) + + blockchain_test( + pre=pre, + # The block reverts and the post state remains unchanged. + post=pre, + blocks=[ + Block( + txs=[tx], + exception=BlockException.INVALID_BLOCK_ACCESS_LIST, + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange( + block_access_index=1, post_nonce=1 + ) + ], + ), + writer: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0, + slot_changes=[ + BalStorageChange( + block_access_index=1, + post_value=1, + ) + ], + ), + ], + ), + } + ).modify(remove_storage(writer)), + ) + ], + ) + + +@pytest.mark.valid_from("Amsterdam") +@pytest.mark.exception_test +def test_bal_invalid_missing_created_code( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """ + Test that clients reject a BAL that omits the deployed code of a + contract created via a contract-creation transaction. + """ + alice = pre.fund_eoa() + runtime_code = Op.STOP + initcode = Initcode(deploy_code=runtime_code) + created = compute_create_address(address=alice) + + tx = Transaction(sender=alice, to=None, data=initcode) + + blockchain_test( + pre=pre, + # The block reverts and the post state remains unchanged. + post=pre, + blocks=[ + Block( + txs=[tx], + exception=BlockException.INVALID_BLOCK_ACCESS_LIST, + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange( + block_access_index=1, post_nonce=1 + ) + ], + ), + created: BalAccountExpectation( + code_changes=[ + BalCodeChange( + block_access_index=1, + new_code=runtime_code, + ) + ], + ), + } + ).modify(remove_code(created)), + ) + ], + ) + + +@pytest.mark.valid_from("Amsterdam") +@pytest.mark.exception_test +def test_bal_invalid_omitted_slot_change_at_index( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """ + Test that clients reject a BAL that drops a slot's earlier change + while keeping its later one, misattributing the slot's first + recorded change to a later transaction than the one that made it. + + Two transactions each write storage slot 0 of the same contract via + the transaction's call value. The BAL is corrupted by removing only + the first transaction's slot_changes entry for slot 0, leaving the + second transaction's entry as the slot's only recorded change. + """ + alice = pre.fund_eoa() + writer = pre.deploy_contract(code=Op.SSTORE(0, Op.CALLVALUE)) + + tx1 = Transaction(sender=alice, to=writer, value=1) + tx2 = Transaction(sender=alice, to=writer, value=2) + + blockchain_test( + pre=pre, + # The block reverts and the post state remains unchanged. + post=pre, + blocks=[ + Block( + txs=[tx1, tx2], + exception=BlockException.INVALID_BLOCK_ACCESS_LIST, + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange( + block_access_index=1, post_nonce=1 + ), + BalNonceChange( + block_access_index=2, post_nonce=2 + ), + ], + ), + writer: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=1 + ), + BalBalanceChange( + block_access_index=2, post_balance=3 + ), + ], + storage_changes=[ + BalStorageSlot( + slot=0, + slot_changes=[ + BalStorageChange( + block_access_index=1, + post_value=1, + ), + BalStorageChange( + block_access_index=2, + post_value=2, + ), + ], + ), + ], + ), + } + ).modify( + remove_slot_change(writer, slot=0, block_access_index=1) + ), + ) + ], + ) + + +@pytest.mark.valid_from("Amsterdam") +@pytest.mark.exception_test +def test_bal_invalid_phantom_read_on_selfdestruct( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """ + Test that clients reject a BAL with a phantom storage read for an + account created and destroyed within the same transaction. + + A contract-creation transaction's init code immediately + SELFDESTRUCTs, sending its endowment to beneficiary, without ever + returning runtime code. Per EIP-6780 the created account is created + and destroyed within the same transaction, so it has zero net BAL + changes (its balance and code never persist), but the account still + legitimately appears in the BAL as an entry with empty changes. The + BAL is corrupted by injecting a phantom storage read for a slot the + account never touched. + """ + alice = pre.fund_eoa() + beneficiary = pre.fund_eoa(amount=0) + endowment = 100 + phantom_slot = 0x07 + + initcode = Op.SELFDESTRUCT(beneficiary) + created = compute_create_address(address=alice) + + tx = Transaction(sender=alice, to=None, data=initcode, value=endowment) + + blockchain_test( + pre=pre, + # The block reverts and the post state remains unchanged. + post=pre, + blocks=[ + Block( + txs=[tx], + exception=BlockException.INVALID_BLOCK_ACCESS_LIST, + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange( + block_access_index=1, post_nonce=1 + ) + ], + ), + created: BalAccountExpectation.empty(), + beneficiary: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, + post_balance=endowment, + ) + ], + ), + } + ).modify(insert_storage_read(created, phantom_slot)), + ) + ], + ) 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 6bcc98b121a..ecee70919ea 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -170,6 +170,12 @@ | `test_bal_invalid_coinbase_balance_value` | Verify clients reject blocks where BAL has an incorrect balance for the coinbase/fee recipient | Same setup as test_bal_invalid_missing_coinbase. BAL modifier changes charlie's post-balance from the actual tip to 999. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate coinbase balance values match actual fee accounting (priority fee x gas used). | ✅ Completed | | `test_bal_invalid_extraneous_coinbase` | Verify clients reject blocks with a spurious coinbase entry when coinbase received no fees | Parameterized: (1) empty_block: no txs, no withdrawals — only system contracts in valid BAL, (2) withdrawal_only: no txs, one withdrawal to a different address — withdrawals don't pay fees so coinbase is still untouched. BAL modifier appends spurious coinbase entry with empty changes. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Coinbase **MUST NOT** appear in BAL when it receives no transaction tips, even if the block has other state-modifying activity (withdrawals). | ✅ Completed | | `test_bal_invalid_surplus_system_address_from_system_call` | Verify clients reject a BAL containing `SYSTEM_ADDRESS` solely due to a system operation caller | Empty block with an EIP-4788 pre-execution system call to `BEACON_ROOTS_ADDRESS`. Helper `beacon_root_system_call_expectations` builds the valid baseline BAL: `BEACON_ROOTS_ADDRESS` has timestamp/root storage changes at `block_access_index=0`, while `SYSTEM_ADDRESS` is marked absent (`None`). The fixture BAL is then corrupted by appending an empty `SYSTEM_ADDRESS` entry. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST`. The synthetic system caller address **MUST NOT** be accepted unless it experienced an actual state access. | ✅ Completed | +| `test_bal_invalid_noop_storage_change` | Verify clients reject a spurious storage change entry whose post-value equals the value already present at the start of the transaction | Oracle only reads storage slot 0 (value stays `0x42` throughout, via `SLOAD`). A canonical BAL builder never records a `storage_changes` entry for a slot that never actually changes. BAL is corrupted by appending a round-trip `storage_changes` entry for slot 0 with `post_value=0x42`, equal to the untouched value. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** reject a storage change entry whose post-value equals the pre-transaction value, not just wrong-value entries. | ✅ Completed | +| `test_bal_invalid_noop_value_change` | Verify clients reject a spurious balance/nonce/code change entry whose post-value equals the account's real, unchanged value | Oracle legitimately appears in the BAL only via a storage read; its balance (0), nonce (1), and code are never touched by the transaction. Parametrized: (1) `noop_balance_change`: appends a `balance_changes` entry with `post_balance` equal to Oracle's real unchanged balance. (2) `noop_nonce_change`: appends a `nonce_changes` entry with `post_nonce` equal to Oracle's real unchanged nonce. (3) `noop_code_change`: appends a `code_changes` entry with `new_code` equal to Oracle's real unchanged code. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** reject a change entry whose post-value exactly matches the account's actual unchanged value, distinct from the wrong-value corruption covered by `test_bal_invalid_balance_value`/`test_bal_invalid_nonce_value`. | ✅ Completed | +| `test_bal_invalid_missing_storage_write` | Verify clients reject a BAL that omits a storage write that was actually performed | Writer's storage slot 0 goes from 0 (default) to 1 via `SSTORE`. BAL modifier removes the account's `storage_changes` entirely. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** detect an account whose real storage write has no corresponding BAL entry. | ✅ Completed | +| `test_bal_invalid_missing_created_code` | Verify clients reject a BAL that omits the deployed code of a contract created via a contract-creation transaction | Alice sends a top-level `CREATE` transaction (`to=None`) whose init code deploys a small runtime. BAL modifier removes the created contract's `code_changes` entirely. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** detect a newly created contract whose deployed code has no corresponding BAL entry. | ✅ Completed | +| `test_bal_invalid_omitted_slot_change_at_index` | Verify clients reject a BAL that drops a slot's earlier change while keeping its later one, misattributing the slot's first recorded change to a later transaction | Two transactions each write storage slot 0 of the same contract via the transaction's call value (slot 0: 0→1 at tx1, 1→2 at tx2). BAL modifier removes only tx1's `slot_changes` entry for slot 0 (new `remove_slot_change` modifier), leaving tx2's entry as the slot's only recorded change. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate that a slot's first BAL-recorded change matches the transaction that actually performed it, not merely that some change with the correct final value exists. | ✅ Completed | +| `test_bal_invalid_phantom_read_on_selfdestruct` | Verify clients reject a BAL with a phantom storage read for an account created and destroyed within the same transaction | A contract-creation transaction's init code immediately `SELFDESTRUCT`s, sending its endowment to beneficiary, without ever returning runtime code. Per EIP-6780 the created account has zero net BAL changes (`BalAccountExpectation.empty()`) but still legitimately appears in the BAL as an entry with empty changes. BAL modifier injects a phantom `storage_reads` entry for a slot the account never touched. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** reject a storage read recorded against an account that performed no storage access at all, even when the account otherwise legitimately appears in the BAL. | ✅ Completed | | `test_bal_2935_simple` | Ensure BAL captures EIP-2935 history storage writes during pre-execution system call alongside normal transactions | Block with 2 normal user transactions: Alice sends 10 wei to Charlie, Bob sends 10 wei to Charlie. At block start (pre-execution), `SYSTEM_ADDRESS` calls `HISTORY_STORAGE_ADDRESS` to store parent block hash. | BAL **MUST** include `HISTORY_STORAGE_ADDRESS` with `storage_changes` (ring buffer slot 0, empty `slot_changes` since parent hash is framework-computed); `SYSTEM_ADDRESS` **MUST NOT** be included in BAL. At `block_access_index=1`: Alice with `nonce_changes`, Charlie with `balance_changes` (10 wei). At `block_access_index=2`: Bob with `nonce_changes`, Charlie with `balance_changes` (20 wei total). | ✅ Completed | | `test_bal_2935_empty_block` | Ensure BAL captures EIP-2935 history storage writes in empty block | Block with no transactions. At block start (pre-execution), `SYSTEM_ADDRESS` calls `HISTORY_STORAGE_ADDRESS` to store parent block hash. | BAL **MUST** include `HISTORY_STORAGE_ADDRESS` with `storage_changes` (ring buffer slot 0, empty `slot_changes`); `SYSTEM_ADDRESS` **MUST NOT** be included in BAL. No transaction-related BAL entries. | ✅ Completed | | `test_bal_2935_query` | Ensure BAL captures storage reads when querying EIP-2935 historical block hashes (valid and invalid queries) with optional value transfer | Parameterized test: Block 1 (empty, stores genesis hash via system call). Block 2: Oracle contract queries `HISTORY_STORAGE_ADDRESS` with block number. Two block number scenarios (valid=0 genesis hash, invalid=1042 out of range) and value (0 or 100 wei). Valid query (block_number=0): reads genesis hash slot, oracle writes returned value. If value > 0, history storage contract receives balance. Invalid query (block_number=1042, out of range): reverts before storage access, oracle has implicit SLOAD recorded, value stays in oracle (not transferred to history storage). | Block 2 BAL **MUST** include: Valid case at `block_access_index=1`: `HISTORY_STORAGE_ADDRESS` with `storage_reads` [slot 0] and `balance_changes` if value > 0, oracle with `storage_changes` (empty `slot_changes`). Invalid case at `block_access_index=1`: `HISTORY_STORAGE_ADDRESS` with NO `storage_reads` (reverts before access) and NO `balance_changes`, oracle with `storage_reads` [0], NO `storage_changes`, and `balance_changes` if value > 0 (value stays in oracle). Alice with `nonce_changes` at `block_access_index=1`. | ✅ Completed |