diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7002.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7002.py new file mode 100644 index 0000000000..be9a0d2616 --- /dev/null +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7002.py @@ -0,0 +1,810 @@ +"""Tests for the effects of EIP-7002 transactions on EIP-7928.""" + +from typing import Callable + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + BalAccountExpectation, + BalBalanceChange, + BalNonceChange, + BalStorageChange, + BalStorageSlot, + Block, + BlockAccessListExpectation, + BlockchainTestFiller, + Op, + Transaction, +) + +from ...prague.eip7002_el_triggerable_withdrawals.helpers import ( + WithdrawalRequest, + WithdrawalRequestContract, + WithdrawalRequestInteractionBase, + WithdrawalRequestTransaction, +) +from ...prague.eip7002_el_triggerable_withdrawals.spec import Spec as Spec7002 +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") + +""" +Note: +1. In each block, the count resets to zero after execution. +2. During a partial sweep, the head is updated after execution; + if not written, the head remains read. +3. Similarly, the excess is modified for overflow; + if not written, it remains read. +4. If the first 32 bytes of the public key are zero, the second slot + in the queue performs a no-op write (i.e., a read). +""" + + +# --- helpers --- # +def _encode_pubkey_amount_slot(withdrawal_request: WithdrawalRequest) -> bytes: + """ + Encode slot +2: 32 bytes containing last 16 bytes of pubkey followed by + 8 bytes of big endian amount, padded with 8 zero bytes on the right. + Storage layout: [16 bytes pubkey][8 bytes amount][8 bytes padding]. + """ + last_16_bytes = withdrawal_request.validator_pubkey[-16:] + amount_bytes = withdrawal_request.amount.to_bytes(8, byteorder="big") + return last_16_bytes + amount_bytes + b"\x00" * 8 + + +def _build_queue_storage_slots( + senders: list, withdrawal_requests: list[WithdrawalRequest] +) -> tuple[list, list]: + """Build queue storage slots for withdrawal requests.""" + num_reqs = len(senders) + queue_writes = [] + queue_reads = [] + for i in range(num_reqs): + base_slot = Spec7002.WITHDRAWAL_REQUEST_QUEUE_STORAGE_OFFSET + (i * 3) + # Slot +0: source address + queue_writes.append( + BalStorageSlot( + slot=base_slot, + slot_changes=[ + BalStorageChange( + block_access_index=i + 1, + post_value=senders[i], + ) + ], + ), + ) + # Slot +1: first 32 bytes of validator pubkey + first_32_bytes = int.from_bytes( + withdrawal_requests[i].validator_pubkey[:32], byteorder="big" + ) + if first_32_bytes != 0: + # Non-zero write: record as storage change + queue_writes.append( + BalStorageSlot( + slot=base_slot + 1, + slot_changes=[ + BalStorageChange( + block_access_index=i + 1, + post_value=first_32_bytes, + ) + ], + ), + ) + else: + # Zero write (no-op): record as storage read + queue_reads.append(base_slot + 1) + # Slot +2: last 16 bytes of pubkey + amount + queue_writes.append( + BalStorageSlot( + slot=base_slot + 2, + slot_changes=[ + BalStorageChange( + block_access_index=i + 1, + post_value=_encode_pubkey_amount_slot( + withdrawal_requests[i] + ), + ) + ], + ), + ) + return queue_writes, queue_reads + + +def _extract_post_storage_from_queue_writes(queue_writes: list) -> dict: + """Extract post-state storage dict from queue writes.""" + post_storage = {} + for bal_slot in queue_writes: + # Get the final value from the last slot_change + if bal_slot.slot_changes: + post_storage[bal_slot.slot] = bal_slot.slot_changes[-1].post_value + return post_storage + + +def _build_incremental_changes( + count: int, + change_class: type, + value_param: str, + value_fn: Callable[[int], int] = lambda i: i, + reset_to: int | None = None, +) -> list: + """ + Build a list of incremental changes with customizable value function. + + Args: + count: Number of changes to create + change_class: Class to instantiate for each change + value_param: Parameter name for the value + (e.g., 'post_balance', 'post_value') + value_fn: Function to compute value from index (default: identity) + reset_to: Optional reset value to append at the end + + """ + changes = [ + change_class(block_access_index=i, **{value_param: value_fn(i)}) + for i in range(1, count + 1) + ] + if reset_to is not None: + changes.append( + change_class( + block_access_index=count + 1, **{value_param: reset_to} + ) + ) + return changes + + +# --- tests --- # + + +@pytest.mark.parametrize( + "pubkey", + # Use different pubkey based on parameter + # 0x01 has first 32 bytes all zero + # Full 48-byte pubkey with non-zero first word + [0x01, b"key" * 16], + ids=["pubkey_first_word_zero", "pubkey_first_word_nonzero"], +) +@pytest.mark.parametrize( + "amount", + [0, 1000], + ids=["amount_zero", "amount_nonzero"], +) +def test_bal_7002_clean_sweep( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + pubkey: bytes, + amount: int, +) -> None: + """ + Ensure BAL correctly tracks "clean sweep" where all withdrawal requests + are dequeued in same block (requests ≤ MAX). + + Tests combinations of: + - pubkey with first 32 bytes zero / non-zero + - amount zero / non-zero + """ + alice = pre.fund_eoa() + + withdrawal_request = WithdrawalRequest( + validator_pubkey=pubkey, + amount=amount, + fee=Spec7002.get_fee(0), + ) + + # Transaction to system contract + tx = Transaction( + sender=alice, + to=Address(Spec7002.WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS), + value=withdrawal_request.fee, + data=withdrawal_request.calldata, + gas_limit=200_000, + ) + + # Build queue writes and reads based on pubkey + queue_writes, queue_reads = _build_queue_storage_slots( + [alice], [withdrawal_request] + ) + + # Base storage reads that always happen + base_storage_reads = [ + # Excess is read-only if while dequeuing queue doesn't overflow + Spec7002.EXCESS_WITHDRAWAL_REQUESTS_STORAGE_SLOT, + # Head slot is read while dequeuing + Spec7002.WITHDRAWAL_REQUEST_QUEUE_HEAD_STORAGE_SLOT, + ] + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + Spec7002.WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS: BalAccountExpectation( # noqa: E501 + balance_changes=[ + # Fee is collected. + BalBalanceChange( + block_access_index=1, + post_balance=withdrawal_request.fee, + ) + ], + storage_reads=base_storage_reads + queue_reads, + storage_changes=[ + BalStorageSlot( + slot=Spec7002.WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT, + # Count goes by number of request. + # Invariant 1: Post-execution ALWAYS resets count. + slot_changes=_build_incremental_changes( + 1, + BalStorageChange, + "post_value", + lambda i: i, + reset_to=0, + ), + ), + BalStorageSlot( + slot=Spec7002.WITHDRAWAL_REQUEST_QUEUE_TAIL_STORAGE_SLOT, + # Tail index goes up by number of requests. + # Invariant 2: resets if clean sweep. + slot_changes=_build_incremental_changes( + 1, + BalStorageChange, + "post_value", + lambda i: i, + reset_to=0, + ), + ), + ] + + queue_writes, + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + Spec7002.WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS: Account( + balance=withdrawal_request.fee, + storage=_extract_post_storage_from_queue_writes(queue_writes), + ), + }, + ) + + +def test_bal_7002_partial_sweep( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL correctly tracks queue overflow when requests exceed MAX. + Block 1: 20 requests (partial sweep, 16 dequeued). + Block 2: Empty (clean sweep of remaining 4). + """ + num_requests = 20 + fee = Spec7002.get_fee(0) + senders = [pre.fund_eoa() for _ in range(num_requests)] + + # Block 1: 20 withdrawal requests + withdrawal_requests = [ + WithdrawalRequest(validator_pubkey=i + 1, amount=0, fee=fee) + for i in range(num_requests) + ] + + eip7002_address = Address(Spec7002.WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS) + + txs_block_1 = [ + Transaction( + sender=sender, + to=eip7002_address, + value=withdrawal_request.fee, + data=withdrawal_request.calldata, + gas_limit=200_000, + ) + for sender, withdrawal_request in zip( + senders, withdrawal_requests, strict=True + ) + ] + + excess_after_block_1 = Spec7002.get_excess_withdrawal_requests( + 0, num_requests + ) + + block_1_expectations: dict = { + sender: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=i + 1, post_nonce=1) + ] + ) + for i, sender in enumerate(senders) + } + + # Build queue writes and reads + queue_writes, queue_reads = _build_queue_storage_slots( + senders, withdrawal_requests + ) + + block_1_expectations[eip7002_address] = BalAccountExpectation( + balance_changes=_build_incremental_changes( + num_requests, + BalBalanceChange, + "post_balance", + lambda i: fee * i, + ), + storage_reads=queue_reads, + storage_changes=[ + # Excess is only updated once during + # dequeue + BalStorageSlot( + slot=Spec7002.EXCESS_WITHDRAWAL_REQUESTS_STORAGE_SLOT, + slot_changes=[ + BalStorageChange( + block_access_index=num_requests + 1, + post_value=excess_after_block_1, + ) + ], + ), + BalStorageSlot( + slot=Spec7002.WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT, + slot_changes=_build_incremental_changes( + num_requests, + BalStorageChange, + "post_value", + lambda i: i, + reset_to=0, + ), + ), + BalStorageSlot( + slot=Spec7002.WITHDRAWAL_REQUEST_QUEUE_HEAD_STORAGE_SLOT, + slot_changes=[ + BalStorageChange( + block_access_index=num_requests + 1, + post_value=Spec7002.MAX_WITHDRAWAL_REQUESTS_PER_BLOCK, + ) + ], + ), + BalStorageSlot( + slot=Spec7002.WITHDRAWAL_REQUEST_QUEUE_TAIL_STORAGE_SLOT, + slot_changes=_build_incremental_changes( + num_requests, + BalStorageChange, + "post_value", + lambda i: i, + ), + ), + ] + + queue_writes, + ) + + # Block 2: Empty block, clean sweep of remaining 4 requests + excess_after_block_2 = Spec7002.get_excess_withdrawal_requests( + excess_after_block_1, 0 + ) + + block_2_expectations = { + eip7002_address: BalAccountExpectation( + storage_reads=[Spec7002.WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT], + storage_changes=[ + BalStorageSlot( + slot=Spec7002.EXCESS_WITHDRAWAL_REQUESTS_STORAGE_SLOT, + slot_changes=[ + BalStorageChange( + block_access_index=1, + post_value=excess_after_block_2, + ) + ], + ), + # Head is cleared + BalStorageSlot( + slot=Spec7002.WITHDRAWAL_REQUEST_QUEUE_HEAD_STORAGE_SLOT, + slot_changes=[ + BalStorageChange(block_access_index=1, post_value=0) + ], + ), + # Tail is cleared + BalStorageSlot( + slot=Spec7002.WITHDRAWAL_REQUEST_QUEUE_TAIL_STORAGE_SLOT, + slot_changes=[ + BalStorageChange(block_access_index=1, post_value=0) + ], + ), + ], + ) + } + + # Build post state storage: queue data persists even after dequeue + post_storage = _extract_post_storage_from_queue_writes(queue_writes) + post_storage[Spec7002.EXCESS_WITHDRAWAL_REQUESTS_STORAGE_SLOT] = ( + excess_after_block_2 + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=txs_block_1, + expected_block_access_list=BlockAccessListExpectation( + account_expectations=block_1_expectations + ), + ), + Block( + txs=[], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=block_2_expectations + ), + ), + ], + post={ + **{sender: Account(nonce=1) for sender in senders}, + eip7002_address: Account( + balance=fee * num_requests, + storage=post_storage, + ), + }, + ) + + +def test_bal_7002_no_withdrawal_requests( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures EIP-7002 system contract dequeue operation even + when block has no withdrawal requests. + + This test verifies that the post-execution dequeue system call always + reads queue state (slots 0-3), even when no requests are present. The + system contract should have storage_reads but no storage_changes. + """ + alice = pre.fund_eoa() + bob = pre.fund_eoa(amount=0) + + value = 10 + + tx = Transaction( + sender=alice, + to=bob, + value=value, + gas_limit=200_000, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=value + ) + ], + ), + Spec7002.WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS: BalAccountExpectation( # noqa: E501 + storage_reads=[ + Spec7002.EXCESS_WITHDRAWAL_REQUESTS_STORAGE_SLOT, + Spec7002.WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT, + Spec7002.WITHDRAWAL_REQUEST_QUEUE_HEAD_STORAGE_SLOT, + Spec7002.WITHDRAWAL_REQUEST_QUEUE_TAIL_STORAGE_SLOT, + ], + storage_changes=[], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + bob: Account(balance=value), + }, + ) + + +def test_bal_7002_request_from_contract( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + 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). + """ + fee = Spec7002.get_fee(0) + + # Create withdrawal request interaction using Prague helper + interaction = WithdrawalRequestContract( + requests=[ + WithdrawalRequest( + validator_pubkey=0x01, + amount=0, + fee=fee, + ) + ], + contract_balance=fee, + ) + + # Set up pre-state using helper + interaction.update_pre(pre) + + alice = interaction.sender_account + relay_contract = interaction.contract_address + + # Build queue storage slots with contract as source + queue_writes, queue_reads = _build_queue_storage_slots( + [relay_contract], interaction.requests + ) + + block = Block( + txs=interaction.transactions(), + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + relay_contract: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, + post_balance=0, + ) + ], + ), + Spec7002.WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS: BalAccountExpectation( # noqa: E501 + balance_changes=[ + BalBalanceChange( + block_access_index=1, + post_balance=fee, + ) + ], + storage_reads=[ + Spec7002.EXCESS_WITHDRAWAL_REQUESTS_STORAGE_SLOT, + Spec7002.WITHDRAWAL_REQUEST_QUEUE_HEAD_STORAGE_SLOT, + ] + + queue_reads, + storage_changes=[ + BalStorageSlot( + slot=Spec7002.WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT, + slot_changes=_build_incremental_changes( + 1, + BalStorageChange, + "post_value", + lambda i: i, + reset_to=0, + ), + ), + BalStorageSlot( + slot=Spec7002.WITHDRAWAL_REQUEST_QUEUE_TAIL_STORAGE_SLOT, + slot_changes=_build_incremental_changes( + 1, + BalStorageChange, + "post_value", + lambda i: i, + reset_to=0, + ), + ), + ] + + queue_writes, + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + relay_contract: Account(balance=0), + Spec7002.WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS: Account( + balance=fee, + storage=_extract_post_storage_from_queue_writes(queue_writes), + ), + }, + ) + + +@pytest.mark.parametrize( + "interaction", + [ + pytest.param( + WithdrawalRequestTransaction( + requests=[ + WithdrawalRequest( + validator_pubkey=0x01, + amount=0, + fee=0, # Below MIN_WITHDRAWAL_REQUEST_FEE + valid=False, + ) + ] + ), + id="insufficient_fee", + ), + pytest.param( + WithdrawalRequestTransaction( + requests=[ + WithdrawalRequest( + validator_pubkey=0x01, + amount=0, + fee=Spec7002.get_fee(0), + calldata_modifier=lambda x: x[ + :-1 + ], # 55 bytes instead of 56 + valid=False, + ) + ] + ), + id="calldata_too_short", + ), + pytest.param( + WithdrawalRequestTransaction( + requests=[ + WithdrawalRequest( + validator_pubkey=0x01, + amount=0, + fee=Spec7002.get_fee(0), + calldata_modifier=lambda x: x + + b"\x00", # 57 bytes instead of 56 + valid=False, + ) + ] + ), + id="calldata_too_long", + ), + pytest.param( + WithdrawalRequestTransaction( + requests=[ + WithdrawalRequest( + validator_pubkey=0x01, + amount=0, + fee=Spec7002.get_fee(0), + gas_limit=25_000, # Insufficient gas + valid=False, + ) + ] + ), + id="oog", + ), + pytest.param( + WithdrawalRequestContract( + requests=[ + WithdrawalRequest( + validator_pubkey=0x01, + amount=0, + fee=Spec7002.get_fee(0), + valid=False, + ) + ], + call_type=Op.DELEGATECALL, + ), + id="invalid_call_type_delegatecall", + ), + pytest.param( + WithdrawalRequestContract( + requests=[ + WithdrawalRequest( + validator_pubkey=0x01, + amount=0, + fee=Spec7002.get_fee(0), + valid=False, + ) + ], + call_type=Op.STATICCALL, + ), + id="invalid_call_type_staticcall", + ), + pytest.param( + WithdrawalRequestContract( + requests=[ + WithdrawalRequest( + validator_pubkey=0x01, + amount=0, + fee=Spec7002.get_fee(0), + valid=False, + ) + ], + call_type=Op.CALLCODE, + ), + id="invalid_call_type_callcode", + ), + pytest.param( + WithdrawalRequestContract( + requests=[ + WithdrawalRequest( + validator_pubkey=0x01, + amount=0, + fee=Spec7002.get_fee(0), + valid=False, + ) + ], + extra_code=Op.REVERT(0, 0), + ), + id="contract_reverts", + ), + ], +) +def test_bal_7002_request_invalid( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + interaction: WithdrawalRequestInteractionBase, +) -> None: + """ + Ensure BAL correctly handles invalid withdrawal request scenarios. + + Tests various failure modes: + - insufficient_fee: Transaction reverts due to fee below minimum + - calldata_too_short: Transaction reverts due to short calldata (55 bytes) + - calldata_too_long: Transaction reverts due to long calldata (57 bytes) + - oog: Transaction runs out of gas before completion + - invalid_call_type_*: Contract call via DELEGATECALL/STATICCALL/CALLCODE + - contract_reverts: Contract calls system contract but reverts after + + In all cases: + - Sender's nonce increments (transaction executed) + - Sender pays gas costs + - System contract is accessed during dequeue but has no state changes + - No withdrawal request is queued + """ + # Use helper to set up pre-state and get transaction + interaction.update_pre(pre) + tx = interaction.transactions()[0] + alice = interaction.sender_account + + # Build account expectations + account_expectations = { + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], + ), + Spec7002.WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS: BalAccountExpectation( + storage_reads=[ + Spec7002.EXCESS_WITHDRAWAL_REQUESTS_STORAGE_SLOT, + Spec7002.WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT, + Spec7002.WITHDRAWAL_REQUEST_QUEUE_HEAD_STORAGE_SLOT, + Spec7002.WITHDRAWAL_REQUEST_QUEUE_TAIL_STORAGE_SLOT, + ], + storage_changes=[], + ), + } + + # For all invalid scenarios, system contract should have reads but + # no write since the dequeue operation still happens post-execution + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + + post: dict = { + alice: Account(nonce=1), + Spec7002.WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS: Account(storage={}), + } + + # Add relay contract to post-state for contract scenarios + if isinstance(interaction, WithdrawalRequestContract): + post[interaction.contract_address] = Account() + + blockchain_test( + pre=pre, + blocks=[block], + post=post, + ) 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 6f70977982..09cd675424 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -23,7 +23,6 @@ | `test_bal_noop_storage_write` | Ensure BAL includes storage read but not write for no-op writes where pre-state equals post-state | Contract with pre-existing storage value `0x42` in slot `0x01`; transaction executes `SSTORE(0x01, 0x42)` (writing same value) | BAL **MUST** include the contract address with `storage_reads` for slot `0x01` since it was accessed, but **MUST NOT** include it in `storage_changes` (no actual state change). | ✅ Completed | | `test_bal_fully_unmutated_account` | Ensure BAL captures account that has zero net mutations | Alice sends 0 wei to `Oracle` which writes same pre-existing value to storage | BAL MUST include Alice with `nonce_changes` and balance changes (gas), `Oracle` with `storage_reads` for accessed slot but empty `storage_changes`. | ✅ Completed | | `test_bal_net_zero_balance_transfer` | BAL includes accounts with net-zero balance change but excludes them from balance changes | Contract receives and sends same amount to recipient using CALL or SELFDESTRUCT | BAL **MUST** include contract in `account_changes` without `balance_changes` (net zero). BAL **MUST** record non-zero `balance_changes` for recipient. | ✅ Completed | -| `test_bal_system_dequeue_withdrawals_eip7002` | BAL tracks post-exec system dequeues for withdrawals | Pre-populate EIP-7002 withdrawal requests; produce a block where dequeues occur | BAL MUST include the 7002 system contract with `storage_changes` (queue head/tail slots 0–3) using `block_access_index = len(txs)` and balance changes for withdrawal recipients. | 🟡 Planned | | `test_bal_system_dequeue_consolidations_eip7251` | BAL tracks post-exec system dequeues for consolidations | Pre-populate EIP-7251 consolidation requests; produce a block where dequeues occur | BAL MUST include the 7251 system contract with `storage_changes` (queue slots 0–3) using `block_access_index = len(txs)`. | 🟡 Planned | | `test_bal_aborted_storage_access` | Ensure BAL captures storage access in aborted transactions correctly | Alice calls contract that reads storage slot `0x01`, writes to slot `0x02`, then aborts with `REVERT`/`INVALID` | BAL MUST include storage_reads for slots `0x01` and `0x02` (aborted writes become reads), empty storage_changes. Only nonce changes for Alice. | ✅ Completed | | `test_bal_aborted_account_access` | Ensure BAL captures account access in aborted transactions for all account accessing opcodes | Alice calls `AbortContract` that performs account access operations (`BALANCE`, `EXTCODESIZE`, `EXTCODECOPY`, `EXTCODEHASH`, `CALL`, `CALLCODE`, `DELEGATECALL`, `STATICCALL`) on `TargetContract` and aborts via `REVERT`/`INVALID` | BAL MUST include Alice, `TargetContract`, and `AbortContract` in account_changes and nonce changes for Alice. | ✅ Completed | @@ -121,3 +120,8 @@ | `test_bal_spurious_entry_index_plus_2_with_cross_tx_read` | Ensure clients reject BALs containing a spurious entry at `bal_index = len(transactions)+2`, even if its slot is legitimately read elsewhere in the block | Block with `N` txs. BAL is modified to include an extra `StorageKey` entry with `block_access_index = N+2` for `(VictimContract, slot=0x01)`. Additionally include another tx in the same block that performs `SLOAD(VictimContract, 0x01)` (legitimate read). | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate that every `block_access_index` in BAL is in-range (≤ `N` plus any allowed system ops), and **MUST NOT** accept out-of-range indices even if the `(address, slot)` appears elsewhere legitimately. | 🟡 Planned | | `test_bal_spurious_entry_index_plus_2_with_cross_tx_write` | Ensure clients reject BALs containing a spurious entry at `bal_index = len(transactions)+2`, even if its slot is legitimately written elsewhere in the block | Block with `N` txs. BAL is modified to include an extra `StorageKey` entry with `block_access_index = N+2` for `(VictimContract, slot=0x01)`. Additionally include another tx in the same block that performs `SSTORE(VictimContract, 0x01, 0x42)` (legitimate write). | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate `block_access_index` bounds and reject spurious indices regardless of whether the referenced `(address, slot)` is otherwise accessed or mutated in the block. | 🟡 Planned | | `test_bal_spurious_entry_index_plus_2_no_other_txs` | Ensure clients reject BALs containing a spurious entry at `bal_index = len(transactions)+2` when no other transaction touches the referenced slot | Block with `N` txs that do not access `(VictimContract, slot=0x01)`. BAL is modified to include an extra `StorageKey` entry with `block_access_index = N+2` for `(VictimContract, slot=0x01)`. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** reject any BAL that contains out-of-range `block_access_index` values, independent of access patterns in the executed block. | 🟡 Planned | +| `test_bal_7002_clean_sweep` | Ensure BAL correctly tracks "clean sweep" where all withdrawal requests are dequeued in same block (requests ≤ MAX). Parameterized: (1) pubkey first 32 bytes zero / non-zero, (2) amount zero / non-zero | Alice sends transaction to `WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS` with 1 withdrawal request. Validator pubkey has either first 32 bytes zero or non-zero. Amount is either zero or non-zero. Since 1 ≤ MAX_WITHDRAWAL_REQUESTS_PER_BLOCK, post-execution system call dequeues all requests ("clean sweep"), resetting head and tail to 0. | BAL **MUST** include Alice with `nonce_changes` at `block_access_index=1`. `WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS` **MUST** have: `balance_changes` at `block_access_index=1` (receives fee), `storage_reads` for excess, head, and slot 5 (first 32 bytes of pubkey) if zero. At `block_access_index=1` (tx enqueue): `storage_changes` for count (0→1), tail (0→1), slot 4 (source address), slot 5 (first 32 bytes, **ONLY** if non-zero), slot 6. At `block_access_index=2` (post-exec dequeue): `storage_changes` for count (1→0), tail (1→0). Clean sweep invariant: when all requests dequeued, both head and tail reset to 0. | ✅ Completed | +| `test_bal_7002_partial_sweep` | Ensure BAL correctly tracks queue overflow when requests exceed MAX, demonstrating partial sweep in block 1 and cleanup in block 2 | Block 1: 20 different EOAs each send withdrawal request to `WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS`. Since 20 > MAX_WITHDRAWAL_REQUESTS_PER_BLOCK, only first MAX requests dequeued ("partial sweep"), leaving 4 in queue. Block 2: Empty block (no transactions), remaining 4 requests dequeued ("clean sweep"), queue becomes empty. | Block 1 BAL **MUST** include all 20 senders with `nonce_changes` at respective `block_access_index` (1-20). `WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS` at each tx: `storage_changes` for count (increments to 20), tail (increments to 20). At `block_access_index=21` (post-exec partial dequeue): `storage_changes` for count (20→0), head (0→MAX). Partial sweep: head advances by MAX, tail stays 20, queue has 4 remaining (tail - head = 4). Block 2 BAL **MUST** include `WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS` at `block_access_index=1` (post-exec clean sweep): `storage_changes` for head (MAX→0), tail (20→0). Clean sweep: both head and tail reset to 0, queue empty. |✅ Completed | +| `test_bal_7002_no_withdrawal_requests` | Ensure BAL captures EIP-7002 system contract dequeue operation even when block has no withdrawal requests | Block with 1 transaction: Alice sends 10 wei to Bob. No withdrawal requests submitted. | BAL **MUST** include Alice with `nonce_changes` at `block_access_index=1`. BAL **MUST** include Bob with `balance_changes` at `block_access_index=1`. BAL **MUST** include EIP-7002 system contract (`WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS`) with `storage_reads` for slots: excess (slot 0), count (slot 1), head (slot 2), tail (slot 3). System contract **MUST NOT** have `storage_changes` (no writes occur when queue is empty). This test demonstrates that the post-execution dequeue operation always runs and reads queue state, even when no requests are present. | ✅ Completed | +| `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 |