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 09f578d286..1dcb5b3ec4 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 @@ -2090,6 +2090,98 @@ def test_bal_multiple_storage_writes_same_slot( ) +@pytest.mark.parametrize( + "intermediate_values", + [ + pytest.param([2], id="depth_1"), + pytest.param([2, 3], id="depth_2"), + pytest.param([2, 3, 4], id="depth_3"), + ], +) +def test_bal_nested_delegatecall_storage_writes_net_zero( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + intermediate_values: list, +) -> None: + """ + Test BAL correctly handles nested DELEGATECALL frames where intermediate + frames write different values but the deepest frame reverts to original. + + Each nesting level writes a different intermediate value, and the deepest + frame writes back the original value, resulting in net-zero change. + + Example for depth=2 (intermediate_values=[2, 3]): + - Pre-state: slot 0 = 1 + - Root frame writes: slot 0 = 2 + - Child frame writes: slot 0 = 3 + - Grandchild frame writes: slot 0 = 1 (back to original) + - Expected: No storage_changes (net-zero overall) + """ + alice = pre.fund_eoa() + starting_value = 1 + + # deepest contract writes back to starting_value + deepest_code = Op.SSTORE(0, starting_value) + Op.STOP + next_contract = pre.deploy_contract(code=deepest_code) + delegate_contracts = [next_contract] + + # Build intermediate contracts (in reverse order) that write then + # DELEGATECALL. Skip the first value since that's for the root contract + for value in reversed(intermediate_values[1:]): + code = ( + Op.SSTORE(0, value) + + Op.DELEGATECALL(100_000, next_contract, 0, 0, 0, 0) + + Op.STOP + ) + next_contract = pre.deploy_contract(code=code) + delegate_contracts.append(next_contract) + + # root_contract writes first intermediate value, then DELEGATECALLs + root_contract = pre.deploy_contract( + code=( + Op.SSTORE(0, intermediate_values[0]) + + Op.DELEGATECALL(100_000, next_contract, 0, 0, 0, 0) + + Op.STOP + ), + storage={0: starting_value}, + ) + + tx = Transaction( + sender=alice, + to=root_contract, + gas_limit=500_000, + ) + + account_expectations: Dict[Address, BalAccountExpectation | None] = { + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + root_contract: BalAccountExpectation( + storage_reads=[0], + storage_changes=[], # validate no changes + ), + } + # All delegate contracts accessed but no changes + for contract in delegate_contracts: + account_expectations[contract] = BalAccountExpectation.empty() + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + ], + post={ + alice: Account(nonce=1), + root_contract: Account(storage={0: starting_value}), + }, + ) + + def test_bal_create_transaction_empty_code( pre: Alloc, blockchain_test: BlockchainTestFiller, 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 dbcf8ce31a..f0f96f71c9 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -54,6 +54,7 @@ | `test_bal_oog_7702_delegated_warm_cold` | Ensure BAL handles OOG during EIP-7702 delegated account loading when first account is warm, second is cold | Alice calls warm delegated account Bob (7702) which delegates to cold `TargetContract` with insufficient gas for second cold load | BAL **MUST** include Bob in `account_changes` (warm load succeeds) but **MUST NOT** include `TargetContract` (cold load fails due to OOG) | 🟡 Planned | | `test_bal_multiple_balance_changes_same_account` | Ensure BAL tracks multiple balance changes to same account across transactions | Alice funds Bob (starts at 0) in tx0 with exact amount needed. Bob spends everything in tx1 to Charlie. Bob's balance: 0 → funding_amount → 0 | BAL **MUST** include Bob with two `balance_changes`: one at txIndex=1 (receives funds) and one at txIndex=2 (balance returns to 0). This tests balance tracking across two transactions. | ✅ Completed | | `test_bal_multiple_storage_writes_same_slot` | Ensure BAL tracks multiple writes to same storage slot across transactions | Alice calls contract 3 times in same block. Contract increments slot 1 on each call: 0 → 1 → 2 → 3 | BAL **MUST** include contract with slot 1 having three `slot_changes`: txIndex=1 (value 1), txIndex=2 (value 2), txIndex=3 (value 3). Each transaction's write must be recorded separately. | ✅ Completed | +| `test_bal_nested_delegatecall_storage_writes_net_zero` | Ensure BAL correctly filters net-zero storage changes across nested DELEGATECALL frames | Parametrized by nesting depth (1-3). Root contract has slot 0 = 1. Each frame writes a different intermediate value via DELEGATECALL chain, deepest frame writes back to original value (1). Example depth=2: 1 → 2 → 3 → 1 | BAL **MUST** include root contract with `storage_reads` for slot 0 but **MUST NOT** include `storage_changes` (net-zero). All delegate contracts **MUST** have empty changes. Tests that frame merging correctly removes parent's intermediate writes when child reverts to pre-tx value. | ✅ Completed | | `test_bal_create_transaction_empty_code` | Ensure BAL does not record spurious code changes for CREATE transaction deploying empty code | Alice sends CREATE transaction with empty initcode (deploys code `b""`). Contract address gets nonce = 1 and code = `b""`. | BAL **MUST** include Alice with `nonce_changes` and created contract with `nonce_changes` but **MUST NOT** include `code_changes` for contract (setting `b"" -> b""` is net-zero). | ✅ Completed | | `test_bal_cross_tx_storage_revert_to_zero` | Ensure BAL captures storage changes when tx2 reverts slot back to original value (blobhash regression test) | Alice sends tx1 writing slot 0=0xABCD (from 0x0), then tx2 writing slot 0=0x0 (back to original) | BAL **MUST** include contract with slot 0 having two `slot_changes`: txIndex=1 (0xABCD) and txIndex=2 (0x0). Cross-transaction net-zero **MUST NOT** be filtered. | ✅ Completed | | `test_bal_create_contract_init_revert` | Ensure BAL correctly handles CREATE when parent call reverts | Caller calls factory, factory executes CREATE (succeeds), then factory REVERTs rolling back the CREATE | BAL **MUST** include Alice with `nonce_changes`. Caller and factory with no changes (reverted). Created contract address appears in BAL but **MUST NOT** have `nonce_changes` or `code_changes` (CREATE was rolled back). Contract address **MUST NOT** exist in post-state. | ✅ Completed |