From e78e479b3300c0f2ab99c7798fb9e6238e535ff4 Mon Sep 17 00:00:00 2001 From: fselmo Date: Fri, 4 Sep 2026 13:56:44 -0600 Subject: [PATCH 1/5] fix(test-specs): let parent checklist items take eip= and fix stub output path --- .../src/execution_testing/checklists/eip_checklist.pyi | 3 +++ .../src/execution_testing/cli/generate_checklist_stubs.py | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/testing/src/execution_testing/checklists/eip_checklist.pyi b/packages/testing/src/execution_testing/checklists/eip_checklist.pyi index 6d350cf4ddb..a1df8af9e86 100644 --- a/packages/testing/src/execution_testing/checklists/eip_checklist.pyi +++ b/packages/testing/src/execution_testing/checklists/eip_checklist.pyi @@ -14,6 +14,9 @@ F = TypeVar("F", bound=Callable[..., Any]) class _CallableChecklistItem: """Base type for all callable checklist items.""" + # Items with sub-items are emitted as classes, so a call on them is a + # constructor call to mypy: accept the same arguments as `__call__`. + def __new__(cls, *args: Any, eip: Any = ..., **kwargs: Any) -> Any: ... @overload def __call__(self, func: F) -> F: ... @overload diff --git a/packages/testing/src/execution_testing/cli/generate_checklist_stubs.py b/packages/testing/src/execution_testing/cli/generate_checklist_stubs.py index af657eeacf2..b35565c8c9d 100644 --- a/packages/testing/src/execution_testing/cli/generate_checklist_stubs.py +++ b/packages/testing/src/execution_testing/cli/generate_checklist_stubs.py @@ -106,7 +106,7 @@ def generate_checklist_stubs(output: str | None, dry_run: bool) -> None: """ try: # Add src to path so we can import the module - src_path = Path(__file__).parent.parent + src_path = Path(__file__).parents[2] sys.path.insert(0, str(src_path)) from execution_testing.checklists.eip_checklist import ( @@ -129,6 +129,9 @@ def generate_checklist_stubs(output: str | None, dry_run: bool) -> None: class _CallableChecklistItem: """Base type for all callable checklist items.""" + # Items with sub-items are emitted as classes, so a call on them is a + # constructor call to mypy: accept the same arguments as `__call__`. + def __new__(cls, *args: Any, eip: Any = ..., **kwargs: Any) -> Any: ... @overload def __call__(self, func: F) -> F: ... @overload From 754f8a6892ae9267c61202e26238ed4f1f8d397a Mon Sep 17 00:00:00 2001 From: fselmo Date: Fri, 4 Sep 2026 14:01:07 -0600 Subject: [PATCH 2/5] fix(tests): pin EIP-8246 reference spec to the latest blob --- tests/amsterdam/eip8246_selfdestruct_no_burn/spec.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/amsterdam/eip8246_selfdestruct_no_burn/spec.py b/tests/amsterdam/eip8246_selfdestruct_no_burn/spec.py index d27c0b3f36a..8a91f41b26e 100644 --- a/tests/amsterdam/eip8246_selfdestruct_no_burn/spec.py +++ b/tests/amsterdam/eip8246_selfdestruct_no_burn/spec.py @@ -13,5 +13,5 @@ class ReferenceSpec: ref_spec_8246 = ReferenceSpec( git_path="EIPS/eip-8246.md", - version="3b30ff829e5e698f1c6f69427111d194b80af38d", + version="8be64cf6a01350938b93332cf0062ab7a3166f23", ) From 94ab70600cf1a3719e4989613de065300b48c251 Mon Sep 17 00:00:00 2001 From: fselmo Date: Fri, 4 Sep 2026 14:02:48 -0600 Subject: [PATCH 3/5] feat(tests): complete EIP-8246 testing checklist --- .../eip_checklist_external_coverage.txt | 10 + .../eip_checklist_not_applicable.txt | 30 + .../test_eip_mainnet.py | 82 +++ .../test_fork_transition.py | 70 +++ .../test_selfdestruct_no_burn.py | 132 +++++ .../test_selfdestruct_same_tx.py | 538 ++++++++++++++++++ 6 files changed, 862 insertions(+) create mode 100644 tests/amsterdam/eip8246_selfdestruct_no_burn/eip_checklist_external_coverage.txt create mode 100644 tests/amsterdam/eip8246_selfdestruct_no_burn/eip_checklist_not_applicable.txt create mode 100644 tests/amsterdam/eip8246_selfdestruct_no_burn/test_eip_mainnet.py create mode 100644 tests/amsterdam/eip8246_selfdestruct_no_burn/test_fork_transition.py create mode 100644 tests/cancun/eip6780_selfdestruct/test_selfdestruct_same_tx.py diff --git a/tests/amsterdam/eip8246_selfdestruct_no_burn/eip_checklist_external_coverage.txt b/tests/amsterdam/eip8246_selfdestruct_no_burn/eip_checklist_external_coverage.txt new file mode 100644 index 00000000000..012bee9a2be --- /dev/null +++ b/tests/amsterdam/eip8246_selfdestruct_no_burn/eip_checklist_external_coverage.txt @@ -0,0 +1,10 @@ +general/code_coverage/eels = The EIP-8246 code paths (selfdestruct() in vm/instructions/system.py, clear_account_preserving_balance() in state_tracker.py, the accounts_to_delete loop in fork.py) are fully exercised by tests/amsterdam/eip8246_selfdestruct_no_burn together with the fork-aware same-tx tests in tests/cancun/eip6780_selfdestruct; verify with `fill --fork Amsterdam --cov=ethereum.forks.amsterdam` +general/code_coverage/test_coverage = Run `fill --cov=`; the only test-module lines never executed are the `raise ValueError` guards for unhandled parametrize values +general/code_coverage/missed_lines = Missed spec lines are outside EIP-8246: fork.py header and PoW validation plus system-call plumbing, system.py CALL-family and CREATE failure branches, state_tracker.py journal edge cases, interpreter.py precompile and exception paths +opcode/test/execution_context/set_code = A set-code delegated EOA is never a same-transaction created account, so EIP-8246 leaves it unchanged; covered by tests/prague/eip7702_set_code_tx/test_set_code_txs.py::test_set_code_to_self_destruct and ::test_set_code_to_self_destructing_account_deployed_in_same_tx +opcode/test/return_data/buffer/parent = Return-data behavior of SELFDESTRUCT is unchanged; covered by tests/byzantium/eip211_return_data/test_selfdestruct.py::test_selfdestruct_clears_return_data +opcode/test/terminating/rollback/logs = The EIP-7708 transfer log emitted by a reverted SELFDESTRUCT is discarded with its frame; covered by tests/cancun/eip6780_selfdestruct/test_journal_revert.py::test_selfdestruct_balance_transfer_reverted (expects an empty receipt log list under EIP-7708) +opcode/test/gas_usage/normal = SELFDESTRUCT gas is unchanged by EIP-8246; measured with the EIP-8246 remnant in tests/amsterdam/eip8038_state_access_gas_cost_increase/test_selfdestruct_gas.py::test_same_tx_created_selfdestruct_self_burn and tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_selfdestruct.py::test_selfdestruct_to_self_in_create_tx +opcode/test/gas_usage/out_of_gas_execution = Same-transaction SELFDESTRUCT running out of gas keeps the contract's code and balance; covered by tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py::test_selfdestruct_to_self[same_tx-exact_gas_minus_1] and tests/amsterdam/eip8038_state_access_gas_cost_increase/test_selfdestruct_gas.py::test_selfdestruct_oog_boundary +opcode/test/gas_usage/order_of_operations/exact = tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py::test_selfdestruct_to_self[same_tx-exact_gas] asserts the EIP-8246 remnant when the frame has exactly the SELFDESTRUCT cost +opcode/test/gas_usage/order_of_operations/oog = tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py::test_selfdestruct_to_self[same_tx-exact_gas_minus_1] asserts no deletion mark when the frame is one gas short diff --git a/tests/amsterdam/eip8246_selfdestruct_no_burn/eip_checklist_not_applicable.txt b/tests/amsterdam/eip8246_selfdestruct_no_burn/eip_checklist_not_applicable.txt new file mode 100644 index 00000000000..7dfc1e407b6 --- /dev/null +++ b/tests/amsterdam/eip8246_selfdestruct_no_burn/eip_checklist_not_applicable.txt @@ -0,0 +1,30 @@ +general/code_coverage/second_client = Optional +opcode/test/mem_exp = SELFDESTRUCT takes no memory operands; EIP-8246 changes the finalization of an existing opcode, not its inputs +opcode/test/stack_overflow = Stack semantics of the pre-existing SELFDESTRUCT opcode are unchanged by EIP-8246 +opcode/test/stack_underflow = Stack semantics of the pre-existing SELFDESTRUCT opcode are unchanged by EIP-8246 +opcode/test/stack_complex_operations = Stack semantics of the pre-existing SELFDESTRUCT opcode are unchanged by EIP-8246 +opcode/test/execution_context/tx_context = SELFDESTRUCT does not depend on transaction context +opcode/test/execution_context/block_context = SELFDESTRUCT does not depend on block context +opcode/test/gas_usage/memory_expansion = SELFDESTRUCT does not expand memory +opcode/test/gas_usage/out_of_gas_memory = SELFDESTRUCT does not expand memory +opcode/test/out_of_bounds = SELFDESTRUCT's only operand is an address; values above 2**160-1 are masked to 20 bytes, a pre-existing rule that EIP-8246 does not touch +opcode/test/return_data/buffer/current = SELFDESTRUCT halts its frame, so there is no current-context return buffer to observe after it +opcode/test/data_portion = SELFDESTRUCT has no data portion +opcode/test/contract_creation = SELFDESTRUCT does not create contracts; the CREATE2 redeploy over a cleared remnant is covered under initcode re-entry +opcode/test/terminating/rollback/storage = SELFDESTRUCT writes no storage; its revertable effects are the balance move, the transfer log and the deletion mark +opcode/test/terminating/rollback/nonce = SELFDESTRUCT increments no nonce; its revertable effects are the balance move, the transfer log and the deletion mark +opcode/test/terminating/rollback/contracts = SELFDESTRUCT creates no contract; its revertable effects are the balance move, the transfer log and the deletion mark +opcode/test/fork_transition/invalid = SELFDESTRUCT is valid before and after Amsterdam; only its finalization changes +precompile = EIP does not introduce a new precompile +removed_precompile = EIP does not remove a precompile +system_contract = EIP does not introduce a new system contract +transaction_type = EIP does not introduce a new transaction type +block_header_field = EIP does not add any new block header fields +block_body_field = EIP does not add any new block body fields +gas_cost_changes = EIP does not change any gas cost; SELFDESTRUCT gas in Amsterdam is owned by EIP-8037 and EIP-8038 +gas_refunds_changes = EIP does not change any gas refund +blob_count_changes = EIP does not introduce any blob count changes +execution_layer_request = EIP does not introduce an execution layer request +new_transaction_validity_constraint = EIP does not introduce a new transaction validity constraint +modified_transaction_validity_constraint = EIP does not modify a transaction validity constraint +block_level_constraint = EIP does not introduce a block-level validation constraint diff --git a/tests/amsterdam/eip8246_selfdestruct_no_burn/test_eip_mainnet.py b/tests/amsterdam/eip8246_selfdestruct_no_burn/test_eip_mainnet.py new file mode 100644 index 00000000000..56a52a2240e --- /dev/null +++ b/tests/amsterdam/eip8246_selfdestruct_no_burn/test_eip_mainnet.py @@ -0,0 +1,82 @@ +""" +Mainnet marked execute checklist tests for +[EIP-8246: Remove SELFDESTRUCT Burn](https://eips.ethereum.org/EIPS/eip-8246). +""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Op, + StateTestFiller, + Transaction, + compute_create_address, +) +from execution_testing import ( + Macros as Om, +) + +from .spec import ref_spec_8246 + +REFERENCE_SPEC_GIT_PATH = ref_spec_8246.git_path +REFERENCE_SPEC_VERSION = ref_spec_8246.version + +pytestmark = [pytest.mark.valid_at("EIP8246"), pytest.mark.mainnet] + +ENDOWMENT = 1 + + +def test_create_tx_selfdestruct_to_self_keeps_balance( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """A creation transaction whose initcode self-destructs to itself.""" + sender = pre.fund_eoa() + created = compute_create_address(address=sender, nonce=sender.nonce) + + tx = Transaction( + sender=sender, + to=None, + value=ENDOWMENT, + data=Op.SELFDESTRUCT(Op.ADDRESS), + ) + + state_test( + pre=pre, + post={ + created: Account(balance=ENDOWMENT, nonce=0, code=b"", storage={}) + }, + tx=tx, + ) + + +def test_factory_create_selfdestruct_to_self_keeps_balance( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + A factory CREATE whose initcode self-destructs to itself, with the + balance still observable by the factory in the same transaction. + """ + sender = pre.fund_eoa() + initcode = Op.SELFDESTRUCT(Op.ADDRESS) + factory = pre.deploy_contract( + code=Om.MSTORE(initcode, 0) + + Op.SSTORE( + 0, Op.CREATE(value=Op.CALLVALUE, offset=0, size=len(initcode)) + ) + + Op.SSTORE(1, Op.BALANCE(Op.SLOAD(0))) + + Op.STOP + ) + created = compute_create_address(address=factory, nonce=1) + + tx = Transaction(sender=sender, to=factory, value=ENDOWMENT) + + state_test( + pre=pre, + post={ + factory: Account(nonce=2, storage={0: created, 1: ENDOWMENT}), + created: Account(balance=ENDOWMENT, nonce=0, code=b"", storage={}), + }, + tx=tx, + ) diff --git a/tests/amsterdam/eip8246_selfdestruct_no_burn/test_fork_transition.py b/tests/amsterdam/eip8246_selfdestruct_no_burn/test_fork_transition.py new file mode 100644 index 00000000000..55490ac0206 --- /dev/null +++ b/tests/amsterdam/eip8246_selfdestruct_no_burn/test_fork_transition.py @@ -0,0 +1,70 @@ +""" +Fork transition tests for [EIP-8246: Remove SELFDESTRUCT Burn](https://eips.ethereum.org/EIPS/eip-8246). +""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Hash, + Op, + Transaction, + compute_create_address, +) +from execution_testing import ( + Macros as Om, +) +from execution_testing.checklists import EIPChecklist + +from .spec import ref_spec_8246 + +REFERENCE_SPEC_GIT_PATH = ref_spec_8246.git_path +REFERENCE_SPEC_VERSION = ref_spec_8246.version + + +@pytest.mark.valid_at_transition_to("EIP8246") +@EIPChecklist.Opcode.Test.ForkTransition.At() +def test_selfdestruct_to_self_fork_transition( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """ + A same-tx selfdestruct-to-self burns the balance before the fork and + leaves a balance-only account from the fork block on. + """ + endowment = 5 + sender = pre.fund_eoa() + initcode = Op.SELFDESTRUCT(Op.ADDRESS) + factory = pre.deploy_contract( + code=Om.MSTORE(initcode, 0) + + Op.SSTORE( + Op.CALLDATALOAD(0), + Op.CREATE(value=Op.CALLVALUE, offset=0, size=len(initcode)), + ) + + Op.STOP + ) + before = compute_create_address(address=factory, nonce=1) + at = compute_create_address(address=factory, nonce=2) + after = compute_create_address(address=factory, nonce=3) + + def create_tx(index: int) -> Transaction: + return Transaction( + sender=sender, to=factory, value=endowment, data=Hash(index) + ) + + blockchain_test( + pre=pre, + blocks=[ + Block(timestamp=14_999, txs=[create_tx(1)]), + Block(timestamp=15_000, txs=[create_tx(2)]), + Block(timestamp=15_001, txs=[create_tx(3)]), + ], + post={ + factory: Account(nonce=4, storage={1: before, 2: at, 3: after}), + before: Account.NONEXISTENT, + at: Account(balance=endowment, nonce=0, code=b"", storage={}), + after: Account(balance=endowment, nonce=0, code=b"", storage={}), + }, + ) diff --git a/tests/amsterdam/eip8246_selfdestruct_no_burn/test_selfdestruct_no_burn.py b/tests/amsterdam/eip8246_selfdestruct_no_burn/test_selfdestruct_no_burn.py index 1cf6462de75..3a66d623411 100644 --- a/tests/amsterdam/eip8246_selfdestruct_no_burn/test_selfdestruct_no_burn.py +++ b/tests/amsterdam/eip8246_selfdestruct_no_burn/test_selfdestruct_no_burn.py @@ -10,18 +10,29 @@ Account, Address, Alloc, + BalAccountExpectation, + BalBalanceChange, Block, + BlockAccessListExpectation, BlockchainTestFiller, Bytecode, + Conditional, + Fork, Hash, Op, StateTestFiller, Storage, Transaction, + TransactionReceipt, compute_create_address, keccak256, ) +from execution_testing import ( + Macros as Om, +) +from execution_testing.checklists import EIPChecklist +from ..eip7708_eth_transfer_logs.spec import transfer_log from .spec import ref_spec_8246 REFERENCE_SPEC_GIT_PATH = ref_spec_8246.git_path @@ -63,6 +74,10 @@ pytest.param(Op.MSTORE(2**32, 0), False, id="oog"), ], ) +@EIPChecklist.Opcode.Test.ExecutionContext.Initcode.Behavior() +@EIPChecklist.Opcode.Test.ExecutionContext.Initcode.Behavior.Opcode() +@EIPChecklist.Opcode.Test.Terminating.Scenarios.Initcode() +@EIPChecklist.Opcode.Test.Terminating.Rollback.Balance() def test_selfdestructing_initcode_preserves_balance( blockchain_test: BlockchainTestFiller, pre: Alloc, @@ -237,6 +252,8 @@ def test_selfdestructing_initcode_preserves_balance( "value", [pytest.param(1, id="kept"), pytest.param(0, id="removed")], ) +@EIPChecklist.Opcode.Test.ExecutionContext.Initcode.Behavior.Tx() +@EIPChecklist.Opcode.Test.Terminating.Scenarios.TopLevel() def test_create_transaction_initcode_selfdestruct( state_test: StateTestFiller, pre: Alloc, @@ -267,3 +284,118 @@ def test_create_transaction_initcode_selfdestruct( ) } state_test(pre=pre, post=post, tx=tx) + + +@pytest.mark.parametrize( + "sweep_on_redeploy", + [ + pytest.param(False, id="selfdestruct_to_self"), + pytest.param(True, id="sweep_to_origin"), + ], +) +@EIPChecklist.Opcode.Test.ExecutionContext.Initcode.Reentry() +@EIPChecklist.Opcode.Test.Terminating.Scenarios.Initcode() +def test_create2_redeploy_over_remnant( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + sweep_on_redeploy: bool, +) -> None: + """ + Case 17 of the EIP and its third security consideration: the cleared + nonce and storage let the same factory CREATE2 over the funded remnant, + and the redeployed initcode can spend the preserved balance. + """ + endowment = 5 + deployer = pre.fund_eoa() + redeployer = pre.fund_eoa() + + if sweep_on_redeploy: + # Only a redeploy sees a balance above the call value, so the same + # initcode bytes sweep to the redeployer the second time. + selfdestruct: Bytecode = Conditional( + condition=Op.GT(Op.BALANCE(Op.ADDRESS), Op.CALLVALUE), + if_true=Op.SELFDESTRUCT(Op.ORIGIN), + if_false=Op.SELFDESTRUCT(Op.ADDRESS), + ) + redeploy_value = 0 + else: + selfdestruct = Op.SELFDESTRUCT(Op.ADDRESS) + redeploy_value = endowment + + initcode = Op.SSTORE(0, 1) + selfdestruct + factory = pre.deploy_contract( + code=Om.MSTORE(initcode, 0) + + Op.SSTORE( + Op.CALLDATALOAD(0), + Op.CREATE2( + value=Op.CALLVALUE, offset=0, size=len(initcode), salt=0 + ), + ) + + Op.STOP + ) + created = compute_create_address( + address=factory, salt=0, initcode=initcode, opcode=Op.CREATE2 + ) + + tx1 = Transaction( + sender=deployer, to=factory, value=endowment, data=Hash(1) + ) + tx2 = Transaction( + sender=redeployer, to=factory, value=redeploy_value, data=Hash(2) + ) + + created_post: Account | None + if sweep_on_redeploy: + created_post = Account.NONEXISTENT + final_balance = 0 + tx2_logs = [transfer_log(created, redeployer, endowment)] + else: + final_balance = 2 * endowment + created_post = Account( + balance=final_balance, nonce=0, code=b"", storage={} + ) + tx2_logs = [ + transfer_log(redeployer, factory, endowment), + transfer_log(factory, created, endowment), + ] + if fork.is_eip_enabled(7708): + tx1.expected_receipt = TransactionReceipt( + logs=[ + transfer_log(deployer, factory, endowment), + transfer_log(factory, created, endowment), + ] + ) + tx2.expected_receipt = TransactionReceipt(logs=tx2_logs) + + expected_bal = None + if fork.is_eip_enabled(7928): + expected_bal = BlockAccessListExpectation( + account_expectations={ + created: BalAccountExpectation( + nonce_changes=[], + code_changes=[], + storage_changes=[], + storage_reads=[0], + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=endowment + ), + BalBalanceChange( + block_access_index=2, post_balance=final_balance + ), + ], + ) + } + ) + + blockchain_test( + pre=pre, + blocks=[ + Block(txs=[tx1, tx2], expected_block_access_list=expected_bal) + ], + post={ + factory: Account(nonce=3, storage={1: created, 2: created}), + created: created_post, + }, + ) diff --git a/tests/cancun/eip6780_selfdestruct/test_selfdestruct_same_tx.py b/tests/cancun/eip6780_selfdestruct/test_selfdestruct_same_tx.py new file mode 100644 index 00000000000..38a153b7ba8 --- /dev/null +++ b/tests/cancun/eip6780_selfdestruct/test_selfdestruct_same_tx.py @@ -0,0 +1,538 @@ +""" +Same-transaction SELFDESTRUCT scenarios. + +Tests for [EIP-6780: SELFDESTRUCT only in same transaction](https://eips.ethereum.org/EIPS/eip-6780). + +A contract created in the transaction self-destructs, possibly more than +once and around value it receives afterwards. Before EIP-8246 finalization +removes the account and burns whatever it still holds; from EIP-8246 on +only nonce, code and storage are cleared and the balance survives. +""" + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + BalAccountExpectation, + BalBalanceChange, + BalCodeChange, + BalNonceChange, + BlockAccessListExpectation, + Bytecode, + Conditional, + Fork, + Initcode, + Op, + StateTestFiller, + Transaction, + TransactionLog, + TransactionReceipt, + compute_create_address, +) +from execution_testing import ( + Macros as Om, +) +from execution_testing.checklists import EIPChecklist + +from tests.amsterdam.eip7708_eth_transfer_logs.spec import transfer_log + +REFERENCE_SPEC_GIT_PATH = "EIPS/eip-6780.md" +REFERENCE_SPEC_VERSION = "1b6a0e94cc47e859b9866e570391cf37dc55059a" + +pytestmark = pytest.mark.valid_from("Cancun") + +OTHER_BALANCE = 1 +SEND_AMOUNT = 1 + +SD_SELF = "selfdestruct_to_self" +SD_OTHER = "selfdestruct_to_other" +SEND = "send_value" + + +def finalized(fork: Fork, balance: int) -> Account | None: + """ + Post-state of a same-tx destroyed contract that held ``balance`` when + the transaction ended: removed, or balance-only under EIP-8246. + """ + if fork.is_eip_enabled(8246) and balance > 0: + return Account(balance=balance, nonce=0, code=b"", storage={}) + return Account.NONEXISTENT + + +def finalized_bal( + fork: Fork, balance: int, storage_reads: list[int] +) -> BalAccountExpectation: + """BAL entry of a same-tx destroyed contract.""" + kept = balance if fork.is_eip_enabled(8246) else 0 + if kept == 0 and not storage_reads: + return BalAccountExpectation.empty() + balance_changes = [] + if kept > 0: + balance_changes = [ + BalBalanceChange(block_access_index=1, post_balance=kept) + ] + return BalAccountExpectation( + nonce_changes=[], + code_changes=[], + storage_changes=[], + storage_reads=storage_reads, + balance_changes=balance_changes, + ) + + +@pytest.mark.parametrize("create_opcode", [Op.CREATE, Op.CREATE2]) +@pytest.mark.parametrize( + "initial_balance", + [pytest.param(0, id="zero_balance"), pytest.param(3, id="funded")], +) +@pytest.mark.parametrize( + "steps", + [ + pytest.param((SD_SELF,), id="case01_self"), + pytest.param((SD_SELF, SD_SELF), id="case02_self_self"), + pytest.param((SD_SELF, SD_OTHER), id="case03_self_other"), + pytest.param((SD_OTHER, SD_SELF), id="case04_other_self"), + pytest.param((SD_OTHER, SEND), id="case05_other_send"), + pytest.param((SD_OTHER, SEND, SEND), id="case06_other_send_send"), + pytest.param((SD_OTHER, SEND, SD_OTHER), id="case07_other_send_other"), + pytest.param((SD_OTHER, SEND, SD_SELF), id="case08_other_send_self"), + pytest.param((SD_SELF, SEND), id="case09_self_send"), + pytest.param((SD_SELF, SEND, SEND), id="case10_self_send_send"), + pytest.param((SD_SELF, SEND, SD_OTHER), id="case11_self_send_other"), + pytest.param((SD_SELF, SEND, SD_SELF), id="case12_self_send_self"), + ], +) +@EIPChecklist.Opcode.Test.ExecutionContext.Call(eip=[8246]) +@EIPChecklist.Opcode.Test.GasUsage.ExtraGas(eip=[8246]) +@EIPChecklist.Opcode.Test.Terminating.Scenarios.SubLevel(eip=[8246]) +def test_selfdestruct_sequences( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + create_opcode: Op, + steps: tuple[str, ...], + initial_balance: int, +) -> None: + """ + Cases 1-12 of the EIP-8246 test list: what a sequence of self-destructs + and value sends leaves in a same-tx created contract at finalization. + """ + sender = pre.fund_eoa() + other = pre.fund_eoa(amount=OTHER_BALANCE) + + # The victim self-destructs to the address in calldata, or just takes + # the value when calldata is zero. + victim_code = ( + Conditional( + condition=Op.CALLDATALOAD(0), + if_true=Op.SELFDESTRUCT(Op.CALLDATALOAD(0)), + ) + + Op.STOP + ) + initcode = Initcode(deploy_code=victim_code) + + if create_opcode == Op.CREATE: + create = Op.CREATE(value=initial_balance, offset=0, size=len(initcode)) + elif create_opcode == Op.CREATE2: + create = Op.CREATE2( + value=initial_balance, offset=0, size=len(initcode), salt=0 + ) + else: + raise ValueError(f"unhandled create opcode {create_opcode}") + + entry_code = Om.MSTORE(initcode, 0) + Op.SSTORE(0, create) + for slot, step in enumerate(steps, 1): + if step == SD_SELF: + calldata: Address | Bytecode | int = Op.SLOAD(0) + value = 0 + elif step == SD_OTHER: + calldata = other + value = 0 + elif step == SEND: + calldata = 0 + value = SEND_AMOUNT + else: + raise ValueError(f"unhandled step {step}") + entry_code += Op.MSTORE(0, calldata) + Op.SSTORE( + slot, + Op.CALL( + gas=Op.GAS, + address=Op.SLOAD(0), + value=value, + args_offset=0, + args_size=32, + ), + ) + entry = pre.deploy_contract(code=entry_code + Op.STOP) + victim = compute_create_address( + address=entry, + nonce=1, + salt=0, + initcode=initcode, + opcode=create_opcode, + ) + + tx_value = initial_balance + SEND_AMOUNT * steps.count(SEND) + tx = Transaction(sender=sender, to=entry, value=tx_value) + + victim_balance = initial_balance + other_balance = OTHER_BALANCE + logs: list[TransactionLog] = [] + if tx_value > 0: + logs.append(transfer_log(sender, entry, tx_value)) + if initial_balance > 0: + logs.append(transfer_log(entry, victim, initial_balance)) + for step in steps: + if step == SD_SELF: + # Before EIP-8246 the opcode burns a same-tx contract's balance. + if not fork.is_eip_enabled(8246): + victim_balance = 0 + elif step == SD_OTHER: + if victim_balance > 0: + logs.append(transfer_log(victim, other, victim_balance)) + other_balance += victim_balance + victim_balance = 0 + elif step == SEND: + logs.append(transfer_log(entry, victim, SEND_AMOUNT)) + victim_balance += SEND_AMOUNT + else: + raise ValueError(f"unhandled step {step}") + if fork.is_eip_enabled(7708): + tx.expected_receipt = TransactionReceipt(logs=logs) + + expected_bal = None + if fork.is_eip_enabled(7928): + expected_bal = BlockAccessListExpectation( + account_expectations={ + victim: finalized_bal(fork, victim_balance, []) + } + ) + + entry_storage: dict[int, Address | int] = {0: victim} + for slot in range(1, len(steps) + 1): + entry_storage[slot] = 1 + state_test( + pre=pre, + post={ + entry: Account(balance=0, storage=entry_storage), + victim: finalized(fork, victim_balance), + other: Account(balance=other_balance), + }, + tx=tx, + expected_block_access_list=expected_bal, + ) + + +@pytest.mark.parametrize( + "beneficiary", + [pytest.param("self", id="self"), pytest.param("other", id="other")], +) +@pytest.mark.parametrize( + "initial_balance", + [pytest.param(0, id="zero_balance"), pytest.param(3, id="funded")], +) +@pytest.mark.parametrize( + "bump_nonce,write_storage", + [ + pytest.param(True, False, id="nonce"), + pytest.param(False, True, id="storage"), + pytest.param(True, True, id="nonce_and_storage"), + ], +) +def test_selfdestruct_clears_nonce_and_storage( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + beneficiary: str, + initial_balance: int, + bump_nonce: bool, + write_storage: bool, +) -> None: + """ + Cases 13-16 of the EIP-8246 test list: a same-tx contract that bumped + its nonce or wrote storage is removed, or under EIP-8246 reset to a + balance-only account. + """ + sender = pre.fund_eoa() + other = pre.fund_eoa(amount=OTHER_BALANCE) + + victim_code = Bytecode() + if bump_nonce: + victim_code += Op.POP(Op.CREATE(0, 0, 0)) * 2 + if write_storage: + victim_code += Op.SSTORE(0, 1) + Op.SSTORE(1, 2) + if beneficiary == "self": + victim_code += Op.SELFDESTRUCT(Op.ADDRESS) + elif beneficiary == "other": + victim_code += Op.SELFDESTRUCT(other) + else: + raise ValueError(f"unhandled beneficiary {beneficiary}") + initcode = Initcode(deploy_code=victim_code) + + entry = pre.deploy_contract( + code=Om.MSTORE(initcode, 0) + + Op.SSTORE( + 0, Op.CREATE(value=initial_balance, offset=0, size=len(initcode)) + ) + + Op.SSTORE(1, Op.CALL(gas=Op.GAS, address=Op.SLOAD(0))) + + Op.STOP + ) + victim = compute_create_address(address=entry, nonce=1) + + tx = Transaction(sender=sender, to=entry, value=initial_balance) + + if beneficiary == "self": + victim_balance = initial_balance + other_balance = OTHER_BALANCE + elif beneficiary == "other": + victim_balance = 0 + other_balance = OTHER_BALANCE + initial_balance + else: + raise ValueError(f"unhandled beneficiary {beneficiary}") + + logs: list[TransactionLog] = [] + if initial_balance > 0: + logs.append(transfer_log(sender, entry, initial_balance)) + logs.append(transfer_log(entry, victim, initial_balance)) + if beneficiary == "other": + logs.append(transfer_log(victim, other, initial_balance)) + if fork.is_eip_enabled(7708): + tx.expected_receipt = TransactionReceipt(logs=logs) + + expected_bal = None + if fork.is_eip_enabled(7928): + storage_reads = [0, 1] if write_storage else [] + expected_bal = BlockAccessListExpectation( + account_expectations={ + victim: finalized_bal(fork, victim_balance, storage_reads) + } + ) + + post: dict[Address, Account | None] = { + entry: Account(storage={0: victim, 1: 1}), + victim: finalized(fork, victim_balance), + other: Account(balance=other_balance), + } + if bump_nonce: + for child_nonce in (1, 2): + child = compute_create_address(address=victim, nonce=child_nonce) + post[child] = Account(nonce=1, code=b"", balance=0) + + state_test( + pre=pre, + post=post, + tx=tx, + expected_block_access_list=expected_bal, + ) + + +@pytest.mark.parametrize("call_opcode", [Op.DELEGATECALL, Op.CALLCODE]) +@pytest.mark.parametrize( + "beneficiary", + [pytest.param("self", id="self"), pytest.param("other", id="other")], +) +@pytest.mark.parametrize( + "initial_balance", + [pytest.param(0, id="zero_balance"), pytest.param(3, id="funded")], +) +@EIPChecklist.Opcode.Test.ExecutionContext.Delegatecall(eip=[8246]) +@EIPChecklist.Opcode.Test.ExecutionContext.Delegatecall.Balance(eip=[8246]) +@EIPChecklist.Opcode.Test.ExecutionContext.Delegatecall.Code(eip=[8246]) +@EIPChecklist.Opcode.Test.ExecutionContext.Delegatecall.Storage(eip=[8246]) +@EIPChecklist.Opcode.Test.ExecutionContext.Callcode(eip=[8246]) +def test_selfdestruct_in_delegate_context( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + call_opcode: Op, + beneficiary: str, + initial_balance: int, +) -> None: + """ + The account destroyed is the one whose context runs SELFDESTRUCT, not + the library holding the code. + """ + sender = pre.fund_eoa() + other = pre.fund_eoa(amount=OTHER_BALANCE) + + if beneficiary == "self": + target: Address | Bytecode = Op.ADDRESS + elif beneficiary == "other": + target = other + else: + raise ValueError(f"unhandled beneficiary {beneficiary}") + library_code = Op.SSTORE(0, 1) + Op.SELFDESTRUCT(target) + library = pre.deploy_contract(code=library_code, balance=7) + + victim_code = Op.POP(call_opcode(gas=Op.GAS, address=library)) + Op.STOP + initcode = Initcode(deploy_code=victim_code) + entry = pre.deploy_contract( + code=Om.MSTORE(initcode, 0) + + Op.SSTORE( + 0, Op.CREATE(value=initial_balance, offset=0, size=len(initcode)) + ) + + Op.SSTORE(1, Op.CALL(gas=Op.GAS, address=Op.SLOAD(0))) + + Op.STOP + ) + victim = compute_create_address(address=entry, nonce=1) + + tx = Transaction(sender=sender, to=entry, value=initial_balance) + + if beneficiary == "self": + victim_balance = initial_balance + other_balance = OTHER_BALANCE + elif beneficiary == "other": + victim_balance = 0 + other_balance = OTHER_BALANCE + initial_balance + else: + raise ValueError(f"unhandled beneficiary {beneficiary}") + + logs: list[TransactionLog] = [] + if initial_balance > 0: + logs.append(transfer_log(sender, entry, initial_balance)) + logs.append(transfer_log(entry, victim, initial_balance)) + if beneficiary == "other": + logs.append(transfer_log(victim, other, initial_balance)) + if fork.is_eip_enabled(7708): + tx.expected_receipt = TransactionReceipt(logs=logs) + + expected_bal = None + if fork.is_eip_enabled(7928): + # The library's SSTORE runs in the victim's context, so the BAL + # attributes the (then cleared) slot to the victim, not the library. + expected_bal = BlockAccessListExpectation( + account_expectations={ + victim: finalized_bal(fork, victim_balance, [0]), + library: BalAccountExpectation.empty(), + } + ) + + state_test( + pre=pre, + post={ + entry: Account(storage={0: victim, 1: 1}), + victim: finalized(fork, victim_balance), + library: Account(balance=7, code=library_code, storage={}), + other: Account(balance=other_balance), + }, + tx=tx, + expected_block_access_list=expected_bal, + ) + + +@pytest.mark.parametrize( + "via_relay", + [pytest.param(False, id="direct"), pytest.param(True, id="via_relay")], +) +@pytest.mark.parametrize( + "initial_balance", + [pytest.param(0, id="zero_balance"), pytest.param(3, id="funded")], +) +@EIPChecklist.Opcode.Test.ExecutionContext.Staticcall(eip=[8246]) +@EIPChecklist.Opcode.Test.ExecutionContext.Staticcall.BanCheck(eip=[8246]) +@EIPChecklist.Opcode.Test.ExecutionContext.Staticcall.BanNoModification( + eip=[8246] +) +@EIPChecklist.Opcode.Test.ExecutionContext.Staticcall.SubCalls(eip=[8246]) +@EIPChecklist.Opcode.Test.ExceptionalAbort(eip=[8246]) +def test_selfdestruct_static_context_same_tx( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + via_relay: bool, + initial_balance: int, +) -> None: + """ + SELFDESTRUCT to self in a static context aborts, so the same-tx created + contract is never marked and keeps its code. + """ + sender = pre.fund_eoa() + victim_code = Op.SELFDESTRUCT(Op.ADDRESS) + initcode = Initcode(deploy_code=victim_code) + + if via_relay: + # The relay cannot SSTORE inside the static context, so it returns + # the inner call result instead. Without that witness a broken + # relay call would leave the same post-state as a correct abort. + relay = pre.deploy_contract( + code=Op.MSTORE(0, Op.CALL(gas=Op.GAS, address=Op.CALLDATALOAD(0))) + + Op.RETURN(0, 32) + ) + static_target: Address | Bytecode = relay + static_result = 1 + else: + static_target = Op.SLOAD(0) + static_result = 0 + + entry = pre.deploy_contract( + code=Om.MSTORE(initcode, 0) + + Op.SSTORE( + 0, Op.CREATE(value=initial_balance, offset=0, size=len(initcode)) + ) + + Op.MSTORE(0, Op.SLOAD(0)) + + Op.SSTORE( + 1, + Op.STATICCALL( + gas=Op.GAS, + address=static_target, + args_offset=0, + args_size=32, + ret_offset=64, + ret_size=32, + ), + ) + # Store the inner result offset by one so that a failing inner call + # is still a written slot. + + Op.SSTORE(2, Op.ADD(Op.MLOAD(64), 1)) + + Op.STOP + ) + victim = compute_create_address(address=entry, nonce=1) + + tx = Transaction(sender=sender, to=entry, value=initial_balance) + + logs: list[TransactionLog] = [] + if initial_balance > 0: + logs.append(transfer_log(sender, entry, initial_balance)) + logs.append(transfer_log(entry, victim, initial_balance)) + if fork.is_eip_enabled(7708): + tx.expected_receipt = TransactionReceipt(logs=logs) + + expected_bal = None + if fork.is_eip_enabled(7928): + balance_changes = [] + if initial_balance > 0: + balance_changes = [ + BalBalanceChange( + block_access_index=1, post_balance=initial_balance + ) + ] + expected_bal = BlockAccessListExpectation( + account_expectations={ + victim: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + code_changes=[ + BalCodeChange( + block_access_index=1, new_code=bytes(victim_code) + ) + ], + balance_changes=balance_changes, + storage_changes=[], + storage_reads=[], + ) + } + ) + + state_test( + pre=pre, + post={ + entry: Account(storage={0: victim, 1: static_result, 2: 1}), + victim: Account( + balance=initial_balance, nonce=1, code=victim_code + ), + }, + tx=tx, + expected_block_access_list=expected_bal, + ) From a29fdd72ba54a4895ced808f95a03780e55581ba Mon Sep 17 00:00:00 2001 From: fselmo Date: Fri, 4 Sep 2026 15:12:31 -0600 Subject: [PATCH 4/5] chore(tests): clarify intent in tests / reorganize --- .../test_eip_mainnet.py | 5 +-- .../test_fork_transition.py | 5 +-- .../test_selfdestruct_no_burn.py | 7 ++-- ...x.py => test_selfdestruct_finalization.py} | 35 +++++++++++-------- 4 files changed, 30 insertions(+), 22 deletions(-) rename tests/cancun/eip6780_selfdestruct/{test_selfdestruct_same_tx.py => test_selfdestruct_finalization.py} (93%) diff --git a/tests/amsterdam/eip8246_selfdestruct_no_burn/test_eip_mainnet.py b/tests/amsterdam/eip8246_selfdestruct_no_burn/test_eip_mainnet.py index 56a52a2240e..79f9b2181c9 100644 --- a/tests/amsterdam/eip8246_selfdestruct_no_burn/test_eip_mainnet.py +++ b/tests/amsterdam/eip8246_selfdestruct_no_burn/test_eip_mainnet.py @@ -55,8 +55,9 @@ def test_factory_create_selfdestruct_to_self_keeps_balance( pre: Alloc, ) -> None: """ - A factory CREATE whose initcode self-destructs to itself, with the - balance still observable by the factory in the same transaction. + A factory CREATEs a contract whose initcode self-destructs to itself. + The factory then reads the new account's balance and finds the + endowment still there. """ sender = pre.fund_eoa() initcode = Op.SELFDESTRUCT(Op.ADDRESS) diff --git a/tests/amsterdam/eip8246_selfdestruct_no_burn/test_fork_transition.py b/tests/amsterdam/eip8246_selfdestruct_no_burn/test_fork_transition.py index 55490ac0206..a7d29dd51c1 100644 --- a/tests/amsterdam/eip8246_selfdestruct_no_burn/test_fork_transition.py +++ b/tests/amsterdam/eip8246_selfdestruct_no_burn/test_fork_transition.py @@ -31,8 +31,9 @@ def test_selfdestruct_to_self_fork_transition( pre: Alloc, ) -> None: """ - A same-tx selfdestruct-to-self burns the balance before the fork and - leaves a balance-only account from the fork block on. + A contract created in the transaction self-destructs to itself. Before + the fork its balance is burned and the account is deleted; from the fork + block on the account stays behind holding that balance. """ endowment = 5 sender = pre.fund_eoa() diff --git a/tests/amsterdam/eip8246_selfdestruct_no_burn/test_selfdestruct_no_burn.py b/tests/amsterdam/eip8246_selfdestruct_no_burn/test_selfdestruct_no_burn.py index 3a66d623411..2ed7ff5bb60 100644 --- a/tests/amsterdam/eip8246_selfdestruct_no_burn/test_selfdestruct_no_burn.py +++ b/tests/amsterdam/eip8246_selfdestruct_no_burn/test_selfdestruct_no_burn.py @@ -302,9 +302,10 @@ def test_create2_redeploy_over_remnant( sweep_on_redeploy: bool, ) -> None: """ - Case 17 of the EIP and its third security consideration: the cleared - nonce and storage let the same factory CREATE2 over the funded remnant, - and the redeployed initcode can spend the preserved balance. + Case 17 of the EIP and its third security consideration. Because the + account left behind has nonce zero and no storage, the same factory can + CREATE2 over it again in a later transaction, and that second deployment + can spend the balance the first one left there. """ endowment = 5 deployer = pre.fund_eoa() diff --git a/tests/cancun/eip6780_selfdestruct/test_selfdestruct_same_tx.py b/tests/cancun/eip6780_selfdestruct/test_selfdestruct_finalization.py similarity index 93% rename from tests/cancun/eip6780_selfdestruct/test_selfdestruct_same_tx.py rename to tests/cancun/eip6780_selfdestruct/test_selfdestruct_finalization.py index 38a153b7ba8..0cd04f8cfd5 100644 --- a/tests/cancun/eip6780_selfdestruct/test_selfdestruct_same_tx.py +++ b/tests/cancun/eip6780_selfdestruct/test_selfdestruct_finalization.py @@ -1,12 +1,14 @@ """ -Same-transaction SELFDESTRUCT scenarios. +What is left of a contract that self-destructs in the transaction that +created it. Tests for [EIP-6780: SELFDESTRUCT only in same transaction](https://eips.ethereum.org/EIPS/eip-6780). -A contract created in the transaction self-destructs, possibly more than -once and around value it receives afterwards. Before EIP-8246 finalization -removes the account and burns whatever it still holds; from EIP-8246 on -only nonce, code and storage are cleared and the balance survives. +Such a contract may self-destruct more than once, and may receive more +value afterwards. At the end of the transaction, before EIP-8246 the +account is deleted and any balance it still holds is burned; from EIP-8246 +on its nonce, code and storage are cleared but the balance stays, leaving a +balance-only account behind. """ import pytest @@ -52,8 +54,9 @@ def finalized(fork: Fork, balance: int) -> Account | None: """ - Post-state of a same-tx destroyed contract that held ``balance`` when - the transaction ended: removed, or balance-only under EIP-8246. + Expected account for a contract that self-destructed in the transaction + that created it, holding ``balance`` when the transaction ended. It is + deleted, or under EIP-8246 kept with only that balance. """ if fork.is_eip_enabled(8246) and balance > 0: return Account(balance=balance, nonce=0, code=b"", storage={}) @@ -63,7 +66,7 @@ def finalized(fork: Fork, balance: int) -> Account | None: def finalized_bal( fork: Fork, balance: int, storage_reads: list[int] ) -> BalAccountExpectation: - """BAL entry of a same-tx destroyed contract.""" + """Block access list entry expected for that same contract.""" kept = balance if fork.is_eip_enabled(8246) else 0 if kept == 0 and not storage_reads: return BalAccountExpectation.empty() @@ -115,8 +118,9 @@ def test_selfdestruct_sequences( initial_balance: int, ) -> None: """ - Cases 1-12 of the EIP-8246 test list: what a sequence of self-destructs - and value sends leaves in a same-tx created contract at finalization. + Cases 1-12 of the EIP-8246 test list: a contract created in this + transaction runs a sequence of self-destructs and value sends, and + whatever it still holds at the end is what it keeps. """ sender = pre.fund_eoa() other = pre.fund_eoa(amount=OTHER_BALANCE) @@ -250,9 +254,10 @@ def test_selfdestruct_clears_nonce_and_storage( write_storage: bool, ) -> None: """ - Cases 13-16 of the EIP-8246 test list: a same-tx contract that bumped - its nonce or wrote storage is removed, or under EIP-8246 reset to a - balance-only account. + Cases 13-16 of the EIP-8246 test list: a contract created in this + transaction raises its nonce or writes storage before self-destructing. + It is deleted, or under EIP-8246 has its nonce reset to zero and its + storage cleared while it keeps its balance. """ sender = pre.fund_eoa() other = pre.fund_eoa(amount=OTHER_BALANCE) @@ -444,8 +449,8 @@ def test_selfdestruct_static_context_same_tx( initial_balance: int, ) -> None: """ - SELFDESTRUCT to self in a static context aborts, so the same-tx created - contract is never marked and keeps its code. + SELFDESTRUCT to self inside a static call aborts the frame, so the + contract is never registered for deletion and keeps its code. """ sender = pre.fund_eoa() victim_code = Op.SELFDESTRUCT(Op.ADDRESS) From 552dfdaed613848331a8a7e1dec807742d8e6ef0 Mon Sep 17 00:00:00 2001 From: fselmo Date: Fri, 4 Sep 2026 15:37:56 -0600 Subject: [PATCH 5/5] feat(tests): cover the EIP-8246 redeploy attack, cross-block recreate and untouched balances --- .../test_selfdestruct_no_burn.py | 65 ++++++++++ .../test_selfdestruct_finalization.py | 115 ++++++++++++++++++ 2 files changed, 180 insertions(+) diff --git a/tests/amsterdam/eip8246_selfdestruct_no_burn/test_selfdestruct_no_burn.py b/tests/amsterdam/eip8246_selfdestruct_no_burn/test_selfdestruct_no_burn.py index 2ed7ff5bb60..69d1cee3b16 100644 --- a/tests/amsterdam/eip8246_selfdestruct_no_burn/test_selfdestruct_no_burn.py +++ b/tests/amsterdam/eip8246_selfdestruct_no_burn/test_selfdestruct_no_burn.py @@ -24,6 +24,7 @@ Storage, Transaction, TransactionReceipt, + compute_create2_address, compute_create_address, keccak256, ) @@ -33,6 +34,7 @@ from execution_testing.checklists import EIPChecklist from ..eip7708_eth_transfer_logs.spec import transfer_log +from ..eip7997_deterministic_factory_predeploy.spec import Spec from .spec import ref_spec_8246 REFERENCE_SPEC_GIT_PATH = ref_spec_8246.git_path @@ -40,6 +42,8 @@ pytestmark = pytest.mark.valid_from("EIP8246") +FACTORY = Address(Spec.FACTORY_ADDRESS) + @pytest.mark.parametrize("initial_balance", [0, 1]) @pytest.mark.parametrize("create_opcode", [Op.CREATE, Op.CREATE2]) @@ -400,3 +404,64 @@ def test_create2_redeploy_over_remnant( created: created_post, }, ) + + +@EIPChecklist.Opcode.Test.ExecutionContext.Initcode.Reentry() +def test_deterministic_factory_redeploy_takes_balance( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """ + The EIP's third security consideration, using the permissionless + EIP-7997 factory. One account deploys a contract through the factory + that self-destructs to itself, leaving its balance behind. Anyone else + can then call the same factory with the same salt and initcode, land on + that balance, and take it. + """ + endowment = 5 + deployer = pre.fund_eoa() + redeployer = pre.fund_eoa() + salt = 0x8246 + + # Identical bytes both times: the first deployment only funds itself, + # while a redeploy finds more than it was sent and sweeps the surplus. + initcode = Conditional( + condition=Op.GT(Op.BALANCE(Op.ADDRESS), Op.CALLVALUE), + if_true=Op.SELFDESTRUCT(Op.ORIGIN), + if_false=Op.SELFDESTRUCT(Op.ADDRESS), + ) + created = compute_create2_address(Spec.FACTORY_ADDRESS, salt, initcode) + call_data = Hash(salt) + bytes(initcode) + + deploy_tx = Transaction( + sender=deployer, + to=FACTORY, + value=endowment, + data=call_data, + ) + deploy_tx.expected_receipt = TransactionReceipt( + logs=[ + transfer_log(deployer, FACTORY, endowment), + transfer_log(FACTORY, created, endowment), + ] + ) + # The sweep log is what proves the redeployer, not the deployer, ends + # up with the balance. + redeploy_tx = Transaction( + sender=redeployer, + to=FACTORY, + value=0, + data=call_data, + ) + redeploy_tx.expected_receipt = TransactionReceipt( + logs=[transfer_log(created, redeployer, endowment)] + ) + + blockchain_test( + pre=pre, + blocks=[Block(txs=[deploy_tx, redeploy_tx])], + post={ + FACTORY: Account(balance=0, code=Spec.FACTORY_BYTECODE), + created: Account.NONEXISTENT, + }, + ) diff --git a/tests/cancun/eip6780_selfdestruct/test_selfdestruct_finalization.py b/tests/cancun/eip6780_selfdestruct/test_selfdestruct_finalization.py index 0cd04f8cfd5..e89f2875298 100644 --- a/tests/cancun/eip6780_selfdestruct/test_selfdestruct_finalization.py +++ b/tests/cancun/eip6780_selfdestruct/test_selfdestruct_finalization.py @@ -20,10 +20,13 @@ BalBalanceChange, BalCodeChange, BalNonceChange, + Block, BlockAccessListExpectation, + BlockchainTestFiller, Bytecode, Conditional, Fork, + Hash, Initcode, Op, StateTestFiller, @@ -541,3 +544,115 @@ def test_selfdestruct_static_context_same_tx( tx=tx, expected_block_access_list=expected_bal, ) + + +@pytest.mark.parametrize( + "endowment", + [pytest.param(0, id="zero_balance"), pytest.param(3, id="funded")], +) +@EIPChecklist.Opcode.Test.ExecutionContext.Initcode.Reentry(eip=[8246]) +def test_recreate_in_later_block( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + endowment: int, +) -> None: + """ + A contract is created and self-destructs in one block, then the same + factory creates it again at the same address in a later block. Before + EIP-8246 the first account is gone by then, so the second creation + starts from nothing; from EIP-8246 on it lands on the balance the first + one left behind and adds to it. + """ + sender = pre.fund_eoa() + initcode = Op.SELFDESTRUCT(Op.ADDRESS) + factory = pre.deploy_contract( + code=Om.MSTORE(initcode, 0) + + Op.SSTORE( + Op.CALLDATALOAD(0), + Op.CREATE2( + value=Op.CALLVALUE, offset=0, size=len(initcode), salt=0 + ), + ) + + Op.STOP + ) + created = compute_create_address( + address=factory, salt=0, initcode=initcode, opcode=Op.CREATE2 + ) + + def deploy(slot: int) -> Transaction: + tx = Transaction( + sender=sender, to=factory, value=endowment, data=Hash(slot) + ) + if fork.is_eip_enabled(7708) and endowment > 0: + tx.expected_receipt = TransactionReceipt( + logs=[ + transfer_log(sender, factory, endowment), + transfer_log(factory, created, endowment), + ] + ) + return tx + + # Each deployment keeps its endowment under EIP-8246, so the second one + # finds the first one's balance still there. + final_balance = 2 * endowment if fork.is_eip_enabled(8246) else 0 + + blockchain_test( + pre=pre, + blocks=[Block(txs=[deploy(1)]), Block(txs=[deploy(2)])], + post={ + factory: Account(nonce=3, storage={1: created, 2: created}), + created: finalized(fork, final_balance), + }, + ) + + +@pytest.mark.parametrize( + "existing_balance", + [pytest.param(0, id="zero_balance"), pytest.param(3, id="funded")], +) +def test_selfdestruct_to_self_keeps_existing_balance( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + existing_balance: int, +) -> None: + """ + A contract is created at an address that already held a balance, with + the creation forwarding nothing of its own, and then self-destructs to + itself. From EIP-8246 on that balance is never moved at all, so the + block access list records no change for the account even though it was + created and cleared within the transaction. + """ + sender = pre.fund_eoa() + initcode = Op.SELFDESTRUCT(Op.ADDRESS) + factory = pre.deploy_contract( + code=Om.MSTORE(initcode, 0) + + Op.SSTORE(0, Op.CREATE(value=0, offset=0, size=len(initcode))) + + Op.STOP + ) + created = compute_create_address(address=factory, nonce=1) + if existing_balance > 0: + pre.fund_address(created, existing_balance) + + tx = Transaction(sender=sender, to=factory) + if fork.is_eip_enabled(7708): + tx.expected_receipt = TransactionReceipt(logs=[]) + + expected_bal = None + if fork.is_eip_enabled(7928): + # The nonce goes up and back down and no code is ever deposited, + # so nothing about the account actually changes. + expected_bal = BlockAccessListExpectation( + account_expectations={created: BalAccountExpectation.empty()} + ) + + state_test( + pre=pre, + post={ + factory: Account(nonce=2, storage={0: created}), + created: finalized(fork, existing_balance), + }, + tx=tx, + expected_block_access_list=expected_bal, + )