diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 166fa131fac..cd57c5f56bb 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -41,6 +41,7 @@ Test fixtures for use by clients are available for each release on the [Github r - ✨ Add essential tests for coverage gaps in EIP-7951 (`p256verify` precompile) ([#2179](https://github.com/ethereum/execution-spec-tests/pull/2159), [#2203](https://github.com/ethereum/execution-spec-tests/pull/2203), [#2215](https://github.com/ethereum/execution-spec-tests/pull/2215), [#2216](https://github.com/ethereum/execution-spec-tests/pull/2216), [#2217](https://github.com/ethereum/execution-spec-tests/pull/2217), [#2218](https://github.com/ethereum/execution-spec-tests/pull/2218), [#2221](https://github.com/ethereum/execution-spec-tests/pull/2221), [#2229](https://github.com/ethereum/execution-spec-tests/pull/2229), [#2230](https://github.com/ethereum/execution-spec-tests/pull/2230), [#2237](https://github.com/ethereum/execution-spec-tests/pull/2237), [#2238](https://github.com/ethereum/execution-spec-tests/pull/2238)). - ✨ Add EIP-7928 successful and OOG single-opcode tests ([#2118](https://github.com/ethereum/execution-spec-tests/pull/2118)). - ✨ Add EIP-7928 tests for EIP-2930 interactions ([#2167](https://github.com/ethereum/execution-spec-tests/pull/2167)). +- ✨ Add EIP-7928 tests for NOOP operations ([#2178](https://github.com/ethereum/execution-spec-tests/pull/2178)). ## [v5.0.0](https://github.com/ethereum/execution-spec-tests/releases/tag/v5.0.0) - 2025-09-05 diff --git a/src/ethereum_test_types/block_access_list/expectations.py b/src/ethereum_test_types/block_access_list/expectations.py index 7eb8e64e062..4d9ecc7fa49 100644 --- a/src/ethereum_test_types/block_access_list/expectations.py +++ b/src/ethereum_test_types/block_access_list/expectations.py @@ -5,7 +5,7 @@ BAL values in tests. """ -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, ClassVar, Dict, List, Optional from pydantic import Field, PrivateAttr @@ -53,6 +53,33 @@ class BalAccountExpectation(CamelModel): default=None, description="Explicit absent value expectations using BalAccountAbsentValues" ) + _EMPTY: ClassVar[Optional["BalAccountExpectation"]] = None + + @classmethod + def empty(cls) -> "BalAccountExpectation": + """ + Create an expectation that validates the account has NO changes. + + This is distinct from `BalAccountExpectation()` with no fields set, + which is ambiguous and clashes with `model_fields_set` logic, and + will raise a clarifying error if used in expectations. + + Returns: + A BalAccountExpectation instance with all change lists empty. + This uses a classvar to facilitate identity checks across + multiple expectation instances. + + """ + if cls._EMPTY is None: + cls._EMPTY = cls( + nonce_changes=[], + balance_changes=[], + code_changes=[], + storage_changes=[], + storage_reads=[], + ) + return cls._EMPTY + def compose( *modifiers: Callable[["BlockAccessList"], "BlockAccessList"], @@ -168,13 +195,14 @@ def verify_against(self, actual_bal: "BlockAccessList") -> None: raise BlockAccessListValidationError( f"Address {address} should not be in BAL but was found" ) - elif expectation == BalAccountExpectation(): - # explicit check for NO account changes for the address - if actual_accounts_by_addr.get(address) != BalAccountChange(address=address): - raise BlockAccessListValidationError( - f"No account changes expected for {address} but found " - f"changes: {actual_accounts_by_addr[address]}" - ) + elif not expectation.model_fields_set: + # Disallow ambiguous BalAccountExpectation() with no fields set + raise BlockAccessListValidationError( + f"Address {address}: BalAccountExpectation() with no fields set is " + f"ambiguous. Use BalAccountExpectation.empty() to validate no changes, " + f"or explicitly set the fields to validate " + f"(e.g., nonce_changes=[...])." + ) else: # check address is present and validate changes if address not in actual_accounts_by_addr: @@ -182,6 +210,14 @@ def verify_against(self, actual_bal: "BlockAccessList") -> None: f"Expected address {address} not found in actual BAL" ) + if expectation is BalAccountExpectation.empty(): + # explicit check for "no changes" validation w/ .empty() + if actual_accounts_by_addr.get(address) != BalAccountChange(address=address): + raise BlockAccessListValidationError( + f"No account changes expected for {address} but found " + f"changes: {actual_accounts_by_addr[address]}" + ) + actual_account = actual_accounts_by_addr[address] try: self._compare_account_expectations(expectation, actual_account) diff --git a/src/ethereum_test_types/tests/test_block_access_lists.py b/src/ethereum_test_types/tests/test_block_access_lists.py index 0142dce413f..cd78a1cfe93 100644 --- a/src/ethereum_test_types/tests/test_block_access_lists.py +++ b/src/ethereum_test_types/tests/test_block_access_lists.py @@ -69,7 +69,18 @@ def test_address_exclusion_validation_raises_when_address_is_present(): expectation.verify_against(actual_bal) -def test_empty_account_changes_raises_when_changes_are_present(): +@pytest.mark.parametrize( + "empty_changes_definition,exception_message", + [ + [BalAccountExpectation(), "ambiguous. Use BalAccountExpectation.empty()"], + [BalAccountExpectation.empty(), "No account changes expected for "], + ], + ids=["BalAccountExpectation()", "BalAccountExpectation.empty()"], +) +def test_empty_account_changes_definitions( + empty_changes_definition, + exception_message, +): """ Test that validation fails when expected empty changes but actual has changes. @@ -85,12 +96,11 @@ def test_empty_account_changes_raises_when_changes_are_present(): ] ) - expectation = BlockAccessListExpectation(account_expectations={alice: BalAccountExpectation()}) + expectation = BlockAccessListExpectation( + account_expectations={alice: empty_changes_definition} + ) - with pytest.raises( - BlockAccessListValidationError, - match=f"No account changes expected for {alice}", - ): + with pytest.raises(BlockAccessListValidationError, match=exception_message): expectation.verify_against(actual_bal) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py index 7142d7877e2..ad19a2687b5 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py @@ -5,6 +5,7 @@ import pytest from ethereum_test_base_types import AccessList, Address, Hash +from ethereum_test_forks import Fork from ethereum_test_specs.blockchain import Header from ethereum_test_tools import ( Account, @@ -382,8 +383,8 @@ def test_bal_account_access_target( alice: BalAccountExpectation( nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)] ), - target_contract: BalAccountExpectation(), - oracle_contract: BalAccountExpectation(), + target_contract: BalAccountExpectation.empty(), + oracle_contract: BalAccountExpectation.empty(), } ), ) @@ -462,7 +463,7 @@ def test_bal_callcode_with_value_transfer( bob: BalAccountExpectation( balance_changes=[BalBalanceChange(tx_index=1, post_balance=100)], ), - target_contract: BalAccountExpectation(), + target_contract: BalAccountExpectation.empty(), } ), ) @@ -522,7 +523,7 @@ def test_bal_delegated_storage_writes( ) ], ), - target_contract: BalAccountExpectation(), + target_contract: BalAccountExpectation.empty(), } ), ) @@ -577,7 +578,7 @@ def test_bal_delegated_storage_reads( oracle_contract: BalAccountExpectation( storage_reads=[0x01], ), - target_contract: BalAccountExpectation(), + target_contract: BalAccountExpectation.empty(), } ), ) @@ -737,7 +738,7 @@ def test_bal_2930_slot_listed_but_untouched( nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], ), # The account was loaded. - pure_calculator: BalAccountExpectation(), + pure_calculator: BalAccountExpectation.empty(), } ), ) @@ -871,3 +872,288 @@ def test_bal_2930_slot_listed_and_unlisted_reads( storage_reader: Account(storage={0x01: 0x42, 0x02: 0x43}), }, ) + + +def test_bal_self_transfer( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork, +): + """Test that BAL correctly handles self-transfers.""" + start_balance = 1_000_000 + alice = pre.fund_eoa(amount=start_balance) + + intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() + intrinsic_gas_cost = intrinsic_gas_calculator() + + tx = Transaction( + sender=alice, to=alice, gas_limit=intrinsic_gas_cost, value=100, gas_price=0xA + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + balance_changes=[ + BalBalanceChange( + tx_index=1, + post_balance=start_balance - intrinsic_gas_cost * tx.gas_price, + ) + ], + ) + } + ), + ) + + blockchain_test(pre=pre, blocks=[block], post={}) + + +def test_bal_zero_value_transfer( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork, +): + """Test that BAL correctly handles zero-value transfers.""" + start_balance = 1_000_000 + alice = pre.fund_eoa(amount=start_balance) + bob = pre.fund_eoa(amount=100) + + intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() + intrinsic_gas_cost = intrinsic_gas_calculator() + + tx = Transaction(sender=alice, to=bob, gas_limit=intrinsic_gas_cost, value=0, gas_price=0xA) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + balance_changes=[ + BalBalanceChange( + tx_index=1, + post_balance=start_balance - intrinsic_gas_cost * tx.gas_price, + ) + ], + ), + # Include the address; omit from balance_changes. + bob: BalAccountExpectation(balance_changes=[]), + } + ), + ) + + blockchain_test(pre=pre, blocks=[block], post={}) + + +def test_bal_pure_contract_call( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, +): + """Test that BAL captures contract access for pure computation calls.""" + alice = pre.fund_eoa() + pure_contract = pre.deploy_contract(code=Op.ADD(0x3, 0x2)) + + intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() + gas_limit = intrinsic_gas_calculator() + 5_000 # Buffer + + tx = Transaction(sender=alice, to=pure_contract, gas_limit=gas_limit, gas_price=0xA) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + # Ensure called contract is tracked + pure_contract: BalAccountExpectation.empty(), + } + ), + ) + + blockchain_test(pre=pre, blocks=[block], post={}) + + +def test_bal_noop_storage_write( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, +): + """Test that BAL correctly handles no-op storage write.""" + alice = pre.fund_eoa() + storage_contract = pre.deploy_contract(code=Op.SSTORE(0x01, 0x42), storage={0x01: 0x42}) + + intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() + gas_limit = ( + intrinsic_gas_calculator() + # Sufficient gas for write + + fork.gas_costs().G_COLD_SLOAD + + fork.gas_costs().G_COLD_ACCOUNT_ACCESS + + fork.gas_costs().G_STORAGE_SET + + fork.gas_costs().G_BASE * 10 # Buffer for push + ) + + tx = Transaction(sender=alice, to=storage_contract, gas_limit=gas_limit, gas_price=0xA) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + storage_contract: BalAccountExpectation( + storage_reads=[0x01], + storage_changes=[], + ), + } + ), + ) + + blockchain_test(pre=pre, blocks=[block], post={}) + + +@pytest.mark.parametrize( + "abort_opcode", + [ + pytest.param(Op.REVERT(0, 0), id="revert"), + pytest.param(Op.INVALID, id="invalid"), + ], +) +def test_bal_aborted_storage_access( + pre: Alloc, blockchain_test: BlockchainTestFiller, abort_opcode: Op +): + """Ensure BAL captures storage access in aborted transactions correctly.""" + alice = pre.fund_eoa() + storage_contract = pre.deploy_contract( + code=Op.SLOAD(0x01) + Op.SSTORE(0x02, 0x42) + abort_opcode, + storage={0x01: 0x10}, # Pre-existing value in slot 0x01 + ) + + tx = Transaction(sender=alice, to=storage_contract, gas_limit=5_000_000, gas_price=0xA) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)] + ), + storage_contract: BalAccountExpectation( + storage_changes=[], + storage_reads=[0x01, 0x02], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={}, + ) + + +@pytest.mark.parametrize( + "account_access_opcode", + [ + pytest.param(lambda target_addr: Op.BALANCE(target_addr), id="balance"), + pytest.param(lambda target_addr: Op.EXTCODESIZE(target_addr), id="extcodesize"), + pytest.param(lambda target_addr: Op.EXTCODECOPY(target_addr, 0, 0, 32), id="extcodecopy"), + pytest.param(lambda target_addr: Op.EXTCODEHASH(target_addr), id="extcodehash"), + pytest.param(lambda target_addr: Op.CALL(0, target_addr, 50, 0, 0, 0, 0), id="call"), + pytest.param( + lambda target_addr: Op.CALLCODE(0, target_addr, 50, 0, 0, 0, 0), id="callcode" + ), + pytest.param( + lambda target_addr: Op.DELEGATECALL(0, target_addr, 0, 0, 0, 0), id="delegatecall" + ), + pytest.param( + lambda target_addr: Op.STATICCALL(0, target_addr, 0, 0, 0, 0), id="staticcall" + ), + ], +) +@pytest.mark.parametrize( + "abort_opcode", + [ + pytest.param(Op.REVERT(0, 0), id="revert"), + pytest.param(Op.INVALID, id="invalid"), + ], +) +def test_bal_aborted_account_access( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + account_access_opcode, + abort_opcode: Op, +): + """Ensure BAL captures account access in aborted transactions.""" + alice = pre.fund_eoa() + target_contract = pre.deploy_contract(code=Op.STOP) + + abort_contract = pre.deploy_contract( + balance=100, + code=account_access_opcode(target_contract) + abort_opcode, + ) + + tx = Transaction(sender=alice, to=abort_contract, gas_limit=5_000_000, gas_price=0xA) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)] + ), + target_contract: BalAccountExpectation.empty(), + abort_contract: BalAccountExpectation.empty(), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={}, + ) + + +def test_bal_fully_unmutated_account( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +): + """ + Test that BAL captures account that has zero net mutations. + + oracle account: + 1. Storage read and write the same value (no net change). + 2. Receives `0` value transfer (no net change). + """ + alice = pre.fund_eoa() + # Deploy Oracle contract with pre-existing storage value + oracle = pre.deploy_contract( + code=Op.SSTORE(0x01, 0x42) + Op.STOP, + storage={0x01: 0x42}, # Pre-existing value + ) + + tx = Transaction(sender=alice, to=oracle, gas_limit=1_000_000, value=0, gas_price=0xA) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + oracle: BalAccountExpectation( + storage_changes=[], # No net storage changes + storage_reads=[0x01], # But storage was accessed + balance_changes=[], # No net balance changes + ), + } + ), + ) + + blockchain_test(pre=pre, blocks=[block], post={}) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py index 7d8ea698d33..1bfb3fcb630 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py @@ -231,9 +231,9 @@ def test_bal_balance_and_oog( txs=[tx], expected_block_access_list=BlockAccessListExpectation( account_expectations={ - balance_checker: BalAccountExpectation(), + balance_checker: BalAccountExpectation.empty(), # Bob should only appear in BAL if BALANCE succeeded - **({} if fails_at_balance else {bob: BalAccountExpectation()}), + **({} if fails_at_balance else {bob: BalAccountExpectation.empty()}), } ), ) @@ -300,9 +300,13 @@ def test_bal_extcodesize_and_oog( txs=[tx], expected_block_access_list=BlockAccessListExpectation( account_expectations={ - codesize_checker: BalAccountExpectation(), + codesize_checker: BalAccountExpectation.empty(), # Target should only appear if EXTCODESIZE succeeded - **({} if fails_at_extcodesize else {target_contract: BalAccountExpectation()}), + **( + {} + if fails_at_extcodesize + else {target_contract: BalAccountExpectation.empty()} + ), } ), ) @@ -369,9 +373,9 @@ def test_bal_call_and_oog( txs=[tx], expected_block_access_list=BlockAccessListExpectation( account_expectations={ - call_contract: BalAccountExpectation(), + call_contract: BalAccountExpectation.empty(), # Bob should only appear if CALL succeeded - **({} if fails_at_call else {bob: BalAccountExpectation()}), + **({} if fails_at_call else {bob: BalAccountExpectation.empty()}), } ), ) @@ -443,9 +447,13 @@ def test_bal_delegatecall_and_oog( txs=[tx], expected_block_access_list=BlockAccessListExpectation( account_expectations={ - delegatecall_contract: BalAccountExpectation(), + delegatecall_contract: BalAccountExpectation.empty(), # Target should only appear if DELEGATECALL succeeded - **({} if fails_at_delegatecall else {target_contract: BalAccountExpectation()}), + **( + {} + if fails_at_delegatecall + else {target_contract: BalAccountExpectation.empty()} + ), } ), ) @@ -515,9 +523,13 @@ def test_bal_extcodecopy_and_oog( txs=[tx], expected_block_access_list=BlockAccessListExpectation( account_expectations={ - extcodecopy_contract: BalAccountExpectation(), + extcodecopy_contract: BalAccountExpectation.empty(), # Target should only appear if EXTCODECOPY succeeded - **({} if fails_at_extcodecopy else {target_contract: BalAccountExpectation()}), + **( + {} + if fails_at_extcodecopy + else {target_contract: BalAccountExpectation.empty()} + ), } ), ) 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 2977e4da932..033e37d5cab 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -19,13 +19,18 @@ | `test_bal_2930_slot_listed_and_unlisted_writes` | Ensure BAL includes storage writes regardless of access list presence | Alice sends tx with EIP-2930 access list including `(StorageWriter, slot=0x01)`; StorageWriter executes `SSTORE` to slots `0x01` and `0x02` | BAL MUST include `storage_changes` for StorageWriter's slots `0x01` and `0x02` | ✅ Completed | | `test_bal_2930_slot_listed_and_unlisted_reads` | Ensure BAL includes storage reads regardless of access list presence | Alice sends tx with EIP-2930 access list including `(StorageReader, slot=0x01)`; StorageReader executes `SLOAD` from slots `0x01` and `0x02` | BAL MUST include `storage_reads` for StorageReader's slots `0x01` and `0x02` | ✅ Completed | | `test_bal_7702_delegated_create` | BAL tracks EIP-7702 delegation indicator write and contract creation | Alice sends a type-4 (7702) tx authorizing herself to delegate to `Deployer` code which executes `CREATE` | BAL MUST include for **Alice**: `code_changes` (delegation indicator), `nonce_changes` (increment from 7702 processing), and `balance_changes` (post-gas). For **Child**: `code_changes` (runtime bytecode) and `nonce_changes = 1`. | 🟡 Planned | -| `test_bal_self_transfer` | BAL handles self-transfers correctly | Alice sends `1 ETH` to **Alice** | BAL MUST include **one** entry for Alice with `balance_changes` reflecting **gas only** (value cancels out) and a nonce change; Coinbase balance updated for fees; no separate recipient row. | 🟡 Planned | +| `test_bal_self_transfer` | BAL handles self-transfers correctly | Alice sends `100 wei` to Alice | BAL **MUST** include one entry for Alice with `balance_changes` reflecting gas cost only (value cancels out) and nonce change. | ✅ Completed | +| `test_bal_zero_value_transfer` | BAL handles zero-value transfers correctly | Alice sends `0 wei` to Bob | BAL **MUST** include Alice with `balance_changes` (gas cost only) and nonce change, and Bob in `account_changes` with empty `balance_changes`. | ✅ Completed | | `test_bal_system_contracts_2935_4788` | BAL includes pre-exec system writes for parent hash & beacon root | Build a block with `N` normal txs; 2935 & 4788 active | BAL MUST include `HISTORY_STORAGE_ADDRESS` (EIP-2935) and `BEACON_ROOTS_ADDRESS` (EIP-4788) with `storage_changes` to ring-buffer slots; each write uses `tx_index = N` (system op). | 🟡 Planned | | `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 `tx_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 `tx_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 | +| `test_bal_pure_contract_call` | Ensure BAL captures contract access for pure computation calls | Alice calls `PureContract` that performs pure arithmetic (ADD operation) without storage or balance changes | BAL MUST include Alice and `PureContract` in `account_changes`, and `nonce_changes` for Alice. | ✅ Completed | | `test_bal_create2_to_A_read_then_selfdestruct` | BAL records balance change for A and storage access (no persistent change) | Tx0: Alice sends ETH to address **A**. Tx1: Deployer `CREATE2` a contract **at A**; contract does `SLOAD(B)` and immediately `SELFDESTRUCT(beneficiary=X)` in the same tx. | BAL **MUST** include **A** with `balance_changes` (funding in Tx0 and transfer on selfdestruct in Tx1). BAL **MUST** include storage key **B** as an accessed `StorageKey`, and **MUST NOT** include **B** under `storage_changes` (no persistence due to same-tx create+destruct). | 🟡 Planned | | `test_bal_create2_to_A_write_then_selfdestruct` | BAL records balance change for A and storage access even if a write occurred (no persistent change) | Tx0: Alice sends ETH to **A**. Tx1: Deployer `CREATE2` contract **at A**; contract does `SSTORE(B, v)` (optionally `SLOAD(B)`), then `SELFDESTRUCT(beneficiary=Y)` in the same tx. | BAL **MUST** include **A** with `balance_changes` (Tx0 fund; Tx1 outflow to `Y`). BAL **MUST** include **B** as `StorageKey` accessed, and **MUST NOT** include **B** under `storage_changes` (ephemeral write discarded because the contract was created and destroyed in the same tx). | 🟡 Planned | | `test_bal_precompile_funded_then_called` | BAL records precompile with balance change (fund) and access (call) | **Tx0**: Alice sends `1 ETH` to `ecrecover` (0x01). **Tx1**: Alice (or Bob) calls `ecrecover` with valid input and `0 ETH`. | BAL **MUST** include address `0x01` with `balance_changes` (from Tx0). No `storage_changes` or `code_changes`. | 🟡 Planned | | `test_bal_precompile_call_only` | BAL records precompile when called with no balance change | Alice calls `ecrecover` (0x01) with a valid input, sending **0 ETH**. | BAL **MUST** include address `0x01` in access list, with **no** `balance_changes`, `storage_changes`, or `code_changes`. | 🟡 Planned | +| `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 | > ℹ️ Scope describes whether a test spans a single transaction (`tx`) or entire block (`blk`).