From d22139bf438649d9dbc139ab685b9f9075980253 Mon Sep 17 00:00:00 2001 From: raxhvl <10168946+raxhvl@users.noreply.github.com> Date: Wed, 18 Feb 2026 01:08:18 +0100 Subject: [PATCH] feat(tests): EIP-7928 tests for EIP 2935 (#2113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ๐Ÿงช test(EIP-7928): 2935 tests * chore(test-types): Improve DevEx for BAL ``slot_changes`` expectation for any changes * chore: clean up lint errors * ๐Ÿงช test: fixes and additional test case --------- Co-authored-by: raxhvl Co-authored-by: fselmo --- .../block_access_list/account_changes.py | 23 +- .../block_access_list/expectations.py | 21 +- .../test_block_access_list_expectation.py | 154 ++++++ .../test_block_access_lists_eip2935.py | 465 ++++++++++++++++++ .../test_cases.md | 5 + 5 files changed, 662 insertions(+), 6 deletions(-) create mode 100644 tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip2935.py diff --git a/packages/testing/src/execution_testing/test_types/block_access_list/account_changes.py b/packages/testing/src/execution_testing/test_types/block_access_list/account_changes.py index 5bf4461457..ec1a2ba521 100644 --- a/packages/testing/src/execution_testing/test_types/block_access_list/account_changes.py +++ b/packages/testing/src/execution_testing/test_types/block_access_list/account_changes.py @@ -5,9 +5,9 @@ in a block access list as defined in EIP-7928. """ -from typing import ClassVar, List, Union +from typing import ClassVar, List, Self, Union -from pydantic import Field +from pydantic import Field, model_validator from execution_testing.base_types import ( Address, @@ -79,9 +79,28 @@ class BalStorageSlot(CamelModel, RLPSerializable): slot_changes: List[BalStorageChange] = Field( default_factory=list, description="List of changes to this slot" ) + validate_any_change: bool = Field( + default=False, + description=( + "If True, asserts at least one change exists in this slot " + "without validating specific values. Mutually exclusive with " + "non-empty slot_changes." + ), + exclude=True, + ) rlp_fields: ClassVar[List[str]] = ["slot", "slot_changes"] + @model_validator(mode="after") + def _check_mutual_exclusion(self) -> Self: + if self.validate_any_change and self.slot_changes: + raise ValueError( + "Cannot set both validate_any_change=True and slot_changes. " + "Use validate_any_change=True to assert at least one change " + "exists, or slot_changes=[...] to validate specific changes." + ) + return self + class BalAccountChange(CamelModel, RLPSerializable): """Represents all changes to a specific account in a block.""" diff --git a/packages/testing/src/execution_testing/test_types/block_access_list/expectations.py b/packages/testing/src/execution_testing/test_types/block_access_list/expectations.py index d2c6ce9430..531e245375 100644 --- a/packages/testing/src/execution_testing/test_types/block_access_list/expectations.py +++ b/packages/testing/src/execution_testing/test_types/block_access_list/expectations.py @@ -312,10 +312,23 @@ def _compare_account_expectations( ].slot_changes expected_slot_changes = expected_slot.slot_changes - if not expected_slot_changes: - # Empty expected means any - # slot_changes are acceptable - pass + if expected_slot.validate_any_change: + # Assert at least one change exists + if not actual_slot_changes: + raise BlockAccessListValidationError( + f"Expected at least one change " + f"in slot {expected_slot.slot} " + f"(validate_any_change=True) " + f"but found none" + ) + elif not expected_slot_changes: + # Explicitly empty = assert no changes + if actual_slot_changes: + raise BlockAccessListValidationError( + f"Expected no changes in slot " + f"{expected_slot.slot} but found " + f"{actual_slot_changes}" + ) else: # Validate slot_changes as subsequence slot_actual_idx = 0 diff --git a/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.py b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.py index ae495e9bd7..313bc1eb9e 100644 --- a/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.py +++ b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.py @@ -1102,3 +1102,157 @@ def test_bal_account_absent_values_empty_slot_changes_raises() -> None: ) ] ) + + +# --- Tests for BalStorageSlot.validate_any_change --- + + +def test_validate_any_change_passes_with_non_empty_actual() -> None: + """validate_any_change=True passes when actual has at least one change.""" + addr = Address(0xA) + + actual_bal = BlockAccessList( + [ + BalAccountChange( + address=addr, + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange( + block_access_index=0, post_value=0xBEEF + ) + ], + ), + ], + ) + ] + ) + + expectation = BlockAccessListExpectation( + account_expectations={ + addr: BalAccountExpectation( + storage_changes=[ + BalStorageSlot(slot=0x01, validate_any_change=True), + ], + ), + } + ) + + expectation.verify_against(actual_bal) + + +def test_validate_any_change_fails_with_empty_actual() -> None: + """validate_any_change=True fails when actual has no changes.""" + addr = Address(0xA) + + actual_bal = BlockAccessList( + [ + BalAccountChange( + address=addr, + storage_changes=[ + BalStorageSlot(slot=0x01, slot_changes=[]), + ], + ) + ] + ) + + expectation = BlockAccessListExpectation( + account_expectations={ + addr: BalAccountExpectation( + storage_changes=[ + BalStorageSlot(slot=0x01, validate_any_change=True), + ], + ), + } + ) + + with pytest.raises( + BlockAccessListValidationError, + match="Expected at least one change in slot", + ): + expectation.verify_against(actual_bal) + + +def test_validate_any_change_mutual_exclusion_with_slot_changes() -> None: + """ + validate_any_change=True and non-empty slot_changes raises ValueError. + """ + with pytest.raises( + ValueError, + match="Cannot set both validate_any_change=True and slot_changes", + ): + BalStorageSlot( + slot=0x01, + validate_any_change=True, + slot_changes=[ + BalStorageChange(block_access_index=0, post_value=0xBEEF) + ], + ) + + +def test_slot_changes_empty_asserts_no_changes() -> None: + """slot_changes=[] asserts that actual has no changes.""" + addr = Address(0xA) + + actual_bal = BlockAccessList( + [ + BalAccountChange( + address=addr, + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange( + block_access_index=0, post_value=0xBEEF + ) + ], + ), + ], + ) + ] + ) + + expectation = BlockAccessListExpectation( + account_expectations={ + addr: BalAccountExpectation( + storage_changes=[ + BalStorageSlot(slot=0x01, slot_changes=[]), + ], + ), + } + ) + + with pytest.raises( + BlockAccessListValidationError, + match="Expected no changes in slot", + ): + expectation.verify_against(actual_bal) + + +def test_slot_changes_empty_passes_when_actual_empty() -> None: + """slot_changes=[] passes when actual also has no changes.""" + addr = Address(0xA) + + actual_bal = BlockAccessList( + [ + BalAccountChange( + address=addr, + storage_changes=[ + BalStorageSlot(slot=0x01, slot_changes=[]), + ], + ) + ] + ) + + expectation = BlockAccessListExpectation( + account_expectations={ + addr: BalAccountExpectation( + storage_changes=[ + BalStorageSlot(slot=0x01, slot_changes=[]), + ], + ), + } + ) + + expectation.verify_against(actual_bal) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip2935.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip2935.py new file mode 100644 index 0000000000..f4409671c9 --- /dev/null +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip2935.py @@ -0,0 +1,465 @@ +"""Tests for the effects of EIP-2935 historical block hashes on EIP-7928.""" + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + BalAccountExpectation, + BalBalanceChange, + BalNonceChange, + BalStorageSlot, + Block, + BlockAccessListExpectation, + BlockchainTestFiller, + Fork, + Hash, + Op, + Transaction, +) + +from tests.cancun.eip4788_beacon_root.spec import Spec as Spec4788 +from tests.prague.eip2935_historical_block_hashes_from_state.spec import Spec + +from .spec import ref_spec_7928 + +REFERENCE_SPEC_GIT_PATH = ref_spec_7928.git_path +REFERENCE_SPEC_VERSION = ref_spec_7928.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + +HISTORY_STORAGE_ADDRESS = Address(Spec.HISTORY_STORAGE_ADDRESS) +SYSTEM_ADDRESS = Address(Spec4788.SYSTEM_ADDRESS) + + +def block_hash_system_call_expectations(block_number: int) -> dict: + """ + Build BAL expectations for block hash pre-execution system call. + + Returns account expectations for HISTORY_STORAGE_ADDRESS and + SYSTEM_ADDRESS at block_access_index=0. + """ + return { + HISTORY_STORAGE_ADDRESS: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=block_number % Spec.HISTORY_SERVE_WINDOW, + validate_any_change=True, + ), + ], + ), + # System address MUST NOT be included + SYSTEM_ADDRESS: None, + } + + +def test_bal_2935_simple( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, +) -> None: + """ + Ensure BAL captures history storage writes during system call. + + 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. + """ + alice = pre.fund_eoa() + bob = pre.fund_eoa() + charlie = pre.fund_eoa(amount=0) + + transfer_value = 10 + + tx1 = Transaction( + sender=alice, + to=charlie, + value=transfer_value, + gas_limit=fork.transaction_gas_limit_cap(), + ) + + tx2 = Transaction( + sender=bob, + to=charlie, + value=transfer_value, + gas_limit=fork.transaction_gas_limit_cap(), + ) + + account_expectations = block_hash_system_call_expectations(0) + + account_expectations[alice] = BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], + ) + account_expectations[bob] = BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=2, post_nonce=1)], + ) + account_expectations[charlie] = BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=transfer_value + ), + BalBalanceChange( + block_access_index=2, post_balance=transfer_value * 2 + ), + ], + ) + block = Block( + txs=[tx1, tx2], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + bob: Account(nonce=1), + charlie: Account(balance=transfer_value * 2), + }, + ) + + +def test_bal_2935_empty_block( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures 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. + """ + block = Block( + txs=[], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=block_hash_system_call_expectations(0) + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={}, + ) + + +@pytest.mark.parametrize( + "query_block_number,is_valid", + [ + pytest.param(0, True, id="valid_block_number"), + pytest.param(1042, False, id="block_number_out_of_range"), + ], +) +@pytest.mark.parametrize( + "value", + [ + pytest.param(0, id="no_value"), + pytest.param(100, id="with_value"), + ], +) +def test_bal_2935_query( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + query_block_number: int, + is_valid: bool, + value: int, +) -> None: + """ + Ensure BAL captures storage reads when querying historical block hashes. + + Test scenarios: + 1. Valid query (block_number=0, genesis hash): History storage contract + reads the genesis hash slot, query contract writes returned value + 2. Invalid query (block_number=1042, out of range): History storage + contract reverts before any storage access, query contract has + implicit read recorded + 3. With value transfer: BAL captures balance changes in addition + to storage operations (only when query is valid) + """ + alice = pre.fund_eoa() + + # Contract that calls history storage contract with block number from + # calldata and stores returned block hash in slot 0, + # forwarding any value sent + query_code = ( + Op.CALLDATACOPY(0, 0, 32) + + Op.CALL( + Op.GAS, + HISTORY_STORAGE_ADDRESS, + Op.CALLVALUE, + 0, + 32, + 32, + 32, + ) + + Op.SSTORE(0, Op.MLOAD(32)) + ) + oracle = pre.deploy_contract(query_code) + + tx = Transaction( + sender=alice, + to=oracle, + data=Hash(query_block_number), + value=value, + gas_limit=fork.transaction_gas_limit_cap(), + ) + + # A setup up block that writes genesis block-hash + # to history storage contract so that it can be + # queried later. + block_1 = Block( + expected_block_access_list=BlockAccessListExpectation( + account_expectations=block_hash_system_call_expectations(0) + ) + ) + + block_hash_slot = query_block_number % Spec.HISTORY_SERVE_WINDOW + # Storage reads for the query: + # - Valid query (block 0): reads `block_hash_slot` + # - Invalid query (out-of-range block): reverts before SLOAD + account_expectations = block_hash_system_call_expectations(1) + account_expectations[HISTORY_STORAGE_ADDRESS].storage_reads = ( + # Read only occurs for valid query + [block_hash_slot] if is_valid else [] + ) + + # Add balance changes if value is transferred and query is valid + if value > 0 and is_valid: + account_expectations[HISTORY_STORAGE_ADDRESS].balance_changes = [ + BalBalanceChange(block_access_index=1, post_balance=value) + ] + + account_expectations[alice] = BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], + ) + + account_expectations[oracle] = BalAccountExpectation( + # Valid: write the returned hash (value is framework-computed) + # Invalid: no-op SSTORE(0, 0) becomes implicit read + storage_reads=[] if is_valid else [0], + storage_changes=[ + BalStorageSlot( + slot=0, + validate_any_change=True, + ), + ] + if is_valid + else [], + # if value > 0 and invalid, value stays in query contract + balance_changes=[ + BalBalanceChange( + block_access_index=1, + post_balance=value, + ) + ] + if not is_valid and value > 0 + else [], + ) + + block_2 = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + + post_state: dict[Address, Account] = { + alice: Account(nonce=1), + } + + if is_valid: + post_state[oracle] = Account() + else: + # Invalid query: zero stored + post_state[oracle] = Account(storage={0: 0}) + + if value > 0 and is_valid: + post_state[HISTORY_STORAGE_ADDRESS] = Account(balance=value) + + blockchain_test( + pre=pre, + blocks=[block_1, block_2], + post=post_state, + ) + + +def test_bal_2935_selfdestruct_to_history_storage( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, +) -> None: + """ + Ensure BAL captures SELFDESTRUCT to history storage address alongside + system call storage writes. + + Single block with pre-execution system call writing parent hash to + storage, followed by transaction where contract selfdestructs sending + funds to HISTORY_STORAGE_ADDRESS. Tests that same address can appear in + BAL with different change types (storage_changes and balance_changes) + at different transaction indices. + """ + alice = pre.fund_eoa() + + contract_balance = 100 + + # Contract that selfdestructs to history storage address + selfdestruct_code = Op.SELFDESTRUCT(HISTORY_STORAGE_ADDRESS) + selfdestruct_contract = pre.deploy_contract( + code=selfdestruct_code, + balance=contract_balance, + ) + + tx = Transaction( + sender=alice, + to=selfdestruct_contract, + gas_limit=fork.transaction_gas_limit_cap(), + ) + + account_expectations = block_hash_system_call_expectations(0) + + # Add balance change from selfdestruct to history storage address + account_expectations[HISTORY_STORAGE_ADDRESS].balance_changes = [ + BalBalanceChange(block_access_index=1, post_balance=contract_balance) + ] + + # Add transaction-specific expectations + account_expectations[alice] = BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], + ) + account_expectations[selfdestruct_contract] = BalAccountExpectation( + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=0) + ], + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + HISTORY_STORAGE_ADDRESS: Account(balance=contract_balance), + }, + ) + + +@pytest.mark.parametrize( + "calldata_size", + [ + pytest.param(0, id="empty_calldata"), + pytest.param(31, id="calldata_too_short"), + pytest.param(33, id="calldata_too_long"), + ], +) +@pytest.mark.parametrize( + "value", + [ + pytest.param(0, id="no_value"), + pytest.param(100, id="with_value"), + ], +) +def test_bal_2935_invalid_calldata_size( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + calldata_size: int, + value: int, +) -> None: + """ + Ensure BAL correctly handles EIP-2935 queries with invalid calldata size. + + EIP-2935 requires exactly 32 bytes of calldata. Any other size causes + immediate revert before any storage access occurs. + + Test scenarios with and without value transfer: + 1. Empty calldata (0 bytes): Reverts immediately + 2. Too short (31 bytes): Reverts before storage access + 3. Too long (33 bytes): Reverts before storage access + """ + alice = pre.fund_eoa() + + # Contract that calls history storage contract with variable-size calldata + # and stores returned block hash in slot 0 + query_code = ( + Op.CALLDATACOPY(0, 0, calldata_size) + + Op.CALL( + Op.GAS, + HISTORY_STORAGE_ADDRESS, + Op.CALLVALUE, + 0, + calldata_size, + 32, + 32, + ) + + Op.SSTORE(0, Op.MLOAD(32)) + ) + oracle = pre.deploy_contract(query_code) + + # Pad calldata to requested size + calldata = b"\x00" * calldata_size + + tx = Transaction( + sender=alice, + to=oracle, + data=calldata, + value=value, + gas_limit=fork.transaction_gas_limit_cap(), + ) + + # Block 1: Setup block that writes genesis block-hash via system call + block_1 = Block( + expected_block_access_list=BlockAccessListExpectation( + account_expectations=block_hash_system_call_expectations(0) + ) + ) + + # Block 2: Query with invalid calldata size + account_expectations = block_hash_system_call_expectations(1) + # History storage contract reverts before any storage access + account_expectations[HISTORY_STORAGE_ADDRESS].storage_reads = [] + + account_expectations[alice] = BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], + ) + + account_expectations[oracle] = BalAccountExpectation( + # SSTORE(0, 0) is a no-op write, becomes implicit read + storage_reads=[0], + storage_changes=[], + # Value stays in oracle contract when call reverts + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=value) + ] + if value > 0 + else [], + ) + + block_2 = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + + post_state: dict[Address, Account] = { + alice: Account(nonce=1), + oracle: Account(storage={0: 0}), + } + + if value > 0: + post_state[oracle] = Account(storage={0: 0}, balance=value) + + blockchain_test( + pre=pre, + blocks=[block_1, block_2], + post=post_state, + ) 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 9cae353205..85e2020cac 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -128,5 +128,10 @@ | `test_bal_7002_request_from_contract` | Ensure BAL captures withdrawal request from contract with correct source address | Alice calls `RelayContract` which internally calls EIP-7002 system contract with withdrawal request. Withdrawal request should have `source_address = RelayContract` (not Alice). | BAL **MUST** include Alice with `nonce_changes` at `block_access_index=1`. BAL **MUST** include `RelayContract` with `balance_changes` (fee paid to system contract) at `block_access_index=1`. BAL **MUST** include system contract with `balance_changes`, `storage_reads`, and `storage_changes` (queue modified). Source address in withdrawal request **MUST** be `RelayContract`. Clean sweep: count and tail reset to 0 at `block_access_index=2`. | โœ… Completed | | `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_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 | +| `test_bal_2935_selfdestruct_to_history_storage` | Ensure BAL captures `SELFDESTRUCT` to EIP-2935 history storage address | Single block: Transaction where Alice calls contract (pre-funded with 100 wei) that selfdestructs with `HISTORY_STORAGE_ADDRESS` as beneficiary. | BAL **MUST** include at `block_access_index=1`: Alice with `nonce_changes`, contract with `balance_changes` (100โ†’0), `HISTORY_STORAGE_ADDRESS` with `balance_changes` (receives 100 wei). | โœ… Completed | +| `test_bal_2935_invalid_calldata_size` | Ensure BAL correctly handles EIP-2935 queries with invalid calldata size (reverts before any storage access) | Parameterized test: Block 1 stores genesis hash via system call. Block 2: Oracle contract calls `HISTORY_STORAGE_ADDRESS` with invalid calldata sizes (0, 31, 33 bytes). EIP-2935 requires exactly 32 bytes calldata; any other size causes immediate revert before storage access. Optional value transfer (0 or 100 wei). | Block 2 BAL **MUST** include: `HISTORY_STORAGE_ADDRESS` with NO `storage_reads` (calldata size check fails before any SLOAD) and NO `balance_changes` (call reverts). Oracle with `storage_reads` [0] (implicit SLOAD from no-op SSTORE), NO `storage_changes`, and `balance_changes` if value > 0 (value stays in oracle on revert). Alice with `nonce_changes`. | โœ… Completed | | `test_bal_create2_selfdestruct_then_recreate_same_block` | Ensure BAL handles **(tx1) create+SELFDESTRUCT** then **(tx2) CREATE2 "resurrection"** of the *same address* in the same block | Parameterized: `@pytest.mark.with_all_create_opcodes` for Tx1 create opcode (CREATE or CREATE2), and whether **A** has a pre-existing balance or not. **Tx1:** `Factory` executes `create_opcode` to deploy contract at **A** (for CREATE2: fixed `salt`, fixed `initcode` โ†’ deterministic address). The created contract optionally does `SLOAD/SSTORE` (to prove state touches), then `SELFDESTRUCT(beneficiary=B)` **in the same tx** (so under EIP-6780 the account is actually deleted after Tx1). **Tx2:** `Factory` executes `CREATE2(salt, initcode)` to recreate **A** at the same deterministic address, and this time the runtime code persists (no SELFDESTRUCT). | BAL **MUST** include: **Tx1 (`block_access_index=1`)**: (1) `Factory` with `nonce_changes` (create opcode increments nonce), and `balance_changes` if it endows A. (2) **A** in `account_changes` (it was accessed/created) but **MUST NOT** have persistent `code_changes`, `nonce_changes`, or `storage_changes` (it ends Tx1 non-existent due to same-tx create+SELFDESTRUCT). Any attempted `SSTORE` in A before SELFDESTRUCT **MUST NOT** appear in `storage_changes` (ephemeral). If A had a pre-existing balance, it **MUST** have `balance_changes` reflecting the transfer to B. (3) `B` with `balance_changes` if A had balance transferred on SELFDESTRUCT. **Tx2 (`block_access_index=2`)**: (1) `Factory` with another `nonce_changes`. (2) **A** with `code_changes` (runtime bytecode present), `nonce_changes = 1`, plus any `storage_changes` performed in Tx2. (3) If Tx2 endows or transfers value, include corresponding `balance_changes` for involved accounts. | ๐ŸŸก Planned |