From 84a2b1ae671661ccf1b5303f1c2f487ce9c7839f Mon Sep 17 00:00:00 2001 From: raxhvl Date: Mon, 1 Jun 2026 07:53:12 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20feat:=20Disallow=20empty=20chan?= =?UTF-8?q?ge=20set=20for=20storage=20slot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_block_access_lists_invalid.py | 88 +++++++++++++++++++ .../test_cases.md | 1 + 2 files changed, 89 insertions(+) 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 52d366019d8..fab726c906b 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 @@ -1053,6 +1053,94 @@ def test_bal_invalid_extraneous_entries( ) +@pytest.mark.valid_from("Amsterdam") +@pytest.mark.exception_test +@pytest.mark.parametrize( + "pre_storage,oracle_expectation,slot_to_inject", + [ + pytest.param( + {}, + BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=0x42 + ) + ], + ) + ], + ), + 1, + id="unrelated_slot", + ), + pytest.param( + {0: 0x42}, + BalAccountExpectation(storage_reads=[0]), + 0, + id="demoted_noop", + ), + ], +) +def test_bal_invalid_empty_slot_changes( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + pre_storage: dict, + oracle_expectation: BalAccountExpectation, + slot_to_inject: int, +) -> None: + """Reject BAL containing a SlotChanges with an empty slot_changes list.""" + alice = pre.fund_eoa() + oracle = pre.deploy_contract(code=Op.SSTORE(0, 0x42), storage=pre_storage) + tx = Transaction(sender=alice, to=oracle, gas_limit=1_000_000) + + def append_empty_slot(bal): # type: ignore[no-untyped-def] + from execution_testing import BlockAccessList + + new_root = [] + for account in bal.root: + if account.address == oracle: + new_account = account.model_copy(deep=True) + new_account.storage_changes.append( + BalStorageSlot(slot=slot_to_inject, slot_changes=[]) + ) + # Strip the same slot from storage_reads to isolate the + # empty-slot rule from the "appears in both" rule. + new_account.storage_reads = [ + s + for s in new_account.storage_reads + if int(s) != slot_to_inject + ] + new_root.append(new_account) + else: + new_root.append(account) + return BlockAccessList(root=new_root) + + blockchain_test( + pre=pre, + 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: oracle_expectation, + } + ).modify(append_empty_slot), + ) + ], + ) + + @pytest.mark.valid_from("Amsterdam") @pytest.mark.exception_test @pytest.mark.parametrize( 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 f814e9840ea..3d5691b761c 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -157,6 +157,7 @@ | `test_bal_7002_request_invalid` | Ensure BAL correctly handles invalid withdrawal request scenarios | Parameterized test with 8 invalid scenarios: (1) insufficient_fee (fee=0), (2) calldata_too_short (55 bytes), (3) calldata_too_long (57 bytes), (4) oog (insufficient gas), (5-7) invalid_call_type (DELEGATECALL/STATICCALL/CALLCODE), (8) contract_reverts. Tests both EOA and contract-based withdrawal requests. | BAL **MUST** include sender with `nonce_changes` at `block_access_index=1`. BAL **MUST** include system contract with `storage_reads` for slots: excess (slot 0), count (slot 1), head (slot 2), tail (slot 3). System contract **MUST NOT** have `storage_changes` (transaction failed, no queue modification). | ✅ Completed | | `test_bal_invalid_extraneous_entries` | Verify clients reject blocks with any type of extraneous BAL entries | Alice sends 100 wei to Oracle contract (which reads storage slot 0). Charlie is uninvolved in this transaction. A valid BAL is created containing nonce change for Alice, balance change and storage read for Oracle. The BAL is corrupted by adding various extraneous entries: (1) extra_nonce, (2) extra_balance, (3) extra_code, (4) extra_storage_write_touched (slot 0 - already read), (5) extra_storage_write_untouched (slot 1 - not accessed), (6) extra_storage_write_uninvolved_account (Charlie - uninvolved account), (7) extra_account_access (Charlie), (8) extra_storage_read (slot 999). Each tested at block_access_index 1 (same tx), 2 (system tx), 3 (out of bounds). | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** detect any extraneous entries in BAL. | ✅ Completed | | `test_bal_invalid_duplicate_entries` | Verify clients reject blocks where BAL violates uniqueness constraints | Oracle writes storage, reads storage, and CREATEs a contract. BAL is corrupted with duplicate entries: (1) duplicate_nonce_change, (2) duplicate_balance_change, (3) duplicate_code_change, (4) duplicate_storage_slot, (5) duplicate_storage_read, (6) duplicate_slot_change, (7) storage_key_in_both_changes_and_reads. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Each `block_access_index` must appear at most once per change list, each storage key at most once in `storage_changes` and `storage_reads`, and no key in both. | ✅ Completed | +| `test_bal_invalid_empty_slot_changes` | Verify clients reject BAL containing a storage slot entry with no changes | Parametrized: (1) `unrelated_slot`: Oracle writes one slot; BAL is corrupted to include a second, unrelated slot with no changes. (2) `demoted_noop`: Oracle writes a slot back to its existing value (no-op); BAL is corrupted to record the slot as a change with no changes, instead of as a read. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. A storage slot in `storage_changes` **MUST** have at least one recorded change; a slot accessed without any change belongs in `storage_reads`. | ✅ Completed | | `test_bal_invalid_missing_withdrawal_account` | Verify clients reject blocks where BAL is missing an account modified only by a withdrawal | Alice sends 5 wei to Bob (1 transaction). Charlie receives 10 gwei withdrawal. BAL modifier removes Charlie's entry entirely. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** detect that Charlie's balance was modified by the withdrawal but has no corresponding BAL entry. | ✅ Completed | | `test_bal_invalid_missing_withdrawal_account_empty_block` | Verify clients reject blocks where BAL is missing a withdrawal-modified account in an empty block | Charlie receives 10 gwei withdrawal in block with no transactions. BAL modifier removes Charlie's entry entirely. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** detect withdrawal-modified accounts even when no transactions are present. | ✅ Completed | | `test_bal_invalid_hash_mismatch` | Verify clients reject blocks where the BAL hash in the header does not match the actual BAL content | Alice sends value to Bob. BAL content is valid but header hash is overridden to a wrong value via `rlp_modifier`. Unlike other invalid BAL tests (which corrupt BAL content with matching hash), this keeps the BAL valid but injects a wrong header hash. | Block **MUST** be rejected with `INVALID_BAL_HASH` or `INVALID_BLOCK_HASH` exception. Clients **MUST** re-derive the BAL from block execution and compare its hash to the header, not just verify the BAL content is self-consistent. | ✅ Completed | From e2f5a5456d08bd2c1d78a538502c6a1461c008df Mon Sep 17 00:00:00 2001 From: raxhvl Date: Mon, 1 Jun 2026 08:13:41 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A7=B9=20chore:=20Move=20modifier=20t?= =?UTF-8?q?o=20a=20helper=20fn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_types/block_access_list/modifiers.py | 36 +++++++++++++++++++ .../test_block_access_lists_invalid.py | 25 ++----------- .../test_cases.md | 2 +- 3 files changed, 39 insertions(+), 24 deletions(-) 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 00903266251..2777f5c5e27 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 @@ -481,6 +481,41 @@ def transform(bal: BlockAccessList) -> BlockAccessList: return transform +def append_empty_slot( + address: Address, slot: int +) -> Callable[[BlockAccessList], BlockAccessList]: + """ + Append an empty BalStorageSlot (no changes) to an account's + storage_changes. Used by invalid-BAL tests to simulate a malformed + entry where a slot is recorded as changed but carries no actual change. + """ + + def transform(bal: BlockAccessList) -> BlockAccessList: + from . import BalStorageSlot + + found_address = 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) + new_account.storage_changes.append( + BalStorageSlot(slot=slot, slot_changes=[]) + ) + 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 append empty slot" + ) + + return BlockAccessList(root=new_root) + + return transform + + def duplicate_account( address: Address, ) -> Callable[[BlockAccessList], BlockAccessList]: @@ -779,6 +814,7 @@ def transform(bal: BlockAccessList) -> BlockAccessList: "append_account", "append_change", "append_storage", + "append_empty_slot", "duplicate_account", "reverse_accounts", "keep_only", 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 fab726c906b..c0b16b3a08a 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 @@ -36,6 +36,7 @@ from execution_testing.test_types.block_access_list.modifiers import ( append_account, append_change, + append_empty_slot, append_storage, duplicate_account, duplicate_balance_change, @@ -1095,28 +1096,6 @@ def test_bal_invalid_empty_slot_changes( oracle = pre.deploy_contract(code=Op.SSTORE(0, 0x42), storage=pre_storage) tx = Transaction(sender=alice, to=oracle, gas_limit=1_000_000) - def append_empty_slot(bal): # type: ignore[no-untyped-def] - from execution_testing import BlockAccessList - - new_root = [] - for account in bal.root: - if account.address == oracle: - new_account = account.model_copy(deep=True) - new_account.storage_changes.append( - BalStorageSlot(slot=slot_to_inject, slot_changes=[]) - ) - # Strip the same slot from storage_reads to isolate the - # empty-slot rule from the "appears in both" rule. - new_account.storage_reads = [ - s - for s in new_account.storage_reads - if int(s) != slot_to_inject - ] - new_root.append(new_account) - else: - new_root.append(account) - return BlockAccessList(root=new_root) - blockchain_test( pre=pre, post=pre, @@ -1135,7 +1114,7 @@ def append_empty_slot(bal): # type: ignore[no-untyped-def] ), oracle: oracle_expectation, } - ).modify(append_empty_slot), + ).modify(append_empty_slot(oracle, slot=slot_to_inject)), ) ], ) 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 3d5691b761c..ec9fbed3529 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -157,7 +157,7 @@ | `test_bal_7002_request_invalid` | Ensure BAL correctly handles invalid withdrawal request scenarios | Parameterized test with 8 invalid scenarios: (1) insufficient_fee (fee=0), (2) calldata_too_short (55 bytes), (3) calldata_too_long (57 bytes), (4) oog (insufficient gas), (5-7) invalid_call_type (DELEGATECALL/STATICCALL/CALLCODE), (8) contract_reverts. Tests both EOA and contract-based withdrawal requests. | BAL **MUST** include sender with `nonce_changes` at `block_access_index=1`. BAL **MUST** include system contract with `storage_reads` for slots: excess (slot 0), count (slot 1), head (slot 2), tail (slot 3). System contract **MUST NOT** have `storage_changes` (transaction failed, no queue modification). | ✅ Completed | | `test_bal_invalid_extraneous_entries` | Verify clients reject blocks with any type of extraneous BAL entries | Alice sends 100 wei to Oracle contract (which reads storage slot 0). Charlie is uninvolved in this transaction. A valid BAL is created containing nonce change for Alice, balance change and storage read for Oracle. The BAL is corrupted by adding various extraneous entries: (1) extra_nonce, (2) extra_balance, (3) extra_code, (4) extra_storage_write_touched (slot 0 - already read), (5) extra_storage_write_untouched (slot 1 - not accessed), (6) extra_storage_write_uninvolved_account (Charlie - uninvolved account), (7) extra_account_access (Charlie), (8) extra_storage_read (slot 999). Each tested at block_access_index 1 (same tx), 2 (system tx), 3 (out of bounds). | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** detect any extraneous entries in BAL. | ✅ Completed | | `test_bal_invalid_duplicate_entries` | Verify clients reject blocks where BAL violates uniqueness constraints | Oracle writes storage, reads storage, and CREATEs a contract. BAL is corrupted with duplicate entries: (1) duplicate_nonce_change, (2) duplicate_balance_change, (3) duplicate_code_change, (4) duplicate_storage_slot, (5) duplicate_storage_read, (6) duplicate_slot_change, (7) storage_key_in_both_changes_and_reads. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Each `block_access_index` must appear at most once per change list, each storage key at most once in `storage_changes` and `storage_reads`, and no key in both. | ✅ Completed | -| `test_bal_invalid_empty_slot_changes` | Verify clients reject BAL containing a storage slot entry with no changes | Parametrized: (1) `unrelated_slot`: Oracle writes one slot; BAL is corrupted to include a second, unrelated slot with no changes. (2) `demoted_noop`: Oracle writes a slot back to its existing value (no-op); BAL is corrupted to record the slot as a change with no changes, instead of as a read. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. A storage slot in `storage_changes` **MUST** have at least one recorded change; a slot accessed without any change belongs in `storage_reads`. | ✅ Completed | +| `test_bal_invalid_empty_slot_changes` | Verify clients reject BAL containing a storage slot entry with no changes | Parametrized: (1) `unrelated_slot`: Oracle writes one slot; BAL is corrupted to include a second, unrelated slot with no changes. (2) `demoted_noop`: Oracle writes a slot back to its existing value (no-op), so the slot is recorded as a read; BAL is corrupted to also record the same slot as a change with no changes. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. A storage slot in `storage_changes` **MUST** have at least one recorded change; a slot accessed without any change belongs in `storage_reads`. | ✅ Completed | | `test_bal_invalid_missing_withdrawal_account` | Verify clients reject blocks where BAL is missing an account modified only by a withdrawal | Alice sends 5 wei to Bob (1 transaction). Charlie receives 10 gwei withdrawal. BAL modifier removes Charlie's entry entirely. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** detect that Charlie's balance was modified by the withdrawal but has no corresponding BAL entry. | ✅ Completed | | `test_bal_invalid_missing_withdrawal_account_empty_block` | Verify clients reject blocks where BAL is missing a withdrawal-modified account in an empty block | Charlie receives 10 gwei withdrawal in block with no transactions. BAL modifier removes Charlie's entry entirely. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** detect withdrawal-modified accounts even when no transactions are present. | ✅ Completed | | `test_bal_invalid_hash_mismatch` | Verify clients reject blocks where the BAL hash in the header does not match the actual BAL content | Alice sends value to Bob. BAL content is valid but header hash is overridden to a wrong value via `rlp_modifier`. Unlike other invalid BAL tests (which corrupt BAL content with matching hash), this keeps the BAL valid but injects a wrong header hash. | Block **MUST** be rejected with `INVALID_BAL_HASH` or `INVALID_BLOCK_HASH` exception. Clients **MUST** re-derive the BAL from block execution and compare its hash to the header, not just verify the BAL content is self-consistent. | ✅ Completed |