From e3c7ae5b53ee5847a14f51f981e40d0d0bd7d197 Mon Sep 17 00:00:00 2001 From: LouisTsai Date: Tue, 12 May 2026 20:33:46 +0800 Subject: [PATCH 1/3] feat: implement eip-8624 --- src/ethereum/forks/amsterdam/__init__.py | 2 ++ src/ethereum/forks/amsterdam/fork.py | 6 +++++- src/ethereum/forks/amsterdam/state_tracker.py | 16 +++++++++++++++- .../forks/amsterdam/vm/instructions/system.py | 4 ---- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/ethereum/forks/amsterdam/__init__.py b/src/ethereum/forks/amsterdam/__init__.py index e6f3e9476a0..925dd5ec047 100644 --- a/src/ethereum/forks/amsterdam/__init__.py +++ b/src/ethereum/forks/amsterdam/__init__.py @@ -4,11 +4,13 @@ ### Changes - [EIP-7928: Block-Level Access Lists][EIP-7928] +- [EIP-8246: Remove SELFDESTRUCT balance burn][EIP-8246] ### Releases [EIP-7773]: https://eips.ethereum.org/EIPS/eip-7773 [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 +[EIP-8246]: https://eips.ethereum.org/EIPS/eip-8246 """ from ethereum.fork_criteria import ForkCriteria, Unscheduled diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 6ab8d8bcd76..b6bffcea085 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -73,11 +73,14 @@ account_exists_and_is_empty, create_ether, destroy_account, + destroy_storage, extract_block_diff, get_account, get_code, incorporate_tx_into_block, increment_nonce, + modify_state, + preserve_account_balance, set_account_balance, ) from .transactions import ( @@ -1087,7 +1090,8 @@ def process_transaction( block_output.block_logs += tx_output.logs for address in tx_output.accounts_to_delete: - destroy_account(tx_state, address) + destroy_storage(tx_state, address) + modify_state(tx_state, address, preserve_account_balance) incorporate_tx_into_block(tx_state, block_env.block_access_list_builder) diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index c75eb198f53..6da3f4e30bf 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -434,7 +434,7 @@ def destroy_account(tx_state: TransactionState, address: Address) -> None: This function is made available exclusively for the ``SELFDESTRUCT`` opcode. It is expected that ``SELFDESTRUCT`` will be disabled in a future hardfork and this function will be removed. Only supports same - transaction destruction. + transaction destruction and zero balance on created accounts. Parameters ---------- @@ -448,6 +448,20 @@ def destroy_account(tx_state: TransactionState, address: Address) -> None: set_account(tx_state, address, None) +def preserve_account_balance(account: Account) -> None: + """ + Clear nonce and code for an account, but preserve balance. + + Parameters + ---------- + account : + The account to modify. + + """ + account.nonce = Uint(0) + account.code_hash = EMPTY_CODE_HASH + + def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ Completely remove the storage at ``address``. diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 42a4643f9c0..97e1d28a368 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -25,7 +25,6 @@ increment_nonce, is_account_alive, move_ether, - set_account_balance, ) from ...utils.address import ( compute_contract_address, @@ -609,9 +608,6 @@ def selfdestruct(evm: Evm) -> None: # register account for deletion only if it was created # in the same transaction if originator in tx_state.created_accounts: - # If beneficiary is the same as originator, then - # the ether is burnt. - set_account_balance(tx_state, originator, U256(0)) evm.accounts_to_delete.add(originator) # HALT the execution From 0ee0f02e0796dcd090bb2fd0d8bfd2551ea0891f Mon Sep 17 00:00:00 2001 From: LouisTsai Date: Tue, 12 May 2026 20:54:25 +0800 Subject: [PATCH 2/3] tests: eip-8264 basic case --- .../eip8246_selfdestruct_no_burn/__init__.py | 1 + .../eip8246_selfdestruct_no_burn/spec.py | 17 +++ .../test_selfdestruct_no_burn.py | 103 ++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 tests/amsterdam/eip8246_selfdestruct_no_burn/__init__.py create mode 100644 tests/amsterdam/eip8246_selfdestruct_no_burn/spec.py create mode 100644 tests/amsterdam/eip8246_selfdestruct_no_burn/test_selfdestruct_no_burn.py diff --git a/tests/amsterdam/eip8246_selfdestruct_no_burn/__init__.py b/tests/amsterdam/eip8246_selfdestruct_no_burn/__init__.py new file mode 100644 index 00000000000..53e52f58738 --- /dev/null +++ b/tests/amsterdam/eip8246_selfdestruct_no_burn/__init__.py @@ -0,0 +1 @@ +"""Tests for [EIP-8246: Remove SELFDESTRUCT Burn](https://eips.ethereum.org/EIPS/eip-8246).""" diff --git a/tests/amsterdam/eip8246_selfdestruct_no_burn/spec.py b/tests/amsterdam/eip8246_selfdestruct_no_burn/spec.py new file mode 100644 index 00000000000..d27c0b3f36a --- /dev/null +++ b/tests/amsterdam/eip8246_selfdestruct_no_burn/spec.py @@ -0,0 +1,17 @@ +"""Reference spec for [EIP-8246](https://eips.ethereum.org/EIPS/eip-8246).""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ReferenceSpec: + """Reference specification.""" + + git_path: str + version: str + + +ref_spec_8246 = ReferenceSpec( + git_path="EIPS/eip-8246.md", + version="3b30ff829e5e698f1c6f69427111d194b80af38d", +) 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 new file mode 100644 index 00000000000..3ed930b5e57 --- /dev/null +++ b/tests/amsterdam/eip8246_selfdestruct_no_burn/test_selfdestruct_no_burn.py @@ -0,0 +1,103 @@ +"""Tests for [EIP-8246: Remove SELFDESTRUCT balance burn](https://eips.ethereum.org/EIPS/eip-8246).""" + +from typing import Dict, Optional + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + Initcode, + Op, + StateTestFiller, + Transaction, + compute_create_address, +) + +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_from("Amsterdam") + + +@pytest.mark.parametrize( + "child_balance", + [0, 100_000], +) +def test_selfdestruct_to_self_preserves_balance( + state_test: StateTestFiller, + pre: Alloc, + child_balance: int, +) -> None: + """Same-tx selfdestruct-to-self preserves balance.""" + sender = pre.fund_eoa() + child_initcode = Op.SELFDESTRUCT(Op.ADDRESS) + initcode_holder = pre.deploy_contract(code=child_initcode) + + factory_code = ( + Op.EXTCODECOPY(initcode_holder, 0, 0, len(child_initcode)) + + Op.POP( + Op.CREATE(value=child_balance, offset=0, size=len(child_initcode)) + ) + + Op.RETURN(0, 0) + ) + factory = compute_create_address(address=sender, nonce=sender.nonce) + child = compute_create_address(address=factory, nonce=1) + + tx = Transaction( + sender=sender, + to=None, + data=factory_code, + value=child_balance, + gas_limit=2_000_000, + ) + + post: Dict[Address, Optional[Account]] = ( + {child: Account.NONEXISTENT} + if child_balance == 0 + else { + child: Account( + balance=child_balance, nonce=0, code=b"", storage={} + ) + } + ) + state_test(pre=pre, post=post, tx=tx) + + +def test_double_selfdestruct_to_self( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """Two consecutive selfdestruct-to-self calls preserve balance.""" + sender = pre.fund_eoa() + child_initcode = Initcode(deploy_code=Op.SELFDESTRUCT(Op.ADDRESS)) + initcode_holder = pre.deploy_contract(code=child_initcode) + value_sent = 1 + + factory = compute_create_address(address=sender, nonce=sender.nonce) + child = compute_create_address(address=factory, nonce=1) + + factory_code = ( + Op.EXTCODECOPY(initcode_holder, 0, 0, len(child_initcode)) + + Op.POP( + Op.CREATE(value=value_sent, offset=0, size=len(child_initcode)) + ) + + Op.POP(Op.CALL(gas=Op.GAS, address=child, value=value_sent)) + + Op.POP(Op.CALL(gas=Op.GAS, address=child, value=value_sent)) + + Op.RETURN(0, 0) + ) + + tx = Transaction( + sender=sender, + to=None, + data=factory_code, + value=value_sent * 3, + gas_limit=2_000_000, + ) + + post = { + child: Account(balance=value_sent * 3, nonce=0, code=b"", storage={}), + } + state_test(pre=pre, post=post, tx=tx) From 68d5c14a84a14785612fe096fad687d98d4323f3 Mon Sep 17 00:00:00 2001 From: LouisTsai Date: Fri, 15 May 2026 00:05:06 +0800 Subject: [PATCH 3/3] refactor: add more test parametrizatino --- .../forks/forks/eips/amsterdam/eip_8246.py | 13 + .../test_selfdestruct_no_burn.py | 250 +++++++++++++----- 2 files changed, 202 insertions(+), 61 deletions(-) create mode 100644 packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_8246.py diff --git a/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_8246.py b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_8246.py new file mode 100644 index 00000000000..f6b746c4c6f --- /dev/null +++ b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_8246.py @@ -0,0 +1,13 @@ +""" +EIP-8246: Remove SELFDESTRUCT balance burn. + +https://eips.ethereum.org/EIPS/eip-8246 +""" + +from ....base_fork import BaseFork + + +class EIP8246(BaseFork): + """EIP-8246 class.""" + + pass 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 3ed930b5e57..30394873fb4 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 @@ -1,17 +1,18 @@ """Tests for [EIP-8246: Remove SELFDESTRUCT balance burn](https://eips.ethereum.org/EIPS/eip-8246).""" -from typing import Dict, Optional - import pytest from execution_testing import ( Account, Address, Alloc, - Initcode, + Block, + BlockchainTestFiller, + Bytecode, Op, - StateTestFiller, + Storage, Transaction, compute_create_address, + keccak256, ) from .spec import ref_spec_8246 @@ -19,85 +20,212 @@ REFERENCE_SPEC_GIT_PATH = ref_spec_8246.git_path REFERENCE_SPEC_VERSION = ref_spec_8246.version -pytestmark = pytest.mark.valid_from("Amsterdam") +pytestmark = pytest.mark.valid_from("EIP8246") +@pytest.mark.parametrize("initial_balance", [0, 1]) +@pytest.mark.parametrize("create_opcode", [Op.CREATE, Op.CREATE2]) +@pytest.mark.parametrize("post_send_count", [0, 1, 3]) +@pytest.mark.parametrize( + "post_send_opcode", [Op.CALL, Op.CALLCODE, Op.SELFDESTRUCT] +) +@pytest.mark.parametrize( + "initial_storage", + [ + pytest.param(False, id="no_storage"), + pytest.param(True, id="with_storage"), + ], +) +@pytest.mark.parametrize( + "transfer_target, transfer_drains_victim", + [ + pytest.param(Op.ADDRESS, False, id="self"), + pytest.param(0x01, True, id="precompile"), + pytest.param( + Address(keccak256(b"eip-8246-eoa-target")[-20:]), + True, + id="eoa", + ), + ], +) @pytest.mark.parametrize( - "child_balance", - [0, 100_000], + "exit_op, execution_success", + [ + pytest.param(Op.STOP, True, id="success"), + pytest.param(Op.REVERT(0, 0), False, id="revert"), + pytest.param(Op.MSTORE(2**32, 0), False, id="oog"), + ], ) -def test_selfdestruct_to_self_preserves_balance( - state_test: StateTestFiller, +def test_selfdestruct_preserves_balance( + blockchain_test: BlockchainTestFiller, pre: Alloc, - child_balance: int, + initial_balance: int, + post_send_count: int, + create_opcode: Op, + post_send_opcode: Op, + initial_storage: bool, + transfer_target: Op, + transfer_drains_victim: bool, + exit_op: Op, + execution_success: bool, ) -> None: - """Same-tx selfdestruct-to-self preserves balance.""" - sender = pre.fund_eoa() - child_initcode = Op.SELFDESTRUCT(Op.ADDRESS) - initcode_holder = pre.deploy_contract(code=child_initcode) + """ + Same-tx SELFDESTRUCT preserves the victim's balance per EIP-8246. + + Test flow: + selfdestruct_tx + tx.to = entry_contract + └─ CALL selfdestruct_contract_factory + └─ initcode runs: + [optional] SSTORE(slot, value) + SELFDESTRUCT(transfer_target) # registers victim + └─ selfdestruct_contract_factory exits via STOP | REVERT | OOG + └─ N * post-send to victim (CALL | CALLCODE | donor.SELFDESTRUCT) + + tx finalize + - victim balance-only per EIP-8246, + - or NONEXISTENT if EIP-161 cleans up a zero-balance account + + probe_tx + tx.to = probe_contract + └─ STORAGE [0] = BALANCE(victim) + STORAGE [1] = EXTCODEHASH(victim) + STORAGE [2] = EXTCODESIZE(victim) + STORAGE [3] = SHA3(EXTCODECOPY(victim, 0, 0, size)) + """ + # Selfdestruct target contract template. + # Optionally initializes storage to test clearing. + storage_init = Op.SSTORE(0, 1) if initial_storage else Bytecode() + selfdestruct_initcode = storage_init + Op.SELFDESTRUCT(transfer_target) + + selfdestruct_template = pre.deploy_contract(code=selfdestruct_initcode) + + # Build selfdestruct target contract via CREATE/CREATE2 + salt = 0 + if create_opcode == Op.CREATE2: + create_call = create_opcode( + value=initial_balance, + size=len(selfdestruct_initcode), + salt=salt, + ) + else: + create_call = create_opcode( + value=initial_balance, + size=len(selfdestruct_initcode), + ) - factory_code = ( - Op.EXTCODECOPY(initcode_holder, 0, 0, len(child_initcode)) - + Op.POP( - Op.CREATE(value=child_balance, offset=0, size=len(child_initcode)) + # Selfdestruct target contract factory + # Exits via STOP/REVERT/OOG for different scenario + selfdestruct_contract_factory = pre.deploy_contract( + code=Op.EXTCODECOPY( + address=selfdestruct_template, size=len(selfdestruct_initcode) ) - + Op.RETURN(0, 0) + + Op.POP(create_call) + + exit_op ) - factory = compute_create_address(address=sender, nonce=sender.nonce) - child = compute_create_address(address=factory, nonce=1) - tx = Transaction( - sender=sender, - to=None, - data=factory_code, - value=child_balance, - gas_limit=2_000_000, + victim = compute_create_address( + address=selfdestruct_contract_factory, + opcode=create_opcode, + nonce=1, + salt=salt, + initcode=selfdestruct_initcode, ) - post: Dict[Address, Optional[Account]] = ( - {child: Account.NONEXISTENT} - if child_balance == 0 - else { - child: Account( - balance=child_balance, nonce=0, code=b"", storage={} + # Post value sending to the victim + # Ensure the ether transfer is not burned after eip-8246. + post_send_value = 1 + if post_send_opcode == Op.SELFDESTRUCT: + donor = pre.deploy_contract(code=Op.SELFDESTRUCT(victim)) + post_send = Op.POP( + Op.CALL(gas=Op.GAS, address=donor, value=post_send_value) + ) + else: + post_send = Op.POP( + post_send_opcode(gas=Op.GAS, address=victim, value=post_send_value) + ) + + entry_contract = pre.deploy_contract( + code=Op.POP( + Op.CALL( + gas=Op.GAS, + address=selfdestruct_contract_factory, + value=initial_balance, ) - } + ) + + post_send * post_send_count ) - state_test(pre=pre, post=post, tx=tx) + total_balance = initial_balance + post_send_count * post_send_value -def test_double_selfdestruct_to_self( - state_test: StateTestFiller, - pre: Alloc, -) -> None: - """Two consecutive selfdestruct-to-self calls preserve balance.""" sender = pre.fund_eoa() - child_initcode = Initcode(deploy_code=Op.SELFDESTRUCT(Op.ADDRESS)) - initcode_holder = pre.deploy_contract(code=child_initcode) - value_sent = 1 + selfdestruct_tx = Transaction( + sender=sender, + to=entry_contract, + value=total_balance, + gas_limit=5_000_000, + ) + + # Balance verification + # retained: + # selfdestruct-to-self retains balance + # selfdestruct-to-others drains balance if not revert / OOG + # delivered: post-sends count except for CALLCODE + retained = 0 if transfer_drains_victim else initial_balance + delivered = ( + 0 + if post_send_opcode == Op.CALLCODE + else post_send_count * post_send_value + ) - factory = compute_create_address(address=sender, nonce=sender.nonce) - child = compute_create_address(address=factory, nonce=1) + expected_balance = retained + delivered if execution_success else delivered + victim_alive = expected_balance > 0 - factory_code = ( - Op.EXTCODECOPY(initcode_holder, 0, 0, len(child_initcode)) - + Op.POP( - Op.CREATE(value=value_sent, offset=0, size=len(child_initcode)) + probe_storage = Storage() + probe_code = ( + Op.SSTORE( + probe_storage.store_next(expected_balance), + Op.BALANCE(victim), ) - + Op.POP(Op.CALL(gas=Op.GAS, address=child, value=value_sent)) - + Op.POP(Op.CALL(gas=Op.GAS, address=child, value=value_sent)) - + Op.RETURN(0, 0) + + Op.SSTORE( + probe_storage.store_next(keccak256(b"") if victim_alive else 0), + Op.EXTCODEHASH(victim), + ) + + Op.SSTORE( + probe_storage.store_next(0), + Op.EXTCODESIZE(victim), + ) + + Op.EXTCODECOPY(victim, 0, 0, len(selfdestruct_initcode)) + + Op.SSTORE( + probe_storage.store_next( + keccak256(b"\x00" * len(selfdestruct_initcode)) + ), + Op.SHA3(0, len(selfdestruct_initcode)), + ) + + Op.STOP ) - tx = Transaction( + probe_contract = pre.deploy_contract( + code=probe_code, storage=probe_storage.canary() + ) + + probe_tx = Transaction( sender=sender, - to=None, - data=factory_code, - value=value_sent * 3, - gas_limit=2_000_000, + to=probe_contract, + gas_limit=200_000, ) - post = { - child: Account(balance=value_sent * 3, nonce=0, code=b"", storage={}), - } - state_test(pre=pre, post=post, tx=tx) + blockchain_test( + pre=pre, + post={ + victim: ( + Account.NONEXISTENT + if not victim_alive + else Account( + balance=expected_balance, nonce=0, code=b"", storage={} + ) + ), + probe_contract: Account(storage=probe_storage), + }, + blocks=[Block(txs=[selfdestruct_tx, probe_tx])], + )