diff --git a/.github/configs/eels_resolutions.json b/.github/configs/eels_resolutions.json index 44833d9a06a..9fe14efc946 100644 --- a/.github/configs/eels_resolutions.json +++ b/.github/configs/eels_resolutions.json @@ -52,6 +52,6 @@ "Amsterdam": { "git_url": "https://github.com/fselmo/execution-specs.git", "branch": "feat/amsterdam-fork-and-block-access-lists", - "commit": "39e0b59613be4100d2efc86702ff594c54e5bd81" + "commit": "6abe0ecb792265211d93bc3fea2f932e5d2cfa90" } } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 032fe1bec6c..aecab2d77f7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -32,6 +32,8 @@ Test fixtures for use by clients are available for each release on the [Github r ### πŸ§ͺ Test Cases +- ✨ Add an EIP-7928 test case targeting the `SELFDESTRUCT` opcode. ([#2159](https://github.com/ethereum/execution-spec-tests/pull/2159)). + ## [v5.0.0](https://github.com/ethereum/execution-spec-tests/releases/tag/v5.0.0) - 2025-09-05 ## πŸ‡―πŸ‡΅ Summary diff --git a/src/ethereum_test_types/block_access_list/account_absent_values.py b/src/ethereum_test_types/block_access_list/account_absent_values.py index c0fea606a97..d2109477cbc 100644 --- a/src/ethereum_test_types/block_access_list/account_absent_values.py +++ b/src/ethereum_test_types/block_access_list/account_absent_values.py @@ -74,6 +74,8 @@ class BalAccountAbsentValues(CamelModel): """ + model_config = CamelModel.model_config | {"extra": "forbid"} + nonce_changes: List[BalNonceChange] = Field( default_factory=list, description="List of nonce changes that should NOT exist in the BAL. " diff --git a/src/ethereum_test_types/block_access_list/account_changes.py b/src/ethereum_test_types/block_access_list/account_changes.py index 5edb0b9fc5e..b5a9632c8ab 100644 --- a/src/ethereum_test_types/block_access_list/account_changes.py +++ b/src/ethereum_test_types/block_access_list/account_changes.py @@ -22,6 +22,8 @@ class BalNonceChange(CamelModel, RLPSerializable): """Represents a nonce change in the block access list.""" + model_config = CamelModel.model_config | {"extra": "forbid"} + tx_index: HexNumber = Field( HexNumber(1), description="Transaction index where the change occurred", @@ -34,6 +36,8 @@ class BalNonceChange(CamelModel, RLPSerializable): class BalBalanceChange(CamelModel, RLPSerializable): """Represents a balance change in the block access list.""" + model_config = CamelModel.model_config | {"extra": "forbid"} + tx_index: HexNumber = Field( HexNumber(1), description="Transaction index where the change occurred", @@ -46,6 +50,8 @@ class BalBalanceChange(CamelModel, RLPSerializable): class BalCodeChange(CamelModel, RLPSerializable): """Represents a code change in the block access list.""" + model_config = CamelModel.model_config | {"extra": "forbid"} + tx_index: HexNumber = Field( HexNumber(1), description="Transaction index where the change occurred", @@ -58,6 +64,8 @@ class BalCodeChange(CamelModel, RLPSerializable): class BalStorageChange(CamelModel, RLPSerializable): """Represents a change to a specific storage slot.""" + model_config = CamelModel.model_config | {"extra": "forbid"} + tx_index: HexNumber = Field( HexNumber(1), description="Transaction index where the change occurred", @@ -70,6 +78,8 @@ class BalStorageChange(CamelModel, RLPSerializable): class BalStorageSlot(CamelModel, RLPSerializable): """Represents all changes to a specific storage slot.""" + model_config = CamelModel.model_config | {"extra": "forbid"} + slot: StorageKey = Field(..., description="Storage slot key") slot_changes: List[BalStorageChange] = Field( default_factory=list, description="List of changes to this slot" @@ -81,6 +91,8 @@ class BalStorageSlot(CamelModel, RLPSerializable): class BalAccountChange(CamelModel, RLPSerializable): """Represents all changes to a specific account in a block.""" + model_config = CamelModel.model_config | {"extra": "forbid"} + address: Address = Field(..., description="Account address") nonce_changes: List[BalNonceChange] = Field( default_factory=list, description="List of nonce changes" diff --git a/src/ethereum_test_types/block_access_list/expectations.py b/src/ethereum_test_types/block_access_list/expectations.py index 8bcdeba5381..5a69d45e7dc 100644 --- a/src/ethereum_test_types/block_access_list/expectations.py +++ b/src/ethereum_test_types/block_access_list/expectations.py @@ -32,6 +32,8 @@ class BalAccountExpectation(CamelModel): used for expectations. """ + model_config = CamelModel.model_config | {"extra": "forbid"} + nonce_changes: List[BalNonceChange] = Field( default_factory=list, description="List of expected nonce changes" ) @@ -199,21 +201,33 @@ def _validate_bal_ordering(bal: "BlockAccessList") -> None: f"{bal.root[i - 1].address} >= {bal.root[i].address}" ) - # Check transaction index ordering within accounts + # Check transaction index ordering and uniqueness within accounts for account in bal.root: - change_lists: List[BlockAccessListChangeLists] = [ - account.nonce_changes, - account.balance_changes, - account.code_changes, + changes_to_check: List[tuple[str, BlockAccessListChangeLists]] = [ + ("nonce_changes", account.nonce_changes), + ("balance_changes", account.balance_changes), + ("code_changes", account.code_changes), ] - for change_list in change_lists: - for i in range(1, len(change_list)): - if change_list[i - 1].tx_index >= change_list[i].tx_index: - raise BlockAccessListValidationError( - f"Transaction indices not in ascending order in account " - f"{account.address}: {change_list[i - 1].tx_index} >= " - f"{change_list[i].tx_index}" - ) + + for field_name, change_list in changes_to_check: + if not change_list: + continue + + tx_indices = [c.tx_index for c in change_list] + + # Check both ordering and duplicates + if tx_indices != sorted(tx_indices): + raise BlockAccessListValidationError( + f"Transaction indices not in ascending order in {field_name} of account " + f"{account.address}. Got: {tx_indices}, Expected: {sorted(tx_indices)}" + ) + + if len(tx_indices) != len(set(tx_indices)): + duplicates = sorted({idx for idx in tx_indices if tx_indices.count(idx) > 1}) + raise BlockAccessListValidationError( + f"Duplicate transaction indices in {field_name} of account " + f"{account.address}. Duplicates: {duplicates}" + ) # Check storage slot ordering for i in range(1, len(account.storage_changes)): @@ -224,19 +238,29 @@ def _validate_bal_ordering(bal: "BlockAccessList") -> None: f"{account.storage_changes[i].slot}" ) - # Check transaction index ordering within storage slots + # Check transaction index ordering and uniqueness within storage + # slots for storage_slot in account.storage_changes: - for i in range(1, len(storage_slot.slot_changes)): - if ( - storage_slot.slot_changes[i - 1].tx_index - >= storage_slot.slot_changes[i].tx_index - ): - raise BlockAccessListValidationError( - f"Transaction indices not in ascending order in storage slot " - f"{storage_slot.slot} of account {account.address}: " - f"{storage_slot.slot_changes[i - 1].tx_index} >= " - f"{storage_slot.slot_changes[i].tx_index}" - ) + if not storage_slot.slot_changes: + continue + + tx_indices = [c.tx_index for c in storage_slot.slot_changes] + + # Check both ordering and duplicates + if tx_indices != sorted(tx_indices): + raise BlockAccessListValidationError( + f"Transaction indices not in ascending order in storage slot " + f"{storage_slot.slot} of account {account.address}. " + f"Got: {tx_indices}, Expected: {sorted(tx_indices)}" + ) + + if len(tx_indices) != len(set(tx_indices)): + duplicates = sorted({idx for idx in tx_indices if tx_indices.count(idx) > 1}) + raise BlockAccessListValidationError( + f"Duplicate transaction indices in storage slot " + f"{storage_slot.slot} of account {account.address}. " + f"Duplicates: {duplicates}" + ) # Check storage reads ordering for i in range(1, len(account.storage_reads)): 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 06650810722..20ad08b497e 100644 --- a/src/ethereum_test_types/tests/test_block_access_lists.py +++ b/src/ethereum_test_types/tests/test_block_access_lists.py @@ -335,6 +335,83 @@ def test_actual_bal_tx_indices_ordering(field_name): expectation.verify_against(actual_bal) +@pytest.mark.parametrize( + "field_name", + ["nonce_changes", "balance_changes", "code_changes"], +) +def test_actual_bal_duplicate_tx_indices(field_name): + """ + Test that actual BAL must not have duplicate tx indices in change lists. + """ + addr = Address(0xA) + + # Duplicate tx_index=1 + changes = [] + if field_name == "nonce_changes": + changes = [ + BalNonceChange(tx_index=1, post_nonce=1), + BalNonceChange(tx_index=1, post_nonce=2), # duplicate tx_index + BalNonceChange(tx_index=2, post_nonce=3), + ] + elif field_name == "balance_changes": + changes = [ + BalBalanceChange(tx_index=1, post_balance=100), + BalBalanceChange(tx_index=1, post_balance=200), # duplicate tx_index + BalBalanceChange(tx_index=2, post_balance=300), + ] + elif field_name == "code_changes": + changes = [ + BalCodeChange(tx_index=1, new_code=b"code1"), + BalCodeChange(tx_index=1, new_code=b""), # duplicate tx_index + BalCodeChange(tx_index=2, new_code=b"code2"), + ] + + actual_bal = BlockAccessList([BalAccountChange(address=addr, **{field_name: changes})]) + + expectation = BlockAccessListExpectation(account_expectations={}) + + with pytest.raises( + BlockAccessListValidationError, + match=f"Duplicate transaction indices in {field_name}.*Duplicates: \\[1\\]", + ): + expectation.verify_against(actual_bal) + + +def test_actual_bal_storage_duplicate_tx_indices(): + """ + Test that storage changes must not have duplicate tx indices within same + slot. + """ + addr = Address(0xA) + + # Create storage changes with duplicate tx_index within the same slot + actual_bal = BlockAccessList( + [ + BalAccountChange( + address=addr, + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange(tx_index=1, post_value=0x100), + BalStorageChange(tx_index=1, post_value=0x200), # duplicate tx_index + BalStorageChange(tx_index=2, post_value=0x300), + ], + ) + ], + ) + ] + ) + + expectation = BlockAccessListExpectation(account_expectations={}) + + with pytest.raises( + BlockAccessListValidationError, + match="Duplicate transaction indices in storage slot.*Duplicates: \\[1\\]", + ): + expectation.verify_against(actual_bal) + + def test_expected_addresses_auto_sorted(): """ Test that expected addresses are automatically sorted before comparison. diff --git a/src/pytest_plugins/eels_resolutions.json b/src/pytest_plugins/eels_resolutions.json index 2b1a9c5f9db..f7b1dd99081 100644 --- a/src/pytest_plugins/eels_resolutions.json +++ b/src/pytest_plugins/eels_resolutions.json @@ -55,6 +55,6 @@ "Amsterdam": { "git_url": "https://github.com/fselmo/execution-specs.git", "branch": "feat/amsterdam-fork-and-block-access-lists", - "commit": "39e0b59613be4100d2efc86702ff594c54e5bd81" + "commit": "6abe0ecb792265211d93bc3fea2f932e5d2cfa90" } } 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 128f8276f8f..1f53ca1b211 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 @@ -1,12 +1,16 @@ """Tests for EIP-7928 using the consistent data class pattern.""" +from typing import Dict + import pytest +from ethereum_test_base_types import Address from ethereum_test_tools import ( Account, Alloc, Block, BlockchainTestFiller, + Initcode, Storage, Transaction, compute_create_address, @@ -284,3 +288,139 @@ def test_bal_code_changes( ), }, ) + + +@pytest.mark.valid_from("Amsterdam") +@pytest.mark.parametrize("self_destruct_in_same_tx", [True, False], ids=["same_tx", "new_tx"]) +@pytest.mark.parametrize("pre_funded", [True, False], ids=["pre_funded", "not_pre_funded"]) +def test_bal_self_destruct( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + self_destruct_in_same_tx: bool, + pre_funded: bool, +): + """Ensure BAL captures balance changes caused by `SELFDESTRUCT`.""" + alice = pre.fund_eoa() + bob = pre.fund_eoa(amount=0) + + selfdestruct_code = ( + Op.SLOAD(0x01) # Read from storage slot 0x01 + + Op.SSTORE(0x02, 0x42) # Write to storage slot 0x02 + + Op.SELFDESTRUCT(bob) + ) + # A pre existing self-destruct contract with initial storage + kaboom = pre.deploy_contract(code=selfdestruct_code, storage={0x01: 0x123}) + + # A template for self-destruct contract + self_destruct_init_code = Initcode(deploy_code=selfdestruct_code) + template = pre.deploy_contract(code=self_destruct_init_code) + + transfer_amount = expected_recipient_balance = 100 + pre_fund_amount = 10 + + if self_destruct_in_same_tx: + # The goal is to create a self-destructing contract in the same + # transaction to trigger deletion of code as per EIP-6780. + # The factory contract below creates a new self-destructing + # contract and calls it in this transaction. + + bytecode_size = len(self_destruct_init_code) + factory_bytecode = ( + # Clone template memory + Op.EXTCODECOPY(template, 0, 0, bytecode_size) + # Fund 100 wei and deploy the clone + + Op.CREATE(transfer_amount, 0, bytecode_size) + # Call the clone, which self-destructs + + Op.CALL(100_000, Op.DUP6, 0, 0, 0, 0, 0) + + Op.STOP + ) + + factory = pre.deploy_contract(code=factory_bytecode) + kaboom_same_tx = compute_create_address(address=factory, nonce=1) + + # Determine which account will be self-destructed + self_destructed_account = kaboom_same_tx if self_destruct_in_same_tx else kaboom + + if pre_funded: + expected_recipient_balance += pre_fund_amount + pre.fund_address(address=self_destructed_account, amount=pre_fund_amount) + + tx = Transaction( + sender=alice, + to=factory if self_destruct_in_same_tx else kaboom, + value=transfer_amount, + gas_limit=1_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)], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=expected_recipient_balance) + ] + ), + self_destructed_account: BalAccountExpectation( + balance_changes=[BalBalanceChange(tx_index=1, post_balance=0)] + if pre_funded + else [], + # Accessed slots for same-tx are recorded as reads (0x02) + storage_reads=[0x01, 0x02] if self_destruct_in_same_tx else [0x01], + # Storage changes are recorded for non-same-tx + # self-destructs + storage_changes=[ + BalStorageSlot( + slot=0x02, slot_changes=[BalStorageChange(tx_index=1, post_value=0x42)] + ) + ] + if not self_destruct_in_same_tx + else [], + code_changes=[], # should not be present + nonce_changes=[], # should not be present + ), + } + ), + ) + + post: Dict[Address, Account] = { + alice: Account(nonce=1), + bob: Account(balance=expected_recipient_balance), + } + + # If the account was self-destructed in the same transaction, + # we expect the account to non-existent and its balance to be 0. + if self_destruct_in_same_tx: + post.update( + { + factory: Account( + nonce=2, # incremented after CREATE + balance=0, # spent on CREATE + code=factory_bytecode, + ), + kaboom_same_tx: Account.NONEXISTENT, # type: ignore + # The pre-existing contract remains unaffected + kaboom: Account(balance=0, code=selfdestruct_code, storage={0x01: 0x123}), + } + ) + else: + post.update( + { + # This contract was self-destructed in a separate tx. + # From EIP 6780: `SELFDESTRUCT` does not delete any data + # (including storage keys, code, or the account itself). + kaboom: Account( + balance=0, code=selfdestruct_code, storage={0x01: 0x123, 0x2: 0x42} + ), + } + ) + + 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 3c371be939a..d5b3d2c11d9 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -7,12 +7,18 @@ | `test_bal_storage_writes` | Ensure BAL captures storage writes | Alice calls contract that writes to storage slot `0x01` | BAL MUST include storage changes with correct slot and value | βœ… Completed | | `test_bal_storage_reads` | Ensure BAL captures storage reads | Alice calls contract that reads from storage slot `0x01` | BAL MUST include storage access for the read operation | βœ… Completed | | `test_bal_code_changes` | Ensure BAL captures changes to account code | Alice deploys factory contract that creates new contract | BAL MUST include code changes for newly deployed contract | βœ… Completed | -| `test_bal_2930_slot_listed_but_untouched` | Ensure 2930 access list alone doesn’t appear in BAL | Include `(KV, S=0x01)` in tx’s EIP-2930 access list; tx executes code that does **no** `SLOAD`/`SSTORE` to `S` (e.g., pure arithmetic/log). | BAL **MUST NOT** contain any entry for `(KV, S)` β€” neither reads nor writes β€” because the slot wasn’t touched. | 🟑 Planned | +| `test_bal_self_destruct` | Ensure BAL captures storage access and balance changes caused by `SELFDESTRUCT` | Parameterized test: Alice interacts with a contract (either existing or created same-tx) that reads from storage slot 0x01, writes to storage slot 0x02, then executes `SELFDESTRUCT` with Bob as recipient. Contract may be pre-funded with 10 wei | BAL MUST include Alice's nonce change (increment) and Bob's balance change (100 or 110 depending on pre-funding). For the self-destructing contract: storage_reads=[0x01], empty storage_changes=[], and if pre-funded, balance_changes with post_balance=0; if not pre-funded, no balance change recorded. MUST NOT have code_changes or nonce_changes entries | βœ… Completed | +| `test_bal_2930_slot_listed_but_untouched` | Ensure 2930 access list alone doesn't appear in BAL | Include `(KV, S=0x01)` in tx's EIP-2930 access list; tx executes code that does **no** `SLOAD`/`SSTORE` to `S` (e.g., pure arithmetic/log). | BAL **MUST NOT** contain any entry for `(KV, S)` β€” neither reads nor writes β€” because the slot wasn't touched. | 🟑 Planned | | `test_bal_2930_slot_listed_and_modified` | Ensure BAL records writes only because the slot is touched | Same access list as above, but tx executes `SSTORE` to `S`. | BAL **MUST** include `storage_changes` for `(KV, S)` (and no separate read record for that slot if implementation deduplicates). Presence in the access list is irrelevant; inclusion is due to the actual write. | 🟑 Planned | | `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_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_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 | + > ℹ️ Scope describes whether a test spans a single transaction (`tx`) or entire block (`blk`).