From e4ee738c41a249e4488e9f1b2a732c582b160ed6 Mon Sep 17 00:00:00 2001 From: carsons-eels Date: Tue, 13 Jan 2026 16:26:43 -0500 Subject: [PATCH 01/24] feat(spec-specs): Add transfer log for all `CALL*` and `SELFDESTRUCT` fix(spec-specs): correct CR issues, fix formatting fix(spec-specs): inline `execute_code()` to `process_message()` chore(spec-specs): backport changes fix(spec-specs): trim out whitespace in topic hash to match tests feat(spec-specs): add selfdestruct event topic and logging function feat(spec-specs): selfdestruct to self emits selfdestruct event feat(spec-specs): define call success constant feat(spec-specs): emit selfdestruct finalization log for remaining balance --- src/ethereum/forks/amsterdam/fork.py | 25 +++++- src/ethereum/forks/amsterdam/vm/__init__.py | 82 ++++++++++++++++++- .../forks/amsterdam/vm/instructions/system.py | 18 +++- .../forks/amsterdam/vm/interpreter.py | 6 +- 4 files changed, 125 insertions(+), 6 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 0ff0bf663ed..b6fccce7755 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -1070,8 +1070,29 @@ def process_transaction( block_output.block_gas_used += tx_gas_used_after_refund block_output.blob_gas_used += tx_blob_gas_used + # EIP-7708: Emit selfdestruct logs for remaining balance at finalization. + # This handles the case where a contract receives ETH after being flagged + # for SELFDESTRUCT but before finalization. + finalization_logs: List[Log] = [] + for address in tx_output.accounts_to_delete: + balance = get_account(tx_state, address).balance + if balance > U256(0): + padded_address = left_pad_zero_bytes(address, 32) + finalization_logs.append( + Log( + address=vm.SYSTEM_ADDRESS, + topics=( + vm.SELFDESTRUCT_TOPIC, + Hash32(padded_address), + ), + data=balance.to_be_bytes32(), + ) + ) + + all_logs = tx_output.logs + tuple(finalization_logs) + receipt = make_receipt( - tx, tx_output.error, block_output.block_gas_used, tx_output.logs + tx, tx_output.error, block_output.block_gas_used, all_logs ) receipt_key = rlp.encode(Uint(index)) @@ -1083,7 +1104,7 @@ def process_transaction( receipt, ) - block_output.block_logs += tx_output.logs + block_output.block_logs += all_logs for address in tx_output.accounts_to_delete: destroy_account(tx_state, address) diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index 44135d5e72a..8f06d30a68b 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -18,10 +18,11 @@ from ethereum_types.bytes import Bytes, Bytes0, Bytes32 from ethereum_types.numeric import U64, U256, Uint -from ethereum.crypto.hash import Hash32 +from ethereum.crypto.hash import Hash32, keccak256 from ethereum.exceptions import EthereumException from ethereum.merkle_patricia_trie import Trie from ethereum.state import Address +from ethereum.utils.byte import left_pad_zero_bytes from ..block_access_lists import BlockAccessList, BlockAccessListBuilder from ..blocks import Log, Receipt, Withdrawal @@ -30,6 +31,12 @@ from ..transactions import LegacyTransaction __all__ = ("Environment", "Evm", "Message") +TRANSFER_TOPIC = keccak256(b"Transfer(address,address,uint256)") +SELFDESTRUCT_TOPIC = keccak256(b"Selfdestruct(address, uint256)") +SYSTEM_ADDRESS = Address( + bytes.fromhex("fffffffffffffffffffffffffffffffffffffffe") +) +CALL_SUCCESS = U256(1) @dataclass @@ -195,3 +202,76 @@ def incorporate_child_on_error(evm: Evm, child_evm: Evm) -> None: """ evm.gas_left += child_evm.gas_left + + +def emit_transfer_log( + evm: Evm, + sender: Address, + recipient: Address, + transfer_amount: U256, +) -> None: + """ + Emit a LOG3 for all ETH transfers satisfying EIP-7708. + + Parameters + ---------- + evm : + The state of the ethereum virtual machine + sender : + The account address sending the transfer + recipient :ce to finalize sco + The address of the transfer recipient account + transfer_amount : + The amount of ETH transacted + + """ + if transfer_amount == 0: + return + + padded_sender = left_pad_zero_bytes(sender, 32) + padded_recipient = left_pad_zero_bytes(recipient, 32) + log_entry = Log( + address=SYSTEM_ADDRESS, + topics=( + TRANSFER_TOPIC, + Hash32(padded_sender), + Hash32(padded_recipient), + ), + data=transfer_amount.to_be_bytes32(), + ) + + evm.logs = evm.logs + (log_entry,) + + +def emit_selfdestruct_log( + evm: Evm, + account: Address, + amount: U256, +) -> None: + """ + Emit a LOG2 for self-destruct to self (balance burn) per EIP-7708. + + Parameters + ---------- + evm : + The state of the ethereum virtual machine + account : + The account address being selfdestructed + amount : + The amount of ETH being destroyed + + """ + if amount == 0: + return + + padded_account = left_pad_zero_bytes(account, 32) + log_entry = Log( + address=SYSTEM_ADDRESS, + topics=( + SELFDESTRUCT_TOPIC, + Hash32(padded_account), + ), + data=amount.to_be_bytes32(), + ) + + evm.logs = evm.logs + (log_entry,) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 42a4643f9c0..e37013ea6f2 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -36,8 +36,11 @@ calculate_delegation_cost, ) from .. import ( + CALL_SUCCESS, Evm, Message, + emit_selfdestruct_log, + emit_transfer_log, incorporate_child_on_error, incorporate_child_on_success, ) @@ -338,7 +341,7 @@ def generic_call( else: incorporate_child_on_success(evm, child_evm) evm.return_data = child_evm.output - push(evm.stack, U256(1)) + push(evm.stack, CALL_SUCCESS) actual_output_size = min(memory_output_size, U256(len(child_evm.output))) memory_write( @@ -427,8 +430,11 @@ def call(evm: Evm) -> None: extra_gas, ) charge_gas(evm, message_call_gas.cost + extend_memory.cost) - + if evm.message.is_static and value != U256(0): + raise WriteInStaticContext evm.memory += b"\x00" * extend_memory.expand_by + + # OPERATION sender_balance = get_account(tx_state, evm.message.current_target).balance if sender_balance < value: push(evm.stack, U256(0)) @@ -606,6 +612,14 @@ def selfdestruct(evm: Evm) -> None: # Transfer balance move_ether(tx_state, originator, beneficiary, originator_balance) + # EIP-7708: Emit appropriate log based on beneficiary + if beneficiary == originator: + # Self-destruct to self burns the balance + emit_selfdestruct_log(evm, originator, originator_balance) + else: + # Transfer to different beneficiary + emit_transfer_log(evm, originator, beneficiary, originator_balance) + # register account for deletion only if it was created # in the same transaction if originator in tx_state.created_accounts: diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index 80b30bd7ab2..f45e9fa6740 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -48,7 +48,7 @@ from ..vm.eoa_delegation import get_delegated_code_address, set_delegation from ..vm.gas import GasCosts, charge_gas from ..vm.precompiled_contracts.mapping import PRE_COMPILED_CONTRACTS -from . import Evm +from . import Evm, emit_transfer_log from .exceptions import ( AddressCollision, ExceptionalHalt, @@ -272,7 +272,11 @@ def process_message(message: Message) -> Evm: message.current_target, message.value, ) + emit_transfer_log( + evm, message.caller, message.current_target, message.value + ) + # Execute message code and handle errors try: if evm.message.code_address in PRE_COMPILED_CONTRACTS: if not message.disable_precompiles: From 580f790a6043547b39a37dd1d22c328b27ff219f Mon Sep 17 00:00:00 2001 From: carsons-eels Date: Wed, 21 Jan 2026 23:56:14 -0500 Subject: [PATCH 02/24] fix(spec-specs): emit account closure logs in lexicographical order --- src/ethereum/forks/amsterdam/fork.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index b6fccce7755..94ccbf46d85 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -1074,7 +1074,7 @@ def process_transaction( # This handles the case where a contract receives ETH after being flagged # for SELFDESTRUCT but before finalization. finalization_logs: List[Log] = [] - for address in tx_output.accounts_to_delete: + for address in sorted(tx_output.accounts_to_delete): balance = get_account(tx_state, address).balance if balance > U256(0): padded_address = left_pad_zero_bytes(address, 32) From a08c64b97dc62d929306e44770760d1fa9d9e2f5 Mon Sep 17 00:00:00 2001 From: spencer-tb Date: Fri, 16 Jan 2026 12:05:26 +0000 Subject: [PATCH 03/24] feat(test-tests): add eip-7708 eth transfer log tests test(test-tests): add selfdestruct topic and use empty account test(test-tests): add nested calls log ordering test feat(test-tests): add selfdestruct finalization test fix(test-tests): use spaces in event signature to match spec --- .../eip7708_eth_transfer_logs/__init__.py | 1 + .../eip7708_eth_transfer_logs/spec.py | 36 ++ .../test_eth_transfer_logs.py | 480 ++++++++++++++++++ 3 files changed, 517 insertions(+) create mode 100644 tests/amsterdam/eip7708_eth_transfer_logs/__init__.py create mode 100644 tests/amsterdam/eip7708_eth_transfer_logs/spec.py create mode 100644 tests/amsterdam/eip7708_eth_transfer_logs/test_eth_transfer_logs.py diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/__init__.py b/tests/amsterdam/eip7708_eth_transfer_logs/__init__.py new file mode 100644 index 00000000000..2f36477a667 --- /dev/null +++ b/tests/amsterdam/eip7708_eth_transfer_logs/__init__.py @@ -0,0 +1 @@ +"""Cross-client EIP-7708 Tests.""" diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/spec.py b/tests/amsterdam/eip7708_eth_transfer_logs/spec.py new file mode 100644 index 00000000000..984605a567e --- /dev/null +++ b/tests/amsterdam/eip7708_eth_transfer_logs/spec.py @@ -0,0 +1,36 @@ +"""Defines EIP-7708 specification constants and functions.""" + +from dataclasses import dataclass + +from execution_testing import Address, Hash, keccak256 + + +@dataclass(frozen=True) +class ReferenceSpec: + """Defines the reference spec version and git path.""" + + git_path: str + version: str + + +ref_spec_7708 = ReferenceSpec( + "EIPS/eip-7708.md", "a7c5b2ff5697d5a0be5ea804a89d98a7fd0dce60" +) + + +@dataclass(frozen=True) +class Spec: + """ + Parameters from the EIP-7708 specifications as defined at + https://eips.ethereum.org/EIPS/eip-7708. + """ + + SYSTEM_ADDRESS: Address = Address( + 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFE + ) + TRANSFER_TOPIC: Hash = Hash( + keccak256(b"Transfer(address, address, uint256)") + ) + SELFDESTRUCT_TOPIC: Hash = Hash( + keccak256(b"Selfdestruct(address, uint256)") + ) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_eth_transfer_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_eth_transfer_logs.py new file mode 100644 index 00000000000..9afddddc167 --- /dev/null +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_eth_transfer_logs.py @@ -0,0 +1,480 @@ +""" +Tests [EIP-7708: ETH Transfers Emit a Log](https://eips.ethereum.org/EIPS/eip-7708). + +Tests for verifying that ETH transfers emit LOG3 events as specified. +""" + +import pytest +from execution_testing import ( + EOA, + Account, + Address, + Alloc, + Bytecode, + Bytes, + Environment, + Hash, + Op, + StateTestFiller, + Transaction, + TransactionLog, + TransactionReceipt, + compute_create_address, +) + +from .spec import Spec, ref_spec_7708 + +REFERENCE_SPEC_GIT_PATH = ref_spec_7708.git_path +REFERENCE_SPEC_VERSION = ref_spec_7708.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + + +def transfer_log( + sender: Address, recipient: Address, amount: int +) -> TransactionLog: + """Create an expected transfer log.""" + return TransactionLog( + address=Spec.SYSTEM_ADDRESS, + topics=[ + Spec.TRANSFER_TOPIC, + Hash(bytes(sender).rjust(32, b"\x00")), + Hash(bytes(recipient).rjust(32, b"\x00")), + ], + data=Bytes(amount.to_bytes(32, "big")), + ) + + +def test_simple_transfer_emits_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, +) -> None: + """Test that a simple ETH transfer emits a transfer log.""" + recipient = pre.empty_account() + transfer_amount = 1000 + + tx = Transaction( + sender=sender, + to=recipient, + value=transfer_amount, + gas_limit=21_000, + expected_receipt=TransactionReceipt( + logs=[transfer_log(sender, recipient, transfer_amount)] + ), + ) + + post = {recipient: Account(balance=transfer_amount)} + state_test(env=env, pre=pre, post=post, tx=tx) + + +def test_zero_value_transfer_no_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, +) -> None: + """Test that a zero-value transfer does NOT emit a transfer log.""" + recipient = pre.empty_account() + + tx = Transaction( + sender=sender, + to=recipient, + value=0, + gas_limit=21_000, + expected_receipt=TransactionReceipt(logs=[]), + ) + + state_test(env=env, pre=pre, post={}, tx=tx) + + +def test_call_with_value_emits_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, +) -> None: + """Test that CALL with value emits a transfer log.""" + recipient = pre.empty_account() + transfer_amount = 500 + tx_transfer_amount = 1000 + + contract_code = Op.CALL( + gas=100_000, + address=recipient, + value=transfer_amount, + ) + contract = pre.deploy_contract(contract_code, balance=tx_transfer_amount) + + tx = Transaction( + sender=sender, + to=contract, + value=tx_transfer_amount, + gas_limit=100_000, + expected_receipt=TransactionReceipt( + logs=[ + transfer_log(sender, contract, tx_transfer_amount), + transfer_log(contract, recipient, transfer_amount), + ] + ), + ) + + post = {recipient: Account(balance=transfer_amount)} + state_test(env=env, pre=pre, post=post, tx=tx) + + +def test_selfdestruct_with_value_emits_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, +) -> None: + """Test that SELFDESTRUCT with value emits a transfer log.""" + beneficiary = pre.empty_account() + contract_balance = 2000 + + contract_code = Op.SELFDESTRUCT(beneficiary) + contract = pre.deploy_contract(contract_code, balance=contract_balance) + + tx = Transaction( + sender=sender, + to=contract, + value=0, + gas_limit=100_000, + expected_receipt=TransactionReceipt( + logs=[transfer_log(contract, beneficiary, contract_balance)] + ), + ) + + post = {beneficiary: Account(balance=contract_balance)} + state_test(env=env, pre=pre, post=post, tx=tx) + + +def selfdestruct_log(contract: Address, amount: int) -> TransactionLog: + """Create an expected selfdestruct log (for selfdestruct to self).""" + return TransactionLog( + address=Spec.SYSTEM_ADDRESS, + topics=[ + Spec.SELFDESTRUCT_TOPIC, + Hash(bytes(contract).rjust(32, b"\x00")), + ], + data=Bytes(amount.to_bytes(32, "big")), + ) + + +def test_selfdestruct_to_self_emits_finalization_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, +) -> None: + """ + Test that selfdestruct-to-self emits a finalization log for remaining ETH. + + Scenario: + 1. Factory creates child contract via CREATE with 1000 wei + 2. Factory calls child, child selfdestructs to itself (emits log for 1000) + 3. Factory sends 500 more wei to child (transfer log emitted) + 4. At finalization, child has 500 wei remaining (emits finalization log) + + This tests the EIP-7708 requirement that when a contract receives ETH after + being flagged for SELFDESTRUCT, a Selfdestruct log is emitted at + finalization for the remaining balance. + """ + tx_value = 2000 + child_init_balance = 1000 + additional_eth = 500 + + # Child contract: selfdestructs to itself (address(this)) + child_code = Op.SELFDESTRUCT(Op.ADDRESS) + child_initcode = Op.MSTORE( + 0, Op.PUSH32(bytes(child_code).rjust(32, b"\x00")) + ) + Op.RETURN(32 - len(child_code), len(child_code)) + + initcode_len = len(child_initcode) + + # Factory: CREATE child, CALL to trigger selfdestruct, CALL again with ETH + factory_code = ( + Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + + Op.SSTORE(1, Op.CREATE(child_init_balance, 0, initcode_len)) + + Op.CALL(address=Op.SLOAD(1), value=0, gas=50_000) + + Op.POP + + Op.CALL(address=Op.SLOAD(1), value=additional_eth, gas=50_000) + + Op.POP + ) + + factory = pre.deploy_contract( + factory_code, balance=child_init_balance + additional_eth + ) + child_addr = compute_create_address(address=factory, nonce=1) + + # Expected logs: + # 1. Transfer: sender -> factory (tx value) + # 2. Transfer: factory -> child (CREATE value) + # 3. Selfdestruct: child (initial balance at execution) + # 4. Transfer: factory -> child (additional ETH) + # 5. Selfdestruct: child (remaining balance at finalization) + expected_logs = [ + transfer_log(sender, factory, tx_value), + transfer_log(factory, child_addr, child_init_balance), + selfdestruct_log(child_addr, child_init_balance), + transfer_log(factory, child_addr, additional_eth), + selfdestruct_log(child_addr, additional_eth), + ] + + tx = Transaction( + sender=sender, + to=factory, + value=tx_value, + gas_limit=500_000, + data=bytes(child_initcode), + expected_receipt=TransactionReceipt(logs=expected_logs), + ) + + state_test(env=env, pre=pre, post={}, tx=tx) + + +@pytest.mark.parametrize( + "op_type", + [ + pytest.param("call", id="call"), + pytest.param("selfdestruct", id="selfdestruct"), + ], +) +def test_zero_value_operations_no_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + op_type: str, +) -> None: + """Test that zero-value operations do NOT emit transfer logs.""" + target = pre.empty_account() + + if op_type == "call": + contract_code = Op.CALL(gas=100_000, address=target, value=0) + else: + contract_code = Op.SELFDESTRUCT(target) + + contract = pre.deploy_contract(contract_code, balance=0) + + tx = Transaction( + sender=sender, + to=contract, + value=0, + gas_limit=100_000, + expected_receipt=TransactionReceipt(logs=[]), + ) + + state_test(env=env, pre=pre, post={}, tx=tx) + + +@pytest.mark.parametrize( + "recipient_code,call_gas,call_value,recipient_balance", + [ + pytest.param(Op.REVERT(0, 0), 50_000, 500, 0, id="call_reverted"), + pytest.param(Op.JUMP(0), 100, 500, 0, id="call_out_of_gas"), + pytest.param( + Op.SELFDESTRUCT(Address(0x1234)), + 100, + 0, + 2000, + id="selfdestruct_out_of_gas", + ), + ], +) +def test_failed_inner_operation_no_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + recipient_code: Bytecode, + call_gas: int, + call_value: int, + recipient_balance: int, +) -> None: + """Test that failed inner operations do NOT emit transfer logs.""" + recipient = pre.deploy_contract(recipient_code, balance=recipient_balance) + tx_value = 1000 + + contract_code = Op.CALL( + gas=call_gas, + address=recipient, + value=call_value, + ) + contract = pre.deploy_contract(contract_code, balance=call_value) + + tx = Transaction( + sender=sender, + to=contract, + value=tx_value, + gas_limit=100_000, + expected_receipt=TransactionReceipt( + logs=[transfer_log(sender, contract, tx_value)] + ), + ) + + state_test(env=env, pre=pre, post={}, tx=tx) + + +@pytest.mark.parametrize( + "call_depth", + [ + pytest.param(2, id="depth_2"), + pytest.param(3, id="depth_3"), + pytest.param(10, id="depth_10"), + ], +) +def test_nested_calls_log_order( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + call_depth: int, +) -> None: + """Test that nested CALLs emit transfer logs in chronological order.""" + transfer_value = 100 + tx_value = 1000 + + # Build chain: contracts[0] -> contracts[1] -> ... -> final_recipient + final_recipient = pre.empty_account() + contracts: list[Address] = [] + expected_logs: list[TransactionLog] = [] + + # Build contracts in reverse order (deepest first) + next_target = final_recipient + for _ in range(call_depth): + contract_code = Op.CALL( + gas=500_000, address=next_target, value=transfer_value + ) + # Each contract needs enough balance for its transfer + contract = pre.deploy_contract(contract_code, balance=transfer_value) + contracts.insert(0, contract) + next_target = contract + + # First contract is the tx target + entry_contract = contracts[0] + + # Build expected logs in chronological order + # First: tx-level transfer (sender -> entry_contract) + expected_logs.append(transfer_log(sender, entry_contract, tx_value)) + + # Then: each CALL in order + for i in range(call_depth): + from_addr = contracts[i] + to_addr = contracts[i + 1] if i + 1 < call_depth else final_recipient + expected_logs.append(transfer_log(from_addr, to_addr, transfer_value)) + + tx = Transaction( + sender=sender, + to=entry_contract, + value=tx_value, + gas_limit=1_000_000, + expected_receipt=TransactionReceipt(logs=expected_logs), + ) + + post = {final_recipient: Account(balance=transfer_value)} + state_test(env=env, pre=pre, post=post, tx=tx) + + +@pytest.mark.parametrize( + "reverting_code", + [ + pytest.param(Op.REVERT(0, 0), id="revert"), + pytest.param(Op.INVALID, id="invalid_opcode"), + pytest.param(Op.ADD, id="stack_underflow"), + pytest.param(Op.MSTORE(2**256 - 1, 0), id="out_of_gas"), + ], +) +def test_reverted_transaction_no_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + reverting_code: Bytecode, +) -> None: + """Test that a failed transaction does NOT emit a transfer log.""" + contract = pre.deploy_contract(reverting_code) + + tx = Transaction( + sender=sender, + to=contract, + value=1000, + gas_limit=100_000, + expected_receipt=TransactionReceipt(logs=[]), + ) + + state_test(env=env, pre=pre, post={}, tx=tx) + + +@pytest.mark.parametrize( + "address_type", + [ + pytest.param("ecrecover", id="precompile_ecrecover"), + pytest.param("sha256", id="precompile_sha256"), + pytest.param("system", id="system_address"), + pytest.param("coinbase", id="coinbase_address"), + ], +) +def test_transfer_to_special_address( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + address_type: str, +) -> None: + """Test that transfers to special addresses emit transfer logs.""" + transfer_amount = 1000 + + # Resolve target address based on type + # Note: blake2f (0x09) excluded as it requires specific input format + address_map = { + "ecrecover": Address(0x01), + "sha256": Address(0x02), + "system": Spec.SYSTEM_ADDRESS, + } + + if address_type == "coinbase": + target = env.fee_recipient + # Don't check exact balance - coinbase also receives gas fees + post = {} + else: + target = address_map[address_type] + post = {target: Account(balance=transfer_amount)} + + tx = Transaction( + sender=sender, + to=target, + value=transfer_amount, + gas_limit=100_000, + expected_receipt=TransactionReceipt( + logs=[transfer_log(sender, target, transfer_amount)] + ), + ) + + state_test(env=env, pre=pre, post=post, tx=tx) + + +@pytest.mark.with_all_typed_transactions +def test_transfer_with_all_tx_types( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + typed_transaction: Transaction, +) -> None: + """Test that ETH transfers emit logs for all transaction types.""" + recipient = pre.empty_account() + transfer_amount = 1000 + + tx = typed_transaction.copy( + to=recipient, + value=transfer_amount, + expected_receipt=TransactionReceipt( + logs=[transfer_log(sender, recipient, transfer_amount)] + ), + ) + + post = {recipient: Account(balance=transfer_amount)} + state_test(env=env, pre=pre, post=post, tx=tx) From 4ccec9a6cb70e741726bb27009fa246a964899e9 Mon Sep 17 00:00:00 2001 From: carsons-eels Date: Wed, 21 Jan 2026 21:23:44 -0500 Subject: [PATCH 04/24] fix(spec-specs): Refactor topic strings to match EIP --- src/ethereum/forks/amsterdam/vm/__init__.py | 2 +- tests/amsterdam/eip7708_eth_transfer_logs/spec.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index 8f06d30a68b..21f131c3e02 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -32,7 +32,7 @@ __all__ = ("Environment", "Evm", "Message") TRANSFER_TOPIC = keccak256(b"Transfer(address,address,uint256)") -SELFDESTRUCT_TOPIC = keccak256(b"Selfdestruct(address, uint256)") +SELFDESTRUCT_TOPIC = keccak256(b"Selfdestruct(address,uint256)") SYSTEM_ADDRESS = Address( bytes.fromhex("fffffffffffffffffffffffffffffffffffffffe") ) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/spec.py b/tests/amsterdam/eip7708_eth_transfer_logs/spec.py index 984605a567e..a1fe4412a7d 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/spec.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/spec.py @@ -29,8 +29,8 @@ class Spec: 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFE ) TRANSFER_TOPIC: Hash = Hash( - keccak256(b"Transfer(address, address, uint256)") + keccak256(b"Transfer(address,address,uint256)") ) SELFDESTRUCT_TOPIC: Hash = Hash( - keccak256(b"Selfdestruct(address, uint256)") + keccak256(b"Selfdestruct(address,uint256)") ) From d5f902c738ea793162815108db703daf736cb0ff Mon Sep 17 00:00:00 2001 From: carsons-eels Date: Thu, 22 Jan 2026 00:19:28 -0500 Subject: [PATCH 05/24] fix(spec-tests): formatting fixes so static checks pass --- src/ethereum/forks/amsterdam/vm/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index 21f131c3e02..ffd5e52baff 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -219,8 +219,8 @@ def emit_transfer_log( The state of the ethereum virtual machine sender : The account address sending the transfer - recipient :ce to finalize sco - The address of the transfer recipient account + recipient : + The account address recieving the transfer transfer_amount : The amount of ETH transacted From cb61106253c4b2b94cce2c8f9674f66a85f38b3a Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 22 Jan 2026 20:20:37 -0500 Subject: [PATCH 06/24] fix(spec-specs): Move account closure log emission before priority fee charges (#2059) * fix(spec-specs): Move account closure log emission before priority fee charges * fix(spec-specs): formatting and spelling tweaks * fix(spec-specs): remove duplicate WriteInStaticContext check * refactor(spec-specs): align memory expansion with other opcodes * fix(testing/test): Fix unit test expectation * refactor(spec-specs): move post-mining coinbase balance calculation --------- Co-authored-by: Mario Vega --- src/ethereum/forks/amsterdam/fork.py | 32 +++++++++---------- src/ethereum/forks/amsterdam/vm/__init__.py | 2 +- .../forks/amsterdam/vm/instructions/system.py | 4 +-- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 94ccbf46d85..6b6be36d4b9 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -1054,22 +1054,6 @@ def process_transaction( ) set_account_balance(tx_state, sender, sender_balance_after_refund) - coinbase_balance_after_mining_fee = get_account( - tx_state, block_env.coinbase - ).balance + U256(transaction_fee) - - set_account_balance( - tx_state, block_env.coinbase, coinbase_balance_after_mining_fee - ) - - if coinbase_balance_after_mining_fee == 0 and account_exists_and_is_empty( - tx_state, block_env.coinbase - ): - destroy_account(tx_state, block_env.coinbase) - - block_output.block_gas_used += tx_gas_used_after_refund - block_output.blob_gas_used += tx_blob_gas_used - # EIP-7708: Emit selfdestruct logs for remaining balance at finalization. # This handles the case where a contract receives ETH after being flagged # for SELFDESTRUCT but before finalization. @@ -1091,6 +1075,22 @@ def process_transaction( all_logs = tx_output.logs + tuple(finalization_logs) + coinbase_balance_after_mining_fee = get_account( + tx_state, block_env.coinbase + ).balance + U256(transaction_fee) + + set_account_balance( + tx_state, block_env.coinbase, coinbase_balance_after_mining_fee + ) + + if coinbase_balance_after_mining_fee == 0 and account_exists_and_is_empty( + tx_state, block_env.coinbase + ): + destroy_account(tx_state, block_env.coinbase) + + block_output.block_gas_used += tx_gas_used_after_refund + block_output.blob_gas_used += tx_blob_gas_used + receipt = make_receipt( tx, tx_output.error, block_output.block_gas_used, all_logs ) diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index ffd5e52baff..aac70362e66 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -220,7 +220,7 @@ def emit_transfer_log( sender : The account address sending the transfer recipient : - The account address recieving the transfer + The account address receiving the transfer transfer_amount : The amount of ETH transacted diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index e37013ea6f2..aa2a5b9905c 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -430,11 +430,9 @@ def call(evm: Evm) -> None: extra_gas, ) charge_gas(evm, message_call_gas.cost + extend_memory.cost) - if evm.message.is_static and value != U256(0): - raise WriteInStaticContext - evm.memory += b"\x00" * extend_memory.expand_by # OPERATION + evm.memory += b"\x00" * extend_memory.expand_by sender_balance = get_account(tx_state, evm.message.current_target).balance if sender_balance < value: push(evm.stack, U256(0)) From c962c0e75cb53af814d964ae6e96d8cb65aadd5e Mon Sep 17 00:00:00 2001 From: spencer Date: Fri, 23 Jan 2026 10:56:30 +0000 Subject: [PATCH 07/24] feat(test-specs): add extra eip-7708 test coverage (#2062) * feat(test-specs): add/refactor tests, add mainnet marked, checklist, coverage check * feat(test-specs): add fork transition test for selfdestruct logs * fix: tests * chore(test-specs): fix fork transition tests * test(test-specs): add code deposit oog test case --------- Co-authored-by: carsons-eels Co-authored-by: Mario Vega --- .../eip_checklist_external_coverage.txt | 3 + .../eip_checklist_not_applicable.txt | 14 + .../eip7708_eth_transfer_logs/spec.py | 29 +- .../test_eip_mainnet.py | 96 ++ .../test_eth_transfer_logs.py | 480 ------- .../test_fork_transition.py | 151 ++ .../test_selfdestruct_logs.py | 55 + .../test_transfer_logs.py | 1235 +++++++++++++++++ ...blob_reserve_price_with_bpo_transitions.py | 4 +- 9 files changed, 1585 insertions(+), 482 deletions(-) create mode 100644 tests/amsterdam/eip7708_eth_transfer_logs/eip_checklist_external_coverage.txt create mode 100644 tests/amsterdam/eip7708_eth_transfer_logs/eip_checklist_not_applicable.txt create mode 100644 tests/amsterdam/eip7708_eth_transfer_logs/test_eip_mainnet.py delete mode 100644 tests/amsterdam/eip7708_eth_transfer_logs/test_eth_transfer_logs.py create mode 100644 tests/amsterdam/eip7708_eth_transfer_logs/test_fork_transition.py create mode 100644 tests/amsterdam/eip7708_eth_transfer_logs/test_selfdestruct_logs.py create mode 100644 tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/eip_checklist_external_coverage.txt b/tests/amsterdam/eip7708_eth_transfer_logs/eip_checklist_external_coverage.txt new file mode 100644 index 00000000000..bba1c4cfb18 --- /dev/null +++ b/tests/amsterdam/eip7708_eth_transfer_logs/eip_checklist_external_coverage.txt @@ -0,0 +1,3 @@ +general/code_coverage/eels = EIP-7708 specific code (emit_transfer_log in vm/__init__.py) has full coverage; check codecov for src/ethereum/forks/amsterdam/vm/__init__.py and src/ethereum/forks/amsterdam/vm/interpreter.py +general/code_coverage/test_coverage = Run with `--cov` flag; 99% coverage on vm/__init__.py, 92% on interpreter.py +general/code_coverage/missed_lines = Missed lines are general VM infrastructure (static context errors, gas accounting, error handling) not related to EIP-7708 diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/eip_checklist_not_applicable.txt b/tests/amsterdam/eip7708_eth_transfer_logs/eip_checklist_not_applicable.txt new file mode 100644 index 00000000000..48ce20d19f8 --- /dev/null +++ b/tests/amsterdam/eip7708_eth_transfer_logs/eip_checklist_not_applicable.txt @@ -0,0 +1,14 @@ +opcode = EIP does not introduce a new opcode +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 introduce any gas cost changes +gas_refunds_changes = EIP does not introduce any gas refund changes +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 introduce a modified transaction validity constraint +block_level_constraint = EIP does not introduce a block-level validation constraint diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/spec.py b/tests/amsterdam/eip7708_eth_transfer_logs/spec.py index a1fe4412a7d..a4c21a60ae2 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/spec.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/spec.py @@ -2,7 +2,7 @@ from dataclasses import dataclass -from execution_testing import Address, Hash, keccak256 +from execution_testing import Address, Bytes, Hash, TransactionLog, keccak256 @dataclass(frozen=True) @@ -34,3 +34,30 @@ class Spec: SELFDESTRUCT_TOPIC: Hash = Hash( keccak256(b"Selfdestruct(address,uint256)") ) + + +def transfer_log( + sender: Address, recipient: Address, amount: int +) -> TransactionLog: + """Create an expected Transfer log for EIP-7708.""" + return TransactionLog( + address=Spec.SYSTEM_ADDRESS, + topics=[ + Spec.TRANSFER_TOPIC, + Hash(bytes(sender).rjust(32, b"\x00")), + Hash(bytes(recipient).rjust(32, b"\x00")), + ], + data=Bytes(amount.to_bytes(32, "big")), + ) + + +def selfdestruct_log(contract_address: Address, amount: int) -> TransactionLog: + """Create an expected Selfdestruct log for EIP-7708.""" + return TransactionLog( + address=Spec.SYSTEM_ADDRESS, + topics=[ + Spec.SELFDESTRUCT_TOPIC, + Hash(bytes(contract_address).rjust(32, b"\x00")), + ], + data=Bytes(amount.to_bytes(32, "big")), + ) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_eip_mainnet.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_eip_mainnet.py new file mode 100644 index 00000000000..171500f5d2c --- /dev/null +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_eip_mainnet.py @@ -0,0 +1,96 @@ +""" +Mainnet marked execute checklist tests for +[EIP-7708: ETH transfers emit a log](https://eips.ethereum.org/EIPS/eip-7708). +""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Op, + StateTestFiller, + Transaction, + TransactionReceipt, +) + +from .spec import ref_spec_7708, transfer_log + +REFERENCE_SPEC_GIT_PATH = ref_spec_7708.git_path +REFERENCE_SPEC_VERSION = ref_spec_7708.version + +pytestmark = [pytest.mark.valid_at("Amsterdam"), pytest.mark.mainnet] + + +def test_simple_transfer_mainnet( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """Test that a simple ETH transfer emits a transfer log on mainnet.""" + sender = pre.fund_eoa() + recipient = pre.empty_account() + + tx = Transaction( + ty=0x02, + sender=sender, + to=recipient, + value=1, + gas_limit=21_000, + expected_receipt=TransactionReceipt( + logs=[transfer_log(sender, recipient, 1)] + ), + ) + + post = {recipient: Account(balance=1)} + state_test(pre=pre, post=post, tx=tx) + + +def test_call_with_value_mainnet( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """Test that CALL with value emits a transfer log on mainnet.""" + sender = pre.fund_eoa() + recipient = pre.deploy_contract(Op.STOP) + + contract_code = Op.CALL(gas=50_000, address=recipient, value=100) + contract = pre.deploy_contract(contract_code, balance=100) + + tx = Transaction( + ty=0x02, + sender=sender, + to=contract, + value=0, + gas_limit=100_000, + expected_receipt=TransactionReceipt( + logs=[transfer_log(contract, recipient, 100)] + ), + ) + + post = {recipient: Account(balance=100)} + state_test(pre=pre, post=post, tx=tx) + + +def test_selfdestruct_mainnet( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """Test that SELFDESTRUCT emits a transfer log on mainnet.""" + sender = pre.fund_eoa() + beneficiary = pre.empty_account() + + contract_code = Op.SELFDESTRUCT(beneficiary) + contract = pre.deploy_contract(contract_code, balance=500) + + tx = Transaction( + ty=0x02, + sender=sender, + to=contract, + value=0, + gas_limit=100_000, + expected_receipt=TransactionReceipt( + logs=[transfer_log(contract, beneficiary, 500)] + ), + ) + + post = {beneficiary: Account(balance=500)} + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_eth_transfer_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_eth_transfer_logs.py deleted file mode 100644 index 9afddddc167..00000000000 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_eth_transfer_logs.py +++ /dev/null @@ -1,480 +0,0 @@ -""" -Tests [EIP-7708: ETH Transfers Emit a Log](https://eips.ethereum.org/EIPS/eip-7708). - -Tests for verifying that ETH transfers emit LOG3 events as specified. -""" - -import pytest -from execution_testing import ( - EOA, - Account, - Address, - Alloc, - Bytecode, - Bytes, - Environment, - Hash, - Op, - StateTestFiller, - Transaction, - TransactionLog, - TransactionReceipt, - compute_create_address, -) - -from .spec import Spec, ref_spec_7708 - -REFERENCE_SPEC_GIT_PATH = ref_spec_7708.git_path -REFERENCE_SPEC_VERSION = ref_spec_7708.version - -pytestmark = pytest.mark.valid_from("Amsterdam") - - -def transfer_log( - sender: Address, recipient: Address, amount: int -) -> TransactionLog: - """Create an expected transfer log.""" - return TransactionLog( - address=Spec.SYSTEM_ADDRESS, - topics=[ - Spec.TRANSFER_TOPIC, - Hash(bytes(sender).rjust(32, b"\x00")), - Hash(bytes(recipient).rjust(32, b"\x00")), - ], - data=Bytes(amount.to_bytes(32, "big")), - ) - - -def test_simple_transfer_emits_log( - state_test: StateTestFiller, - env: Environment, - pre: Alloc, - sender: EOA, -) -> None: - """Test that a simple ETH transfer emits a transfer log.""" - recipient = pre.empty_account() - transfer_amount = 1000 - - tx = Transaction( - sender=sender, - to=recipient, - value=transfer_amount, - gas_limit=21_000, - expected_receipt=TransactionReceipt( - logs=[transfer_log(sender, recipient, transfer_amount)] - ), - ) - - post = {recipient: Account(balance=transfer_amount)} - state_test(env=env, pre=pre, post=post, tx=tx) - - -def test_zero_value_transfer_no_log( - state_test: StateTestFiller, - env: Environment, - pre: Alloc, - sender: EOA, -) -> None: - """Test that a zero-value transfer does NOT emit a transfer log.""" - recipient = pre.empty_account() - - tx = Transaction( - sender=sender, - to=recipient, - value=0, - gas_limit=21_000, - expected_receipt=TransactionReceipt(logs=[]), - ) - - state_test(env=env, pre=pre, post={}, tx=tx) - - -def test_call_with_value_emits_log( - state_test: StateTestFiller, - env: Environment, - pre: Alloc, - sender: EOA, -) -> None: - """Test that CALL with value emits a transfer log.""" - recipient = pre.empty_account() - transfer_amount = 500 - tx_transfer_amount = 1000 - - contract_code = Op.CALL( - gas=100_000, - address=recipient, - value=transfer_amount, - ) - contract = pre.deploy_contract(contract_code, balance=tx_transfer_amount) - - tx = Transaction( - sender=sender, - to=contract, - value=tx_transfer_amount, - gas_limit=100_000, - expected_receipt=TransactionReceipt( - logs=[ - transfer_log(sender, contract, tx_transfer_amount), - transfer_log(contract, recipient, transfer_amount), - ] - ), - ) - - post = {recipient: Account(balance=transfer_amount)} - state_test(env=env, pre=pre, post=post, tx=tx) - - -def test_selfdestruct_with_value_emits_log( - state_test: StateTestFiller, - env: Environment, - pre: Alloc, - sender: EOA, -) -> None: - """Test that SELFDESTRUCT with value emits a transfer log.""" - beneficiary = pre.empty_account() - contract_balance = 2000 - - contract_code = Op.SELFDESTRUCT(beneficiary) - contract = pre.deploy_contract(contract_code, balance=contract_balance) - - tx = Transaction( - sender=sender, - to=contract, - value=0, - gas_limit=100_000, - expected_receipt=TransactionReceipt( - logs=[transfer_log(contract, beneficiary, contract_balance)] - ), - ) - - post = {beneficiary: Account(balance=contract_balance)} - state_test(env=env, pre=pre, post=post, tx=tx) - - -def selfdestruct_log(contract: Address, amount: int) -> TransactionLog: - """Create an expected selfdestruct log (for selfdestruct to self).""" - return TransactionLog( - address=Spec.SYSTEM_ADDRESS, - topics=[ - Spec.SELFDESTRUCT_TOPIC, - Hash(bytes(contract).rjust(32, b"\x00")), - ], - data=Bytes(amount.to_bytes(32, "big")), - ) - - -def test_selfdestruct_to_self_emits_finalization_log( - state_test: StateTestFiller, - env: Environment, - pre: Alloc, - sender: EOA, -) -> None: - """ - Test that selfdestruct-to-self emits a finalization log for remaining ETH. - - Scenario: - 1. Factory creates child contract via CREATE with 1000 wei - 2. Factory calls child, child selfdestructs to itself (emits log for 1000) - 3. Factory sends 500 more wei to child (transfer log emitted) - 4. At finalization, child has 500 wei remaining (emits finalization log) - - This tests the EIP-7708 requirement that when a contract receives ETH after - being flagged for SELFDESTRUCT, a Selfdestruct log is emitted at - finalization for the remaining balance. - """ - tx_value = 2000 - child_init_balance = 1000 - additional_eth = 500 - - # Child contract: selfdestructs to itself (address(this)) - child_code = Op.SELFDESTRUCT(Op.ADDRESS) - child_initcode = Op.MSTORE( - 0, Op.PUSH32(bytes(child_code).rjust(32, b"\x00")) - ) + Op.RETURN(32 - len(child_code), len(child_code)) - - initcode_len = len(child_initcode) - - # Factory: CREATE child, CALL to trigger selfdestruct, CALL again with ETH - factory_code = ( - Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) - + Op.SSTORE(1, Op.CREATE(child_init_balance, 0, initcode_len)) - + Op.CALL(address=Op.SLOAD(1), value=0, gas=50_000) - + Op.POP - + Op.CALL(address=Op.SLOAD(1), value=additional_eth, gas=50_000) - + Op.POP - ) - - factory = pre.deploy_contract( - factory_code, balance=child_init_balance + additional_eth - ) - child_addr = compute_create_address(address=factory, nonce=1) - - # Expected logs: - # 1. Transfer: sender -> factory (tx value) - # 2. Transfer: factory -> child (CREATE value) - # 3. Selfdestruct: child (initial balance at execution) - # 4. Transfer: factory -> child (additional ETH) - # 5. Selfdestruct: child (remaining balance at finalization) - expected_logs = [ - transfer_log(sender, factory, tx_value), - transfer_log(factory, child_addr, child_init_balance), - selfdestruct_log(child_addr, child_init_balance), - transfer_log(factory, child_addr, additional_eth), - selfdestruct_log(child_addr, additional_eth), - ] - - tx = Transaction( - sender=sender, - to=factory, - value=tx_value, - gas_limit=500_000, - data=bytes(child_initcode), - expected_receipt=TransactionReceipt(logs=expected_logs), - ) - - state_test(env=env, pre=pre, post={}, tx=tx) - - -@pytest.mark.parametrize( - "op_type", - [ - pytest.param("call", id="call"), - pytest.param("selfdestruct", id="selfdestruct"), - ], -) -def test_zero_value_operations_no_log( - state_test: StateTestFiller, - env: Environment, - pre: Alloc, - sender: EOA, - op_type: str, -) -> None: - """Test that zero-value operations do NOT emit transfer logs.""" - target = pre.empty_account() - - if op_type == "call": - contract_code = Op.CALL(gas=100_000, address=target, value=0) - else: - contract_code = Op.SELFDESTRUCT(target) - - contract = pre.deploy_contract(contract_code, balance=0) - - tx = Transaction( - sender=sender, - to=contract, - value=0, - gas_limit=100_000, - expected_receipt=TransactionReceipt(logs=[]), - ) - - state_test(env=env, pre=pre, post={}, tx=tx) - - -@pytest.mark.parametrize( - "recipient_code,call_gas,call_value,recipient_balance", - [ - pytest.param(Op.REVERT(0, 0), 50_000, 500, 0, id="call_reverted"), - pytest.param(Op.JUMP(0), 100, 500, 0, id="call_out_of_gas"), - pytest.param( - Op.SELFDESTRUCT(Address(0x1234)), - 100, - 0, - 2000, - id="selfdestruct_out_of_gas", - ), - ], -) -def test_failed_inner_operation_no_log( - state_test: StateTestFiller, - env: Environment, - pre: Alloc, - sender: EOA, - recipient_code: Bytecode, - call_gas: int, - call_value: int, - recipient_balance: int, -) -> None: - """Test that failed inner operations do NOT emit transfer logs.""" - recipient = pre.deploy_contract(recipient_code, balance=recipient_balance) - tx_value = 1000 - - contract_code = Op.CALL( - gas=call_gas, - address=recipient, - value=call_value, - ) - contract = pre.deploy_contract(contract_code, balance=call_value) - - tx = Transaction( - sender=sender, - to=contract, - value=tx_value, - gas_limit=100_000, - expected_receipt=TransactionReceipt( - logs=[transfer_log(sender, contract, tx_value)] - ), - ) - - state_test(env=env, pre=pre, post={}, tx=tx) - - -@pytest.mark.parametrize( - "call_depth", - [ - pytest.param(2, id="depth_2"), - pytest.param(3, id="depth_3"), - pytest.param(10, id="depth_10"), - ], -) -def test_nested_calls_log_order( - state_test: StateTestFiller, - env: Environment, - pre: Alloc, - sender: EOA, - call_depth: int, -) -> None: - """Test that nested CALLs emit transfer logs in chronological order.""" - transfer_value = 100 - tx_value = 1000 - - # Build chain: contracts[0] -> contracts[1] -> ... -> final_recipient - final_recipient = pre.empty_account() - contracts: list[Address] = [] - expected_logs: list[TransactionLog] = [] - - # Build contracts in reverse order (deepest first) - next_target = final_recipient - for _ in range(call_depth): - contract_code = Op.CALL( - gas=500_000, address=next_target, value=transfer_value - ) - # Each contract needs enough balance for its transfer - contract = pre.deploy_contract(contract_code, balance=transfer_value) - contracts.insert(0, contract) - next_target = contract - - # First contract is the tx target - entry_contract = contracts[0] - - # Build expected logs in chronological order - # First: tx-level transfer (sender -> entry_contract) - expected_logs.append(transfer_log(sender, entry_contract, tx_value)) - - # Then: each CALL in order - for i in range(call_depth): - from_addr = contracts[i] - to_addr = contracts[i + 1] if i + 1 < call_depth else final_recipient - expected_logs.append(transfer_log(from_addr, to_addr, transfer_value)) - - tx = Transaction( - sender=sender, - to=entry_contract, - value=tx_value, - gas_limit=1_000_000, - expected_receipt=TransactionReceipt(logs=expected_logs), - ) - - post = {final_recipient: Account(balance=transfer_value)} - state_test(env=env, pre=pre, post=post, tx=tx) - - -@pytest.mark.parametrize( - "reverting_code", - [ - pytest.param(Op.REVERT(0, 0), id="revert"), - pytest.param(Op.INVALID, id="invalid_opcode"), - pytest.param(Op.ADD, id="stack_underflow"), - pytest.param(Op.MSTORE(2**256 - 1, 0), id="out_of_gas"), - ], -) -def test_reverted_transaction_no_log( - state_test: StateTestFiller, - env: Environment, - pre: Alloc, - sender: EOA, - reverting_code: Bytecode, -) -> None: - """Test that a failed transaction does NOT emit a transfer log.""" - contract = pre.deploy_contract(reverting_code) - - tx = Transaction( - sender=sender, - to=contract, - value=1000, - gas_limit=100_000, - expected_receipt=TransactionReceipt(logs=[]), - ) - - state_test(env=env, pre=pre, post={}, tx=tx) - - -@pytest.mark.parametrize( - "address_type", - [ - pytest.param("ecrecover", id="precompile_ecrecover"), - pytest.param("sha256", id="precompile_sha256"), - pytest.param("system", id="system_address"), - pytest.param("coinbase", id="coinbase_address"), - ], -) -def test_transfer_to_special_address( - state_test: StateTestFiller, - env: Environment, - pre: Alloc, - sender: EOA, - address_type: str, -) -> None: - """Test that transfers to special addresses emit transfer logs.""" - transfer_amount = 1000 - - # Resolve target address based on type - # Note: blake2f (0x09) excluded as it requires specific input format - address_map = { - "ecrecover": Address(0x01), - "sha256": Address(0x02), - "system": Spec.SYSTEM_ADDRESS, - } - - if address_type == "coinbase": - target = env.fee_recipient - # Don't check exact balance - coinbase also receives gas fees - post = {} - else: - target = address_map[address_type] - post = {target: Account(balance=transfer_amount)} - - tx = Transaction( - sender=sender, - to=target, - value=transfer_amount, - gas_limit=100_000, - expected_receipt=TransactionReceipt( - logs=[transfer_log(sender, target, transfer_amount)] - ), - ) - - state_test(env=env, pre=pre, post=post, tx=tx) - - -@pytest.mark.with_all_typed_transactions -def test_transfer_with_all_tx_types( - state_test: StateTestFiller, - env: Environment, - pre: Alloc, - sender: EOA, - typed_transaction: Transaction, -) -> None: - """Test that ETH transfers emit logs for all transaction types.""" - recipient = pre.empty_account() - transfer_amount = 1000 - - tx = typed_transaction.copy( - to=recipient, - value=transfer_amount, - expected_receipt=TransactionReceipt( - logs=[transfer_log(sender, recipient, transfer_amount)] - ), - ) - - post = {recipient: Account(balance=transfer_amount)} - state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_fork_transition.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_fork_transition.py new file mode 100644 index 00000000000..90582e1b13b --- /dev/null +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_fork_transition.py @@ -0,0 +1,151 @@ +""" +Tests for EIP-7708 fork transition behavior. + +Tests that verify transfer logs are emitted correctly at the Amsterdam fork +transition boundary. +""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Op, + Transaction, + TransactionReceipt, +) + +from .spec import ref_spec_7708, selfdestruct_log, transfer_log + +REFERENCE_SPEC_GIT_PATH = ref_spec_7708.git_path +REFERENCE_SPEC_VERSION = ref_spec_7708.version + + +@pytest.mark.valid_at_transition_to("Amsterdam") +def test_selfdestruct_log_at_fork_transition( + blockchain_test: BlockchainTestFiller, pre: Alloc +) -> None: + """ + Test ETH selfdestruct log behavior at fork transition. + + Before Amsterdam: ETH selfdestructs do NOT emit logs. + At/after Amsterdam: ETH selfdestructs emit Selfdestruct logs. + """ + sender = pre.fund_eoa() + contract1 = pre.deploy_contract(Op.SELFDESTRUCT(Op.ADDRESS), balance=1) + contract2 = pre.deploy_contract(Op.SELFDESTRUCT(Op.ADDRESS), balance=2) + contract3 = pre.deploy_contract(Op.SELFDESTRUCT(Op.ADDRESS), balance=3) + + blocks = [ + Block( + timestamp=14_999, + txs=[ + Transaction( + to=contract1, + sender=sender, + gas_limit=100_000, + expected_receipt=TransactionReceipt(logs=[]), + ) + ], + ), + Block( + timestamp=15_000, + txs=[ + Transaction( + to=contract2, + sender=sender, + gas_limit=100_000, + expected_receipt=TransactionReceipt( + logs=[selfdestruct_log(contract2, 2)] + ), + ) + ], + ), + Block( + timestamp=15_001, + txs=[ + Transaction( + to=contract3, + sender=sender, + gas_limit=100_000, + expected_receipt=TransactionReceipt( + logs=[selfdestruct_log(contract3, 3)] + ), + ) + ], + ), + ] + + blockchain_test( + pre=pre, + blocks=blocks, + post={ + sender: Account(nonce=3), + }, + ) + + +@pytest.mark.valid_at_transition_to("Amsterdam") +def test_transfer_log_fork_transition( + blockchain_test: BlockchainTestFiller, pre: Alloc +) -> None: + """ + Test ETH transfer log behavior at fork transition. + + Before Amsterdam: ETH transfers do NOT emit logs. + At/after Amsterdam: ETH transfers emit Transfer logs. + """ + sender = pre.fund_eoa() + recipient = pre.empty_account() + + blocks = [ + Block( + timestamp=14_999, + txs=[ + Transaction( + to=recipient, + sender=sender, + value=100, + gas_limit=21_000, + expected_receipt=TransactionReceipt(logs=[]), + ) + ], + ), + Block( + timestamp=15_000, + txs=[ + Transaction( + to=recipient, + sender=sender, + value=100, + gas_limit=21_000, + expected_receipt=TransactionReceipt( + logs=[transfer_log(sender, recipient, 100)] + ), + ) + ], + ), + Block( + timestamp=15_001, + txs=[ + Transaction( + to=recipient, + sender=sender, + value=100, + gas_limit=21_000, + expected_receipt=TransactionReceipt( + logs=[transfer_log(sender, recipient, 100)] + ), + ) + ], + ), + ] + + blockchain_test( + pre=pre, + blocks=blocks, + post={ + recipient: Account(balance=300), + }, + ) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_selfdestruct_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_selfdestruct_logs.py new file mode 100644 index 00000000000..ad24b920977 --- /dev/null +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_selfdestruct_logs.py @@ -0,0 +1,55 @@ +""" +Tests for EIP-7708 Selfdestruct logs. + +Tests for the Selfdestruct(address,uint256) log emitted when: +- SELFDESTRUCT to self with nonzero balance +- Account closure after SELFDESTRUCT +""" + +import pytest +from execution_testing import ( + EOA, + Alloc, + Environment, + Op, + StateTestFiller, + Transaction, + TransactionReceipt, +) + +from .spec import ref_spec_7708, selfdestruct_log + +REFERENCE_SPEC_GIT_PATH = ref_spec_7708.git_path +REFERENCE_SPEC_VERSION = ref_spec_7708.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + + +def test_selfdestruct_to_self_emits_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, +) -> None: + """ + Test that selfdestruct-to-self emits a Selfdestruct log. + + Since the contract selfdestructs to itself, there is no transfer. + Instead, a Selfdestruct log is emitted with the contract's balance. + """ + contract_balance = 2000 + + contract_code = Op.SELFDESTRUCT(Op.ADDRESS) + contract = pre.deploy_contract(contract_code, balance=contract_balance) + + tx = Transaction( + sender=sender, + to=contract, + value=0, + gas_limit=100_000, + expected_receipt=TransactionReceipt( + logs=[selfdestruct_log(contract, contract_balance)] + ), + ) + + state_test(env=env, pre=pre, post={}, tx=tx) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py new file mode 100644 index 00000000000..0c58ac037ed --- /dev/null +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py @@ -0,0 +1,1235 @@ +""" +Tests for EIP-7708 Transfer logs. + +Tests for the Transfer(address,address,uint256) log emitted when: +- Nonzero-value-transferring transaction +- Nonzero-value-transferring CALL +- Nonzero-value-transferring SELFDESTRUCT to a different account +""" + +import pytest +from execution_testing import ( + EOA, + Account, + Address, + Alloc, + Block, + BlockchainTestFiller, + Bytecode, + Bytes, + Environment, + Op, + StateTestFiller, + Transaction, + TransactionLog, + TransactionReceipt, + compute_create2_address, + compute_create_address, +) + +from .spec import Spec, ref_spec_7708, transfer_log + +REFERENCE_SPEC_GIT_PATH = ref_spec_7708.git_path +REFERENCE_SPEC_VERSION = ref_spec_7708.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + + +def test_simple_transfer_emits_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, +) -> None: + """Test that a simple ETH transfer emits a transfer log.""" + recipient = pre.empty_account() + + tx = Transaction( + sender=sender, + to=recipient, + value=1, + gas_limit=21_000, + expected_receipt=TransactionReceipt( + logs=[transfer_log(sender, recipient, 1)] + ), + ) + + post = {recipient: Account(balance=1)} + state_test(env=env, pre=pre, post=post, tx=tx) + + +def test_transfer_to_delegated_account_emits_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, +) -> None: + """ + Test that transfer to EIP-7702 delegated account emits correct log. + + The transfer log should show the EOA address as recipient, + not the delegation target address. + """ + delegation_target = pre.deploy_contract(code=Op.STOP) + recipient = pre.fund_eoa(amount=0, delegation=delegation_target) + + tx = Transaction( + sender=sender, + to=recipient, + value=1, + gas_limit=100_000, + expected_receipt=TransactionReceipt( + logs=[transfer_log(sender, recipient, 1)] + ), + ) + + post = {recipient: Account(balance=1)} + state_test(env=env, pre=pre, post=post, tx=tx) + + +def test_zero_value_transfer_no_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, +) -> None: + """Test that a zero-value transfer does NOT emit a transfer log.""" + recipient = pre.empty_account() + + tx = Transaction( + sender=sender, + to=recipient, + value=0, + gas_limit=21_000, + expected_receipt=TransactionReceipt(logs=[]), + ) + + state_test(env=env, pre=pre, post={}, tx=tx) + + +@pytest.mark.parametrize( + "tx_value,expect_log", + [ + pytest.param(1000, True, id="with_value"), + pytest.param(0, False, id="zero_value"), + ], +) +def test_contract_creation_tx( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + tx_value: int, + expect_log: bool, +) -> None: + """Test that contract creation transactions emit logs based on value.""" + initcode = Op.RETURN(0, 0) + created_address = compute_create_address(address=sender, nonce=0) + + expected_logs = ( + [transfer_log(sender, created_address, tx_value)] if expect_log else [] + ) + + tx = Transaction( + sender=sender, + to=None, + value=tx_value, + gas_limit=100_000, + data=bytes(initcode), + expected_receipt=TransactionReceipt(logs=expected_logs), + ) + + post = {created_address: Account(balance=tx_value)} if tx_value > 0 else {} + state_test(env=env, pre=pre, post=post, tx=tx) + + +@pytest.mark.with_all_call_opcodes +def test_call_opcodes_transfer_log_behavior( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + call_opcode: Op, +) -> None: + """ + Test ETH transfer log behavior across all call opcode contexts. + + - CALL with value: emits log (caller -> callee) + - CALLCODE with value: emits log (caller -> caller) as self-transfer + - DELEGATECALL: no value parameter, no transfer log + - STATICCALL: no value parameter, no transfer log + """ + callee = pre.deploy_contract(Op.STOP) + + # Build the call based on opcode type + if call_opcode in [Op.CALL, Op.CALLCODE]: + # These opcodes have a value parameter + call_code = call_opcode(gas=100_000, address=callee, value=1) + else: + # DELEGATECALL and STATICCALL don't have value parameter + call_code = call_opcode(gas=100_000, address=callee) + + contract = pre.deploy_contract(call_code, balance=1) + + # Determine expected logs based on opcode behavior + expected_logs = [transfer_log(sender, contract, 1)] + + if call_opcode == Op.CALL: + # CALL transfers value from contract to callee + expected_logs.append(transfer_log(contract, callee, 1)) + post = {callee: Account(balance=1)} + elif call_opcode == Op.CALLCODE: + # CALLCODE transfers value but stays in caller's context + # This is a self-transfer (contract -> contract) + expected_logs.append(transfer_log(contract, contract, 1)) + post = {} + else: + # DELEGATECALL and STATICCALL: no value transfer, no additional log + post = {} + + tx = Transaction( + sender=sender, + to=contract, + value=1, + gas_limit=200_000, + expected_receipt=TransactionReceipt(logs=expected_logs), + ) + + state_test(env=env, pre=pre, post=post, tx=tx) + + +def test_delegatecall_inner_call_with_value( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, +) -> None: + """ + Test DELEGATECALL to code that performs CALL with value. + + Scenario: A DELEGATECALLs B, B does CALL with value to C. + The CALL from B executes in A's context, so log shows A as sender. + """ + recipient = pre.deploy_contract(Op.STOP) + + # B: code that CALLs recipient with value + code_b = Op.CALL(gas=50_000, address=recipient, value=1) + contract_b = pre.deploy_contract(code_b) + + # A: DELEGATECALLs to B (executes B's code in A's context) + code_a = Op.DELEGATECALL(gas=100_000, address=contract_b) + contract_a = pre.deploy_contract(code_a, balance=1) + + tx = Transaction( + sender=sender, + to=contract_a, + value=0, + gas_limit=200_000, + expected_receipt=TransactionReceipt( + logs=[ + # CALL from B executes in A's context, so A is the sender + transfer_log(contract_a, recipient, 1), + ] + ), + ) + + post = {recipient: Account(balance=1)} + state_test(env=env, pre=pre, post=post, tx=tx) + + +@pytest.mark.parametrize( + "create_value", + [ + pytest.param(1, id="with_value"), + pytest.param(0, id="zero_value"), + ], +) +@pytest.mark.with_all_create_opcodes +def test_create_opcode_emits_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + create_opcode: Op, + create_value: int, +) -> None: + """Test that CREATE/CREATE2 opcodes emit logs based on value.""" + initcode = Op.RETURN(0, 0) + initcode_len = len(initcode) + + contract_code = Op.MSTORE( + 0, Op.PUSH32(bytes(initcode).rjust(32, b"\x00")) + ) + Op.SSTORE( + 0, + create_opcode( + value=create_value, offset=32 - initcode_len, size=initcode_len + ), + ) + contract = pre.deploy_contract(contract_code, balance=create_value) + created_address = compute_create_address( + address=contract, + nonce=1, + salt=0, + initcode=bytes(initcode), + opcode=create_opcode, + ) + + expected_logs = [transfer_log(sender, contract, 1)] + if create_value > 0: + expected_logs.append( + transfer_log(contract, created_address, create_value) + ) + + tx = Transaction( + sender=sender, + to=contract, + value=1, + gas_limit=200_000, + expected_receipt=TransactionReceipt(logs=expected_logs), + ) + + post = {created_address: Account(balance=create_value)} + state_test(env=env, pre=pre, post=post, tx=tx) + + +@pytest.mark.with_all_create_opcodes +def test_selfdestruct_during_initcode( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + create_opcode: Op, +) -> None: + """ + Test that SELFDESTRUCT during initcode emits transfer log. + + Contract is created with value, then immediately self-destructs to a + beneficiary during initcode execution (before deployment completes). + Expected logs: + - CREATE transfer: factory -> created_address + - SELFDESTRUCT transfer: created_address -> beneficiary + """ + beneficiary = pre.deploy_contract(Op.STOP) + + # Initcode that self-destructs to beneficiary (contract never deploys code) + initcode = Op.SELFDESTRUCT(beneficiary) + initcode_bytes = bytes(initcode) + initcode_len = len(initcode_bytes) + + if create_opcode == Op.CREATE: + factory_code = Op.MSTORE( + 0, Op.PUSH32(initcode_bytes.rjust(32, b"\x00")) + ) + Op.CREATE(value=1, offset=32 - initcode_len, size=initcode_len) + else: + factory_code = Op.MSTORE( + 0, Op.PUSH32(initcode_bytes.rjust(32, b"\x00")) + ) + Op.CREATE2( + value=1, offset=32 - initcode_len, size=initcode_len, salt=0 + ) + + factory = pre.deploy_contract(factory_code, balance=1) + + # Compute created address + if create_opcode == Op.CREATE: + created_address = compute_create_address(address=factory, nonce=1) + else: + created_address = compute_create2_address( + address=factory, salt=0, initcode=initcode_bytes + ) + + tx = Transaction( + sender=sender, + to=factory, + value=0, + gas_limit=200_000, + expected_receipt=TransactionReceipt( + logs=[ + # CREATE transfers value to new contract + transfer_log(factory, created_address, 1), + # SELFDESTRUCT transfers balance to beneficiary + transfer_log(created_address, beneficiary, 1), + ] + ), + ) + + # Beneficiary receives the balance, created contract is destroyed + post = {beneficiary: Account(balance=1)} + state_test(env=env, pre=pre, post=post, tx=tx) + + +@pytest.mark.with_all_create_opcodes +def test_initcode_calls_with_value( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + create_opcode: Op, +) -> None: + """ + Test that CALL with value during initcode emits correct log. + + Initcode performs CALL with value before returning deployed code. + Log should show the being-created contract as sender. + """ + recipient = pre.deploy_contract(Op.STOP) + + # Initcode: CALL recipient with value, then RETURN empty code + initcode = Op.CALL(gas=50_000, address=recipient, value=1) + Op.RETURN( + 0, 0 + ) + initcode_bytes = bytes(initcode) + + # Use Initcode helper or direct memory setup for longer initcode + if create_opcode == Op.CREATE: + # Store initcode in memory using code copy approach + factory_code = ( + Op.CODECOPY( + 0, + Op.SUB(Op.CODESIZE, len(initcode_bytes)), + len(initcode_bytes), + ) + + Op.CREATE(value=1, offset=0, size=len(initcode_bytes)) + + Op.STOP + ) + initcode + else: + factory_code = ( + Op.CODECOPY( + 0, + Op.SUB(Op.CODESIZE, len(initcode_bytes)), + len(initcode_bytes), + ) + + Op.CREATE2(value=1, offset=0, size=len(initcode_bytes), salt=0) + + Op.STOP + ) + initcode + + factory = pre.deploy_contract(factory_code, balance=2) + + # Compute created address + if create_opcode == Op.CREATE: + created_address = compute_create_address(address=factory, nonce=1) + else: + created_address = compute_create2_address( + address=factory, salt=0, initcode=initcode_bytes + ) + + tx = Transaction( + sender=sender, + to=factory, + value=0, + gas_limit=300_000, + expected_receipt=TransactionReceipt( + logs=[ + # CREATE transfers value to new contract + transfer_log(factory, created_address, 1), + # Initcode CALLs recipient with value + transfer_log(created_address, recipient, 1), + ] + ), + ) + + post = {recipient: Account(balance=1)} + state_test(env=env, pre=pre, post=post, tx=tx) + + +def test_create_initcode_stop_emits_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, +) -> None: + """ + Test that CREATE with initcode using STOP (no RETURN) emits transfer log. + + When initcode runs STOP instead of RETURN, the contract is created with + empty code. This is a successful CREATE, so transfer log should be emitted. + """ + contract_code = Op.MSTORE( + 0, Op.PUSH32(bytes(Op.STOP).rjust(32, b"\x00")) + ) + Op.CREATE(value=1, offset=31, size=1) + contract = pre.deploy_contract(contract_code, balance=1) + + created_address = compute_create_address(address=contract, nonce=1) + + tx = Transaction( + sender=sender, + to=contract, + value=0, + gas_limit=500_000, + expected_receipt=TransactionReceipt( + logs=[transfer_log(contract, created_address, 1)] + ), + ) + + post = {created_address: Account(balance=1, code=b"")} + state_test(env=env, pre=pre, post=post, tx=tx) + + +@pytest.mark.parametrize( + "initcode", + [ + pytest.param(Op.REVERT(0, 0), id="initcode_reverts"), + pytest.param(Op.INVALID, id="initcode_invalid"), + ], +) +def test_failed_create_with_value_no_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + initcode: Bytecode, +) -> None: + """ + Test that failed CREATE with value does NOT emit transfer log. + + When initcode fails (REVERT, INVALID), the value transfer is reverted + and no log should be emitted for the CREATE. + """ + initcode_len = len(initcode) + contract_code = Op.MSTORE( + 0, Op.PUSH32(bytes(initcode).rjust(32, b"\x00")) + ) + Op.SSTORE(0, Op.CREATE(1, 32 - initcode_len, initcode_len)) + contract = pre.deploy_contract(contract_code, balance=1) + + tx = Transaction( + sender=sender, + to=contract, + value=1, + gas_limit=500_000, + expected_receipt=TransactionReceipt( + logs=[transfer_log(sender, contract, 1)] + ), + ) + + state_test(env=env, pre=pre, post={}, tx=tx) + + +def test_create_insufficient_balance_no_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, +) -> None: + """ + Test that CREATE with insufficient balance does NOT emit transfer log. + + Contract receives 1, tries to CREATE with 1000 value - CREATE fails + (returns 0) but doesn't halt, so tx-level log remains. + """ + initcode = Op.RETURN(0, 0) + initcode_len = len(initcode) + contract_code = Op.MSTORE( + 0, Op.PUSH32(bytes(initcode).rjust(32, b"\x00")) + ) + Op.SSTORE(0, Op.CREATE(1000, 32 - initcode_len, initcode_len)) + contract = pre.deploy_contract(contract_code, balance=0) + + tx = Transaction( + sender=sender, + to=contract, + value=1, + gas_limit=500_000, + expected_receipt=TransactionReceipt( + logs=[transfer_log(sender, contract, 1)] + ), + ) + + state_test(env=env, pre=pre, post={}, tx=tx) + + +@pytest.mark.parametrize( + "initcode", + [ + pytest.param( + # OOG before return + Op.MSTORE(offset=0xFFFFFF, value=0) + Op.RETURN(0, 0), + id="create_out_of_gas_memory_expansion", + ), + pytest.param( + # Invalid opcode + Op.INVALID + Op.RETURN(0, 0), + id="invalid_opcode", + ), + pytest.param( + # OOG during code deposit payment (200 gas/byte for returned code) + # Returns 1000 bytes which costs 200,000 gas for code deposit + Op.RETURN(0, 1000), + id="create_out_of_gas_code_deposit", + ), + ], +) +def test_create_out_of_gas_no_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + initcode: Bytecode, +) -> None: + """Test that CREATE running out of gas does NOT emit transfer log.""" + tx_value = 1000 + gas_limit = 100_000 + create_value = 500 + contract_code = Op.CALLDATACOPY( + dest_offset=0, + offset=0, + size=Op.CALLDATASIZE, + ) + Op.CREATE(value=create_value, offset=0, size=Op.CALLDATASIZE) + contract = pre.deploy_contract(contract_code) + + tx = Transaction( + sender=sender, + to=contract, + data=initcode, + value=tx_value, + gas_limit=gas_limit, + expected_receipt=TransactionReceipt( + logs=[transfer_log(sender, contract, tx_value)] + ), + ) + state_test(env=env, pre=pre, post={}, tx=tx) + + +@pytest.mark.parametrize( + "contract_code", + [ + pytest.param( + # CALL stack underflow: only push 6 items instead of 7 + Op.PUSH1(0) + + Op.PUSH1(0) + + Op.PUSH1(0) + + Op.PUSH1(0) + + Op.PUSH1(100) + + Op.PUSH2(0x1234) + + Op.CALL, + id="call_stack_underflow", + ), + pytest.param( + # CREATE stack underflow: only push 2 items instead of 3 + Op.PUSH1(0) + Op.PUSH1(0) + Op.CREATE, + id="create_stack_underflow", + ), + ], +) +def test_stack_underflow_no_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + contract_code: Bytecode, +) -> None: + """Test that stack underflow during CALL/CREATE does NOT emit log.""" + contract = pre.deploy_contract(contract_code, balance=1000) + + tx = Transaction( + sender=sender, + to=contract, + value=1000, + gas_limit=100_000, + expected_receipt=TransactionReceipt(logs=[]), # TX fails, no logs + ) + + state_test(env=env, pre=pre, post={}, tx=tx) + + +@pytest.mark.pre_alloc_modify +@pytest.mark.with_all_create_opcodes +def test_create_collision_no_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + create_opcode: Op, +) -> None: + """ + Test that CREATE/CREATE2 collision does not emit transfer log. + + When CREATE fails because target address already has code/nonce, + no value transfer occurs and no log should be emitted. + """ + initcode = Op.RETURN(0, 0) + initcode_bytes = bytes(initcode) + initcode_len = len(initcode_bytes) + + # Deploy factory first to compute created address + if create_opcode == Op.CREATE: + factory_code = Op.MSTORE( + 0, Op.PUSH32(initcode_bytes.rjust(32, b"\x00")) + ) + Op.CREATE(value=1, offset=32 - initcode_len, size=initcode_len) + else: + factory_code = Op.MSTORE( + 0, Op.PUSH32(initcode_bytes.rjust(32, b"\x00")) + ) + Op.CREATE2( + value=1, offset=32 - initcode_len, size=initcode_len, salt=0 + ) + + factory = pre.deploy_contract(factory_code, balance=1) + + # Compute and pre-populate the collision address + if create_opcode == Op.CREATE: + collision_address = compute_create_address(address=factory, nonce=1) + else: + collision_address = compute_create2_address( + address=factory, salt=0, initcode=initcode_bytes + ) + + # Pre-deploy contract at collision address to cause collision + pre.deploy_contract(Op.STOP, address=collision_address) + + tx = Transaction( + sender=sender, + to=factory, + value=0, + gas_limit=200_000, + expected_receipt=TransactionReceipt( + logs=[] + ), # No logs - CREATE failed + ) + + state_test(env=env, pre=pre, post={}, tx=tx) + + +def test_selfdestruct_with_value_emits_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, +) -> None: + """Test that SELFDESTRUCT with value emits a transfer log.""" + beneficiary = pre.empty_account() + contract_balance = 2000 + + contract_code = Op.SELFDESTRUCT(beneficiary) + contract = pre.deploy_contract(contract_code, balance=contract_balance) + + tx = Transaction( + sender=sender, + to=contract, + value=0, + gas_limit=100_000, + expected_receipt=TransactionReceipt( + logs=[transfer_log(contract, beneficiary, contract_balance)] + ), + ) + + post = {beneficiary: Account(balance=contract_balance)} + state_test(env=env, pre=pre, post=post, tx=tx) + + +def test_selfdestruct_to_system_address( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, +) -> None: + """ + Test SELFDESTRUCT sending ETH to the EIP-7708 system address. + + Edge case: beneficiary is the same address (0xff...fe) that emits logs. + """ + contract_code = Op.SELFDESTRUCT(Spec.SYSTEM_ADDRESS) + contract = pre.deploy_contract(contract_code, balance=1) + + tx = Transaction( + sender=sender, + to=contract, + value=0, + gas_limit=100_000, + expected_receipt=TransactionReceipt( + logs=[transfer_log(contract, Spec.SYSTEM_ADDRESS, 1)] + ), + ) + + post = {Spec.SYSTEM_ADDRESS: Account(balance=1)} + state_test(env=env, pre=pre, post=post, tx=tx) + + +@pytest.mark.parametrize( + "op_type", + [ + pytest.param("call", id="call"), + pytest.param("selfdestruct", id="selfdestruct"), + ], +) +def test_zero_value_operations_no_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + op_type: str, +) -> None: + """Test that zero-value operations do NOT emit transfer logs.""" + target = pre.empty_account() + + if op_type == "call": + contract_code = Op.CALL(gas=100_000, address=target, value=0) + else: + contract_code = Op.SELFDESTRUCT(target) + + contract = pre.deploy_contract(contract_code, balance=0) + + tx = Transaction( + sender=sender, + to=contract, + value=0, + gas_limit=100_000, + expected_receipt=TransactionReceipt(logs=[]), + ) + + state_test(env=env, pre=pre, post={}, tx=tx) + + +@pytest.mark.parametrize( + "recipient_code,call_gas,call_value,recipient_balance,contract_balance", + [ + pytest.param(Op.REVERT(0, 0), 50_000, 500, 0, 500, id="call_reverted"), + pytest.param(Op.JUMP(0), 100, 500, 0, 500, id="call_out_of_gas"), + pytest.param( + # OOG with memory expansion - tries to access large memory offset + Op.MSTORE(0xFFFFFF, 0) + Op.STOP, + 1000, + 500, + 0, + 500, + id="call_out_of_gas_memory_expansion", + ), + pytest.param( + Op.SELFDESTRUCT(Address(0x1234)), + 100, + 0, + 2000, + 0, + id="selfdestruct_out_of_gas", + ), + pytest.param( + Op.STOP, + 50_000, + 2000, + 0, + 0, + id="call_insufficient_balance", + ), + ], +) +def test_failed_inner_operation_no_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + recipient_code: Bytecode, + call_gas: int, + call_value: int, + recipient_balance: int, + contract_balance: int, +) -> None: + """Test that failed inner operations do NOT emit transfer logs.""" + recipient = pre.deploy_contract(recipient_code, balance=recipient_balance) + tx_value = 1000 + + contract_code = Op.CALL( + gas=call_gas, + address=recipient, + value=call_value, + ) + contract = pre.deploy_contract(contract_code, balance=contract_balance) + + tx = Transaction( + sender=sender, + to=contract, + value=tx_value, + gas_limit=100_000, + expected_receipt=TransactionReceipt( + logs=[transfer_log(sender, contract, tx_value)] + ), + ) + + state_test(env=env, pre=pre, post={}, tx=tx) + + +@pytest.mark.parametrize( + "inner_calls", + [ + pytest.param(1, id="single_call"), + pytest.param(3, id="multiple_calls"), + ], +) +def test_inner_call_succeeds_outer_reverts_no_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + inner_calls: int, +) -> None: + """ + Test that logs from successful inner calls are rolled back on outer revert. + + Scenario: Contract performs N CALLs with value (all succeed), then REVERTs. + Expected: No logs remain (all transfer logs are rolled back). + """ + callee = pre.deploy_contract(Op.STOP) + + # Build contract code: N CALLs with value, then REVERT + contract_code = Op.CALL(gas=100_000, address=callee, value=1) + for _ in range(inner_calls - 1): + contract_code += Op.POP + Op.CALL(gas=100_000, address=callee, value=1) + contract_code += Op.POP + Op.REVERT(0, 0) + + contract = pre.deploy_contract(contract_code, balance=inner_calls) + + tx = Transaction( + sender=sender, + to=contract, + value=1, + gas_limit=500_000, + expected_receipt=TransactionReceipt(logs=[]), + ) + + state_test(env=env, pre=pre, post={}, tx=tx) + + +@pytest.mark.parametrize( + "call_depth", + [ + pytest.param(2, id="depth_2"), + pytest.param(3, id="depth_3"), + pytest.param(10, id="depth_10"), + ], +) +def test_nested_calls_log_order( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + call_depth: int, +) -> None: + """Test that nested CALLs emit transfer logs in chronological order.""" + transfer_value = 100 + tx_value = 1000 + + # Build chain: contracts[0] -> contracts[1] -> ... -> final_recipient + final_recipient = pre.empty_account() + contracts: list[Address] = [] + expected_logs: list[TransactionLog] = [] + + # Build contracts in reverse order (deepest first) + next_target = final_recipient + for _ in range(call_depth): + contract_code = Op.CALL( + gas=500_000, address=next_target, value=transfer_value + ) + # Each contract needs enough balance for its transfer + contract = pre.deploy_contract(contract_code, balance=transfer_value) + contracts.insert(0, contract) + next_target = contract + + # First contract is the tx target + entry_contract = contracts[0] + + # Build expected logs in chronological order + # First: tx-level transfer (sender -> entry_contract) + expected_logs.append(transfer_log(sender, entry_contract, tx_value)) + + # Then: each CALL in order + for i in range(call_depth): + from_addr = contracts[i] + to_addr = contracts[i + 1] if i + 1 < call_depth else final_recipient + expected_logs.append(transfer_log(from_addr, to_addr, transfer_value)) + + tx = Transaction( + sender=sender, + to=entry_contract, + value=tx_value, + gas_limit=1_000_000, + expected_receipt=TransactionReceipt(logs=expected_logs), + ) + + post = {final_recipient: Account(balance=transfer_value)} + state_test(env=env, pre=pre, post=post, tx=tx) + + +def test_contract_log_and_transfer_ordering( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, +) -> None: + """ + Test log ordering between contract-emitted logs and transfer logs. + + Scenario: Contract emits LOG0, then CALLs with value. + Expected order: tx transfer log, contract LOG0, CALL transfer log. + """ + callee = pre.deploy_contract(Op.STOP) + + # Contract emits LOG0, then CALLs callee with value + contract_code = ( + Op.MSTORE(0, 0xDEADBEEF) + + Op.LOG0(offset=0, size=32) # Emit LOG0 with data + + Op.CALL(gas=50_000, address=callee, value=1) + ) + contract = pre.deploy_contract(contract_code, balance=1) + + tx = Transaction( + sender=sender, + to=contract, + value=1, + gas_limit=200_000, + expected_receipt=TransactionReceipt( + logs=[ + # 1. TX-level transfer + transfer_log(sender, contract, 1), + # 2. Contract LOG0 (emitted before CALL) + TransactionLog( + address=contract, + topics=[], + data=Bytes((0xDEADBEEF).to_bytes(32, "big")), + ), + # 3. CALL transfer (emitted during CALL) + transfer_log(contract, callee, 1), + ] + ), + ) + + post = {callee: Account(balance=1)} + state_test(env=env, pre=pre, post=post, tx=tx) + + +@pytest.mark.parametrize( + "reverting_code", + [ + pytest.param(Op.REVERT(0, 0), id="revert"), + pytest.param(Op.INVALID, id="invalid_opcode"), + pytest.param(Op.ADD, id="stack_underflow"), + pytest.param(Op.MSTORE(2**256 - 1, 0), id="out_of_gas"), + ], +) +def test_reverted_transaction_no_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + reverting_code: Bytecode, +) -> None: + """Test that a failed transaction does NOT emit a transfer log.""" + contract = pre.deploy_contract(reverting_code) + + tx = Transaction( + sender=sender, + to=contract, + value=1000, + gas_limit=100_000, + expected_receipt=TransactionReceipt(logs=[]), + ) + + state_test(env=env, pre=pre, post={}, tx=tx) + + +@pytest.mark.parametrize( + "address_type", + [ + pytest.param("ecrecover", id="precompile_ecrecover"), + pytest.param("sha256", id="precompile_sha256"), + pytest.param("system", id="system_address"), + pytest.param("coinbase", id="coinbase_address"), + ], +) +def test_transfer_to_special_address( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + address_type: str, +) -> None: + """Test that transfers to special addresses emit transfer logs.""" + transfer_amount = 1000 + + # Resolve target address based on type + # Note: blake2f (0x09) excluded as it requires specific input format + address_map = { + "ecrecover": Address(0x01), + "sha256": Address(0x02), + "system": Spec.SYSTEM_ADDRESS, + } + + if address_type == "coinbase": + target = env.fee_recipient + # Don't check exact balance - coinbase also receives gas fees + post = {} + else: + target = address_map[address_type] + post = {target: Account(balance=transfer_amount)} + + tx = Transaction( + sender=sender, + to=target, + value=transfer_amount, + gas_limit=100_000, + expected_receipt=TransactionReceipt( + logs=[transfer_log(sender, target, transfer_amount)] + ), + ) + + state_test(env=env, pre=pre, post=post, tx=tx) + + +@pytest.mark.with_all_typed_transactions +def test_transfer_with_all_tx_types( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + typed_transaction: Transaction, +) -> None: + """Test that ETH transfers emit logs for all transaction types.""" + recipient = pre.empty_account() + transfer_amount = 1000 + + tx = typed_transaction.copy( + to=recipient, + value=transfer_amount, + expected_receipt=TransactionReceipt( + logs=[transfer_log(sender, recipient, transfer_amount)] + ), + ) + + post = {recipient: Account(balance=transfer_amount)} + state_test(env=env, pre=pre, post=post, tx=tx) + + +def test_multiple_transfers_same_block( + blockchain_test: BlockchainTestFiller, pre: Alloc +) -> None: + """ + Test that multiple transfers in the same block have independent logs. + + Each transaction should have its own transfer log in its receipt, + verifying logs don't bleed across transactions. + """ + sender = pre.fund_eoa() + recipient1 = pre.empty_account() + recipient2 = pre.empty_account() + + blocks = [ + Block( + txs=[ + Transaction( + to=recipient1, + sender=sender, + nonce=0, + value=100, + gas_limit=21_000, + expected_receipt=TransactionReceipt( + logs=[transfer_log(sender, recipient1, 100)] + ), + ), + Transaction( + to=recipient2, + sender=sender, + nonce=1, + value=200, + gas_limit=21_000, + expected_receipt=TransactionReceipt( + logs=[transfer_log(sender, recipient2, 200)] + ), + ), + ], + ), + ] + + blockchain_test( + pre=pre, + blocks=blocks, + post={ + recipient1: Account(balance=100), + recipient2: Account(balance=200), + }, + ) + + +def test_selfdestruct_then_transfer_same_block( + blockchain_test: BlockchainTestFiller, pre: Alloc +) -> None: + """ + Test transfer to address that selfdestructed earlier in the same block. + + Tx1: Contract selfdestructs, sending balance to beneficiary. + Tx2: Transfer to the contract triggers SELFDESTRUCT again (code not deleted + per EIP-6780), sending the received value to beneficiary. + + Expected logs: + - Tx1: contract -> beneficiary (500) + - Tx2: sender -> contract (100) + contract -> beneficiary (100) + """ + sender = pre.fund_eoa() + beneficiary = pre.empty_account() + + contract_code = Op.SELFDESTRUCT(beneficiary) + contract = pre.deploy_contract(contract_code, balance=500) + + blocks = [ + Block( + txs=[ + Transaction( + to=contract, + sender=sender, + nonce=0, + value=0, + gas_limit=100_000, + expected_receipt=TransactionReceipt( + logs=[transfer_log(contract, beneficiary, 500)] + ), + ), + Transaction( + to=contract, + sender=sender, + nonce=1, + value=100, + gas_limit=100_000, + expected_receipt=TransactionReceipt( + logs=[ + transfer_log(sender, contract, 100), + transfer_log(contract, beneficiary, 100), + ] + ), + ), + ], + ), + ] + + blockchain_test( + pre=pre, + blocks=blocks, + post={ + beneficiary: Account(balance=600), + contract: Account(balance=0), + }, + ) + + +def test_call_to_delegated_account_with_value( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, +) -> None: + """ + Test CALL opcode to 7702 delegated account with value. + + Unlike simple tx transfer, CALL to delegated account executes the + delegated code. The transfer log should show the EOA as recipient. + """ + delegation_target = pre.deploy_contract(code=Op.STOP) + delegated_eoa = pre.fund_eoa(amount=0, delegation=delegation_target) + + caller_code = Op.CALL(gas=50_000, address=delegated_eoa, value=100) + caller = pre.deploy_contract(caller_code, balance=100) + + tx = Transaction( + sender=sender, + to=caller, + value=0, + gas_limit=200_000, + expected_receipt=TransactionReceipt( + logs=[transfer_log(caller, delegated_eoa, 100)] + ), + ) + + post = {delegated_eoa: Account(balance=100)} + state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/osaka/eip7918_blob_reserve_price/test_blob_reserve_price_with_bpo_transitions.py b/tests/osaka/eip7918_blob_reserve_price/test_blob_reserve_price_with_bpo_transitions.py index 810bc410635..14e8da4b8e9 100644 --- a/tests/osaka/eip7918_blob_reserve_price/test_blob_reserve_price_with_bpo_transitions.py +++ b/tests/osaka/eip7918_blob_reserve_price/test_blob_reserve_price_with_bpo_transitions.py @@ -557,7 +557,9 @@ def get_fork_scenarios(fork: TransitionFork) -> Iterator[ParameterSet]: ], get_fork_scenarios, ) -@pytest.mark.valid_at_transition_to("Osaka", subsequent_forks=True) +@pytest.mark.valid_at_transition_to( + "Osaka", subsequent_forks=True, until="BPO4" +) @pytest.mark.valid_for_bpo_forks() @pytest.mark.slow() def test_reserve_price_at_transition( From 2e94c4102841fd439bc395904864847c3a1f6367 Mon Sep 17 00:00:00 2001 From: felipe Date: Thu, 29 Jan 2026 16:17:26 -0700 Subject: [PATCH 08/24] feat(spec,test): EIP-7708 spec updates for self as target (#2086) * fix(spec,text): Updates to EIP-7708 spec for bal-devnet-2 - fix(spec,test): EIP-7708 emit selfdestruct logs only in same tx - fix(spec,test): EIP-7708, only emit on transfers to other accounts - Add more tests that match these edge cases * feat(test): tests for finalization selfdest + bal transfer in lexicographic order * fix: changes from comments on PR #2086 * add more variants to test_selfdestruct_same_tx_via_call * fix: docstring * refactor: improvements from comments on PR #2086 * Update test to use dynamic addresses --------- Co-authored-by: Mario Vega --- .../forks/amsterdam/vm/instructions/system.py | 9 +- .../forks/amsterdam/vm/interpreter.py | 8 +- .../eip7708_eth_transfer_logs/spec.py | 2 +- .../test_fork_transition.py | 137 ++++-- .../test_selfdestruct_logs.py | 460 +++++++++++++++++- .../test_transfer_logs.py | 136 +++++- 6 files changed, 689 insertions(+), 63 deletions(-) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index aa2a5b9905c..10089afe165 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -610,11 +610,12 @@ def selfdestruct(evm: Evm) -> None: # Transfer balance move_ether(tx_state, originator, beneficiary, originator_balance) - # EIP-7708: Emit appropriate log based on beneficiary - if beneficiary == originator: - # Self-destruct to self burns the balance + # EIP-7708: Emit appropriate log based on whether ETH is burned + # or transferred to a different account + if originator in tx_state.created_accounts and beneficiary == originator: + # Self-destruct to self in same tx burns the balance emit_selfdestruct_log(evm, originator, originator_balance) - else: + elif beneficiary != originator: # Transfer to different beneficiary emit_transfer_log(evm, originator, beneficiary, originator_balance) diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index f45e9fa6740..11c263561ef 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -272,9 +272,11 @@ def process_message(message: Message) -> Evm: message.current_target, message.value, ) - emit_transfer_log( - evm, message.caller, message.current_target, message.value - ) + # EIP-7708: Only emit transfer log to a different account + if message.caller != message.current_target: + emit_transfer_log( + evm, message.caller, message.current_target, message.value + ) # Execute message code and handle errors try: diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/spec.py b/tests/amsterdam/eip7708_eth_transfer_logs/spec.py index a4c21a60ae2..fb8deffdda4 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/spec.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/spec.py @@ -14,7 +14,7 @@ class ReferenceSpec: ref_spec_7708 = ReferenceSpec( - "EIPS/eip-7708.md", "a7c5b2ff5697d5a0be5ea804a89d98a7fd0dce60" + "EIPS/eip-7708.md", "172188d7b090ed1afb876140f45e19ac00cba4bb" ) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_fork_transition.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_fork_transition.py index 90582e1b13b..5f5c5700385 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_fork_transition.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_fork_transition.py @@ -14,6 +14,7 @@ Op, Transaction, TransactionReceipt, + compute_create_address, ) from .spec import ref_spec_7708, selfdestruct_log, transfer_log @@ -22,68 +23,116 @@ REFERENCE_SPEC_VERSION = ref_spec_7708.version +@pytest.mark.parametrize( + "same_tx,to_self", + [ + pytest.param(True, True, id="same_tx_to_self"), + pytest.param(False, True, id="pre_existing_to_self"), + pytest.param(False, False, id="pre_existing_to_other"), + ], +) @pytest.mark.valid_at_transition_to("Amsterdam") def test_selfdestruct_log_at_fork_transition( - blockchain_test: BlockchainTestFiller, pre: Alloc + blockchain_test: BlockchainTestFiller, + pre: Alloc, + same_tx: bool, + to_self: bool, ) -> None: """ - Test ETH selfdestruct log behavior at fork transition. + Test selfdestruct log emission across the Amsterdam fork transition. + + same_tx_to_self: Factory CREATEs and selfdestructs to self in one tx. + At/after Amsterdam emits a CREATE transfer log + Selfdestruct log. - Before Amsterdam: ETH selfdestructs do NOT emit logs. - At/after Amsterdam: ETH selfdestructs emit Selfdestruct logs. + pre_existing_to_self: Pre-existing contract selfdestructs to self. + No logs at any fork — SELFDESTRUCT to same account emits nothing. + + pre_existing_to_other: Pre-existing contract selfdestructs to a different + account. At/after Amsterdam emits a Transfer log. """ sender = pre.fund_eoa() - contract1 = pre.deploy_contract(Op.SELFDESTRUCT(Op.ADDRESS), balance=1) - contract2 = pre.deploy_contract(Op.SELFDESTRUCT(Op.ADDRESS), balance=2) - contract3 = pre.deploy_contract(Op.SELFDESTRUCT(Op.ADDRESS), balance=3) + contract_balance = 1000 - blocks = [ - Block( - timestamp=14_999, - txs=[ - Transaction( - to=contract1, - sender=sender, - gas_limit=100_000, - expected_receipt=TransactionReceipt(logs=[]), - ) + if same_tx: + initcode = Op.SELFDESTRUCT(Op.ADDRESS) + initcode_bytes = bytes(initcode) + initcode_len = len(initcode_bytes) + + factory_code = Op.MSTORE( + 0, Op.PUSH32(initcode_bytes.rjust(32, b"\x00")) + ) + Op.CREATE( + value=contract_balance, offset=32 - initcode_len, size=initcode_len + ) + + factory = pre.deploy_contract( + factory_code, balance=contract_balance * 3 + ) + created = [ + compute_create_address(address=factory, nonce=n) + for n in range(1, 4) + ] + targets = [factory] * 3 + + expected_logs = [ + [], + [ + transfer_log(factory, created[1], contract_balance), + selfdestruct_log(created[1], contract_balance), ], - ), - Block( - timestamp=15_000, - txs=[ - Transaction( - to=contract2, - sender=sender, - gas_limit=100_000, - expected_receipt=TransactionReceipt( - logs=[selfdestruct_log(contract2, 2)] - ), - ) + [ + transfer_log(factory, created[2], contract_balance), + selfdestruct_log(created[2], contract_balance), ], - ), + ] + post: dict = { + sender: Account(nonce=3), + created[0]: Account.NONEXISTENT, + created[1]: Account.NONEXISTENT, + created[2]: Account.NONEXISTENT, + } + elif to_self: + targets = [ + pre.deploy_contract( + Op.SELFDESTRUCT(Op.ADDRESS), balance=contract_balance + ) + for _ in range(3) + ] + expected_logs = [[], [], []] + post = {sender: Account(nonce=3)} + else: + beneficiary = pre.empty_account() + targets = [ + pre.deploy_contract( + Op.SELFDESTRUCT(beneficiary), balance=contract_balance + ) + for _ in range(3) + ] + expected_logs = [ + [], + [transfer_log(targets[1], beneficiary, contract_balance)], + [transfer_log(targets[2], beneficiary, contract_balance)], + ] + post = { + sender: Account(nonce=3), + beneficiary: Account(balance=contract_balance * 3), + } + + blocks = [ Block( - timestamp=15_001, + timestamp=ts, txs=[ Transaction( - to=contract3, + to=targets[i], sender=sender, - gas_limit=100_000, - expected_receipt=TransactionReceipt( - logs=[selfdestruct_log(contract3, 3)] - ), + gas_limit=200_000, + expected_receipt=TransactionReceipt(logs=expected_logs[i]), ) ], - ), + ) + for i, ts in enumerate([14_999, 15_000, 15_001]) ] - blockchain_test( - pre=pre, - blocks=blocks, - post={ - sender: Account(nonce=3), - }, - ) + blockchain_test(pre=pre, blocks=blocks, post=post) @pytest.mark.valid_at_transition_to("Amsterdam") diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_selfdestruct_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_selfdestruct_logs.py index ad24b920977..d5cba361bd0 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_selfdestruct_logs.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_selfdestruct_logs.py @@ -3,21 +3,30 @@ Tests for the Selfdestruct(address,uint256) log emitted when: - SELFDESTRUCT to self with nonzero balance -- Account closure after SELFDESTRUCT +- Account created and destroyed in the same transaction """ import pytest from execution_testing import ( EOA, + Account, + Address, Alloc, + Bytecode, Environment, + Initcode, Op, + Opcodes, StateTestFiller, Transaction, TransactionReceipt, + compute_create_address, +) +from execution_testing import ( + Macros as Om, ) -from .spec import ref_spec_7708, selfdestruct_log +from .spec import ref_spec_7708, selfdestruct_log, transfer_log REFERENCE_SPEC_GIT_PATH = ref_spec_7708.git_path REFERENCE_SPEC_VERSION = ref_spec_7708.version @@ -25,17 +34,16 @@ pytestmark = pytest.mark.valid_from("Amsterdam") -def test_selfdestruct_to_self_emits_log( +def test_selfdestruct_to_self_pre_existing_no_log( state_test: StateTestFiller, env: Environment, pre: Alloc, sender: EOA, ) -> None: """ - Test that selfdestruct-to-self emits a Selfdestruct log. + Test that selfdestruct-to-self emits NO log for pre-existing contracts. - Since the contract selfdestructs to itself, there is no transfer. - Instead, a Selfdestruct log is emitted with the contract's balance. + Selfdestruct log only emitted when created and destroyed in same tx. """ contract_balance = 2000 @@ -47,9 +55,445 @@ def test_selfdestruct_to_self_emits_log( to=contract, value=0, gas_limit=100_000, + expected_receipt=TransactionReceipt(logs=[]), + ) + + # Contract keeps its balance (not destroyed since not created in same tx) + state_test( + env=env, + pre=pre, + post={contract: Account(balance=contract_balance)}, + tx=tx, + ) + + +@pytest.mark.parametrize( + "contract_balance", + [ + pytest.param(2000, id="with_balance"), + pytest.param(0, id="zero_balance"), + ], +) +def test_selfdestruct_to_self_same_tx( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + contract_balance: int, +) -> None: + """ + Test selfdestruct-to-self for same-tx created contracts. + + - With balance, SELFDESTRUCT log emitted (burns ETH). + - No balance, no logs expected. + """ + initcode = Op.SELFDESTRUCT(Op.ADDRESS) + initcode_bytes = bytes(initcode) + initcode_len = len(initcode_bytes) + + factory_code = Op.MSTORE( + 0, Op.PUSH32(initcode_bytes.rjust(32, b"\x00")) + ) + Op.CREATE( + value=Op.CALLVALUE, offset=32 - initcode_len, size=initcode_len + ) + + factory = pre.deploy_contract(factory_code) + created_address = compute_create_address(address=factory, nonce=1) + + if contract_balance > 0: + expected_logs = [ + transfer_log(sender, factory, contract_balance), + transfer_log(factory, created_address, contract_balance), + selfdestruct_log(created_address, contract_balance), + ] + else: + expected_logs = [] + + tx = Transaction( + sender=sender, + to=factory, + value=contract_balance, + gas_limit=200_000, + expected_receipt=TransactionReceipt(logs=expected_logs), + ) + + state_test(env=env, pre=pre, post={}, tx=tx) + + +@pytest.mark.parametrize( + "contract_balance", + [ + pytest.param(2000, id="with_balance"), + pytest.param(0, id="zero_balance"), + ], +) +def test_selfdestruct_to_different_address_same_tx( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + contract_balance: int, +) -> None: + """ + Test same-tx selfdestruct to different address. + + With balance: Transfer log emitted. Zero balance: no logs. + """ + beneficiary = pre.deploy_contract(Op.STOP) + + initcode = Op.SELFDESTRUCT(beneficiary) + initcode_bytes = bytes(initcode) + initcode_len = len(initcode_bytes) + + factory_code = Op.MSTORE( + 0, Op.PUSH32(initcode_bytes.rjust(32, b"\x00")) + ) + Op.CREATE( + value=Op.CALLVALUE, offset=32 - initcode_len, size=initcode_len + ) + + factory = pre.deploy_contract(factory_code) + created_address = compute_create_address(address=factory, nonce=1) + + if contract_balance > 0: + expected_logs = [ + transfer_log(sender, factory, contract_balance), + transfer_log(factory, created_address, contract_balance), + transfer_log(created_address, beneficiary, contract_balance), + ] + post = {beneficiary: Account(balance=contract_balance)} + else: + expected_logs = [] + post = {} + + tx = Transaction( + sender=sender, + to=factory, + value=contract_balance, + gas_limit=200_000, + expected_receipt=TransactionReceipt(logs=expected_logs), + ) + + state_test(env=env, pre=pre, post=post, tx=tx) + + +@pytest.mark.parametrize( + "to_self", + [ + pytest.param(True, id="to_self"), + pytest.param(False, id="to_other"), + ], +) +@pytest.mark.parametrize( + "call_twice,second_call_value", + [ + pytest.param(True, 1, id="call_twice_with_value"), + pytest.param(True, 0, id="call_twice"), + pytest.param(False, 0, id="call_once"), + ], +) +@pytest.mark.parametrize( + "transfer_during_create", + [ + pytest.param(True, id="transfer_during_create"), + pytest.param(False, id="transfer_during_call"), + ], +) +def test_selfdestruct_same_tx_via_call( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + to_self: bool, + call_twice: bool, + second_call_value: int, + transfer_during_create: bool, +) -> None: + """ + Test selfdestruct via CREATE-then-CALL (not initcode selfdestruct). + + Factory CREATEs contract with runtime code, then CALLs the contract that + was just created to trigger SELFDESTRUCT (depending on + `transfer_during_create`, the value of the contract is transferred during + the CREATE or CALL opcodes). Contract is still in created_accounts. + + Depending on `call_twice`, the contract can be called twice during the + same call frame where it was created. + """ + contract_balance = 2000 + beneficiary = pre.deploy_contract(Op.STOP) + + if to_self: + runtime_code = Op.SELFDESTRUCT(Op.ADDRESS) + else: + runtime_code = Op.SELFDESTRUCT(beneficiary) + + initcode = Initcode(deploy_code=runtime_code) + initcode_len = len(initcode) + + if transfer_during_create: + create_value = contract_balance + first_call_value = 0 + else: + create_value = 0 + first_call_value = contract_balance + + factory_code = ( + Om.MSTORE(initcode, 0) + + Op.TSTORE( + 0, Op.CREATE(value=create_value, offset=0, size=initcode_len) + ) + + Op.CALL(gas=100_000, address=Op.TLOAD(0), value=first_call_value) + ) + if call_twice: + factory_code += Op.CALL( + gas=100_000, address=Op.TLOAD(0), value=second_call_value + ) + + factory = pre.deploy_contract( + factory_code, balance=contract_balance + second_call_value + ) + created_address = compute_create_address(address=factory, nonce=1) + + if to_self: + expected_logs = [ + transfer_log(factory, created_address, contract_balance), + selfdestruct_log(created_address, contract_balance), + ] + if call_twice and second_call_value > 0: + expected_logs += [ + transfer_log(factory, created_address, second_call_value), + selfdestruct_log(created_address, second_call_value), + ] + post = {} + else: + expected_logs = [ + transfer_log(factory, created_address, contract_balance), + transfer_log(created_address, beneficiary, contract_balance), + ] + if call_twice and second_call_value > 0: + expected_logs += [ + transfer_log(factory, created_address, second_call_value), + transfer_log(created_address, beneficiary, second_call_value), + ] + post = { + beneficiary: Account(balance=contract_balance + second_call_value) + } + + tx = Transaction( + sender=sender, + to=factory, + value=0, + gas_limit=300_000, + expected_receipt=TransactionReceipt(logs=expected_logs), + ) + + state_test(env=env, pre=pre, post=post, tx=tx) + + +@pytest.mark.parametrize( + "payer_code,eth_transferred", + [ + pytest.param( + Op.SELFDESTRUCT(Op.CALLDATALOAD(0)), + True, + id="via_selfdestruct", + ), + pytest.param( + Op.CALL( + gas=50_000, + address=Op.CALLDATALOAD(0), + value=Op.BALANCE(Op.ADDRESS), + ), + True, + id="via_call", + ), + pytest.param( + Op.CALL( + gas=50_000, + address=Op.CALLDATALOAD(0), + value=Op.BALANCE(Op.ADDRESS), + ) + + Op.REVERT(0, 0), + False, + id="via_call_revert", + ), + ], +) +@pytest.mark.parametrize( + "to_self", + [ + pytest.param(False, id="to_beneficiary"), + pytest.param(True, id="to_self"), + ], +) +def test_finalization_selfdestruct_logs( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + payer_code: Bytecode, + eth_transferred: bool, + to_self: bool, +) -> None: + """ + Test Selfdestruct logs at finalization for post-selfdestruct balance. + + X contracts (x1, x2, x3) selfdestruct, then receive ETH via payer contracts + (p1, p2, p3). At finalization, X contracts emit SELFDESTRUCT logs for their + in lexicographical address order (only if they received ETH). + + When to_self=True, X contracts SELFDESTRUCT to themselves (burning ETH + with LOG2). When to_self=False, X contracts SELFDESTRUCT to a beneficiary + (Transfer LOG3). + """ + beneficiary = pre.deploy_contract(Op.STOP) + + # Pre-compute factory address and created contract addresses + # so we can call them in reverse sorted order to prove finalization + # logs are sorted by address, not by call order + factory_address = compute_create_address( + address=sender, nonce=sender.nonce + ) + x1 = compute_create_address(address=factory_address, nonce=1) + x2 = compute_create_address(address=factory_address, nonce=2) + x3 = compute_create_address(address=factory_address, nonce=3) + + # sort() + call in REVERSE order to prove finalization + # lexicographical sorting + sorted_addrs = sorted([x1, x2, x3]) + reverse_sorted = list(reversed(sorted_addrs)) + + # Runtime: selfdestruct on first call, STOP on subsequent calls + selfdestruct_target: Address | Opcodes + if to_self: + selfdestruct_target = Op.ADDRESS + else: + selfdestruct_target = beneficiary + runtime = ( + Op.TLOAD(0) + + Op.ISZERO + + Op.PUSH1(8) + + Op.JUMPI + + Op.STOP + + Op.JUMPDEST + + Op.TSTORE(0, 1) + + Op.SELFDESTRUCT(selfdestruct_target) + ) + initcode = Initcode(deploy_code=runtime) + initcode_len = len(initcode) + + # Payer contracts (p1, p2, p3) will send ETH to created contracts + p1 = pre.deploy_contract(payer_code, balance=100) + p2 = pre.deploy_contract(payer_code, balance=200) + p3 = pre.deploy_contract(payer_code, balance=300) + + # Call p1/p2/p3 targeting addresses in REVERSE sorted order + # This proves finalization logs are sorted by address, not call order + factory_code = ( + Om.MSTORE(initcode, 0) + # Create x1, x2, x3 + + Op.TSTORE(0, Op.CREATE(value=1000, offset=0, size=initcode_len)) + + Op.TSTORE(1, Op.CREATE(value=2000, offset=0, size=initcode_len)) + + Op.TSTORE(2, Op.CREATE(value=3000, offset=0, size=initcode_len)) + # Call x1, x2, x3 to trigger SELFDESTRUCT + + Op.CALL(gas=100_000, address=Op.TLOAD(0), value=0) + + Op.CALL(gas=100_000, address=Op.TLOAD(1), value=0) + + Op.CALL(gas=100_000, address=Op.TLOAD(2), value=0) + # p1/p2/p3 send ETH in REVERSE sorted address order + + Op.MSTORE(0, reverse_sorted[0]) + + Op.CALL(gas=100_000, address=p1, args_offset=0, args_size=32) + + Op.MSTORE(0, reverse_sorted[1]) + + Op.CALL(gas=100_000, address=p2, args_offset=0, args_size=32) + + Op.MSTORE(0, reverse_sorted[2]) + + Op.CALL(gas=100_000, address=p3, args_offset=0, args_size=32) + ) + + factory_balance = 1000 + 2000 + 3000 + pre.fund_address(factory_address, factory_balance) + + # Amounts based on reverse call order: + # p1→reverse[0], p2→reverse[1], p3→reverse[2] + amounts = { + reverse_sorted[0]: 100, + reverse_sorted[1]: 200, + reverse_sorted[2]: 300, + } + + # Execution logs: + # 1. CREATE x1, x2, x3 → LOG3 Transfer (factory → created) + # 2. CALL x1, x2, x3 → LOG3 or LOG2 depending on `to_self` + # 3. p1/p2/p3 send to reverse_sorted order + execution_logs = [ + transfer_log(factory_address, x1, 1000), + transfer_log(factory_address, x2, 2000), + transfer_log(factory_address, x3, 3000), + ] + + if to_self: + # SELFDESTRUCT to self burns ETH → LOG2 Selfdestruct + execution_logs.extend( + [ + selfdestruct_log(x1, 1000), + selfdestruct_log(x2, 2000), + selfdestruct_log(x3, 3000), + ] + ) + beneficiary_balance = 0 + else: + # SELFDESTRUCT to beneficiary → LOG3 Transfer + execution_logs.extend( + [ + transfer_log(x1, beneficiary, 1000), + transfer_log(x2, beneficiary, 2000), + transfer_log(x3, beneficiary, 3000), + ] + ) + beneficiary_balance = factory_balance + + if not eth_transferred: + # Reverted CALLs emit no logs, no ETH transferred, no finalization logs + finalization_logs = [] + post = { + x1: Account.NONEXISTENT, + x2: Account.NONEXISTENT, + x3: Account.NONEXISTENT, + beneficiary: Account(balance=beneficiary_balance), + p1: Account(balance=100), + p2: Account(balance=200), + p3: Account(balance=300), + } + else: + # p1/p2/p3 send ETH in reverse sorted order + execution_logs.extend( + [ + transfer_log(p1, reverse_sorted[0], 100), + transfer_log(p2, reverse_sorted[1], 200), + transfer_log(p3, reverse_sorted[2], 300), + ] + ) + # Finalization logs emitted in SORTED address order (not call order) + finalization_logs = [ + selfdestruct_log(addr, amounts[addr]) for addr in sorted_addrs + ] + post = { + x1: Account.NONEXISTENT, + x2: Account.NONEXISTENT, + x3: Account.NONEXISTENT, + beneficiary: Account(balance=beneficiary_balance), + p1: Account(balance=0), + p2: Account(balance=0), + p3: Account(balance=0), + } + + tx = Transaction( + sender=sender, + to=None, + value=0, + data=factory_code, + gas_limit=1_000_000, expected_receipt=TransactionReceipt( - logs=[selfdestruct_log(contract, contract_balance)] + logs=execution_logs + finalization_logs ), ) - state_test(env=env, pre=pre, post={}, tx=tx) + state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py index 0c58ac037ed..585f69e706f 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py @@ -18,6 +18,7 @@ Bytecode, Bytes, Environment, + Initcode, Op, StateTestFiller, Transaction, @@ -87,6 +88,24 @@ def test_transfer_to_delegated_account_emits_log( state_test(env=env, pre=pre, post=post, tx=tx) +def test_transfer_to_self_no_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, +) -> None: + """Test that a transaction sending value to self emits no transfer log.""" + tx = Transaction( + sender=sender, + to=sender, + value=1, + gas_limit=21_000, + expected_receipt=TransactionReceipt(logs=[]), + ) + + state_test(env=env, pre=pre, post={}, tx=tx) + + def test_zero_value_transfer_no_log( state_test: StateTestFiller, env: Environment, @@ -179,9 +198,9 @@ def test_call_opcodes_transfer_log_behavior( expected_logs.append(transfer_log(contract, callee, 1)) post = {callee: Account(balance=1)} elif call_opcode == Op.CALLCODE: - # CALLCODE transfers value but stays in caller's context - # This is a self-transfer (contract -> contract) - expected_logs.append(transfer_log(contract, contract, 1)) + # CALLCODE transfers value but stays in caller's context. + # This is a self-transfer (contract -> contract), so no transfer + # log per EIP-7708 ("CALL to a different account"). post = {} else: # DELEGATECALL and STATICCALL: no value transfer, no additional log @@ -776,6 +795,50 @@ def test_zero_value_operations_no_log( state_test(env=env, pre=pre, post={}, tx=tx) +@pytest.mark.parametrize( + "call_opcode", + [ + pytest.param(Op.CALL, id="call"), + pytest.param(Op.CALLCODE, id="callcode"), + ], +) +def test_call_to_self_no_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + call_opcode: Op, +) -> None: + """ + Test that CALL/CALLCODE with value to self emits no transfer log. + + Uses CALLDATASIZE to detect recursion: external call has no calldata, + recursive call passes 1 byte of calldata to signal stop. + """ + # CALLDATASIZE > 0 means recursive call, jump to end + # Byte offsets: CALLDATASIZE(1) + PUSH1(2) + JUMPI(1) = 4 + # CALL with args_size=1: ~16 bytes, JUMPDEST at offset 20 + contract_code = ( + Op.CALLDATASIZE + + Op.PUSH1(20) + + Op.JUMPI + + call_opcode(gas=100_000, address=Op.ADDRESS, value=1, args_size=1) + + Op.JUMPDEST + + Op.STOP + ) + contract = pre.deploy_contract(contract_code, balance=1) + + tx = Transaction( + sender=sender, + to=contract, + value=0, + gas_limit=200_000, + expected_receipt=TransactionReceipt(logs=[]), + ) + + state_test(env=env, pre=pre, post={}, tx=tx) + + @pytest.mark.parametrize( "recipient_code,call_gas,call_value,recipient_balance,contract_balance", [ @@ -1203,6 +1266,73 @@ def test_selfdestruct_then_transfer_same_block( ) +def test_selfdestruct_to_self_cross_tx_no_log( + blockchain_test: BlockchainTestFiller, pre: Alloc +) -> None: + """ + Test that selfdestruct-to-self in a cross-tx context emits no log. + + A contract created in Tx1 is not in created_accounts during Tx2. + Selfdestruct-to-self in Tx2 emits no log per EIP-7708: no Selfdestruct + log (not same-tx) and no Transfer log (not a different account). + + Tx1: Contract creation tx (to=None) deploying SELFDESTRUCT(ADDRESS), + value=2000. Logs: [transfer_log(sender, created, 2000)] + Tx2: Call created contract directly, value=0. Logs: [] + Post: contract keeps balance (not deleted, not in created_accounts in Tx2) + """ + contract_balance = 2000 + sender = pre.fund_eoa() + + runtime_code = Op.SELFDESTRUCT(Op.ADDRESS) + initcode = Initcode(deploy_code=runtime_code) + + # Calculate the address that will be created by the first tx + created_address = compute_create_address(address=sender, nonce=0) + + blocks = [ + Block( + txs=[ + # Tx1: Create the contract directly via contract creation tx + Transaction( + to=None, + sender=sender, + nonce=0, + value=contract_balance, + data=bytes(initcode), + gas_limit=300_000, + expected_receipt=TransactionReceipt( + logs=[ + transfer_log( + sender, created_address, contract_balance + ), + ] + ), + ), + # Tx2: Call the created contract directly (cross-tx) + Transaction( + to=created_address, + sender=sender, + nonce=1, + value=0, + gas_limit=100_000, + expected_receipt=TransactionReceipt(logs=[]), + ), + ], + ), + ] + + blockchain_test( + pre=pre, + blocks=blocks, + post={ + # Contract keeps balance: not deleted since not in + # created_accounts during Tx2 + created_address: Account(balance=contract_balance), + }, + ) + + def test_call_to_delegated_account_with_value( state_test: StateTestFiller, env: Environment, From fb5df33e287235a912eb02007571d23692c12005 Mon Sep 17 00:00:00 2001 From: felipe Date: Thu, 29 Jan 2026 22:04:16 -0700 Subject: [PATCH 09/24] feat(test): extend EIP-7708 tests from cases in tracker (#1875) (#2106) - Add CREATE2 support via with_all_create_opcodes marker - Add tests for SELFDESTRUCT to coinbase - revealed a change needed since the last update was made to the EIP that should be included in the currect refspec (miner fees paid before finalization LOG2). --- src/ethereum/forks/amsterdam/fork.py | 22 +- .../test_selfdestruct_logs.py | 188 +++++++++++++++++- 2 files changed, 189 insertions(+), 21 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 6b6be36d4b9..272bc236813 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -1054,9 +1054,17 @@ def process_transaction( ) set_account_balance(tx_state, sender, sender_balance_after_refund) - # EIP-7708: Emit selfdestruct logs for remaining balance at finalization. - # This handles the case where a contract receives ETH after being flagged - # for SELFDESTRUCT but before finalization. + # transfer miner fees + coinbase_balance_after_mining_fee = get_account( + tx_state, block_env.coinbase + ).balance + U256(transaction_fee) + + set_account_balance( + tx_state, block_env.coinbase, coinbase_balance_after_mining_fee + ) + + # EIP-7708: Emit burn logs for balances held by accounts marked for + # deletion AFTER miner fee transfer. finalization_logs: List[Log] = [] for address in sorted(tx_output.accounts_to_delete): balance = get_account(tx_state, address).balance @@ -1075,14 +1083,6 @@ def process_transaction( all_logs = tx_output.logs + tuple(finalization_logs) - coinbase_balance_after_mining_fee = get_account( - tx_state, block_env.coinbase - ).balance + U256(transaction_fee) - - set_account_balance( - tx_state, block_env.coinbase, coinbase_balance_after_mining_fee - ) - if coinbase_balance_after_mining_fee == 0 and account_exists_and_is_empty( tx_state, block_env.coinbase ): diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_selfdestruct_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_selfdestruct_logs.py index d5cba361bd0..d74aa9bc635 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_selfdestruct_logs.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_selfdestruct_logs.py @@ -12,8 +12,12 @@ Account, Address, Alloc, + Block, + BlockchainTestFiller, Bytecode, Environment, + Fork, + Header, Initcode, Op, Opcodes, @@ -74,12 +78,14 @@ def test_selfdestruct_to_self_pre_existing_no_log( pytest.param(0, id="zero_balance"), ], ) +@pytest.mark.with_all_create_opcodes def test_selfdestruct_to_self_same_tx( state_test: StateTestFiller, env: Environment, pre: Alloc, sender: EOA, contract_balance: int, + create_opcode: Op, ) -> None: """ Test selfdestruct-to-self for same-tx created contracts. @@ -93,12 +99,18 @@ def test_selfdestruct_to_self_same_tx( factory_code = Op.MSTORE( 0, Op.PUSH32(initcode_bytes.rjust(32, b"\x00")) - ) + Op.CREATE( + ) + create_opcode( value=Op.CALLVALUE, offset=32 - initcode_len, size=initcode_len ) factory = pre.deploy_contract(factory_code) - created_address = compute_create_address(address=factory, nonce=1) + created_address = compute_create_address( + address=factory, + nonce=1, + salt=0, + initcode=initcode_bytes, + opcode=create_opcode, + ) if contract_balance > 0: expected_logs = [ @@ -127,12 +139,14 @@ def test_selfdestruct_to_self_same_tx( pytest.param(0, id="zero_balance"), ], ) +@pytest.mark.with_all_create_opcodes def test_selfdestruct_to_different_address_same_tx( state_test: StateTestFiller, env: Environment, pre: Alloc, sender: EOA, contract_balance: int, + create_opcode: Op, ) -> None: """ Test same-tx selfdestruct to different address. @@ -147,12 +161,18 @@ def test_selfdestruct_to_different_address_same_tx( factory_code = Op.MSTORE( 0, Op.PUSH32(initcode_bytes.rjust(32, b"\x00")) - ) + Op.CREATE( + ) + create_opcode( value=Op.CALLVALUE, offset=32 - initcode_len, size=initcode_len ) factory = pre.deploy_contract(factory_code) - created_address = compute_create_address(address=factory, nonce=1) + created_address = compute_create_address( + address=factory, + nonce=1, + salt=0, + initcode=initcode_bytes, + opcode=create_opcode, + ) if contract_balance > 0: expected_logs = [ @@ -364,11 +384,7 @@ def test_finalization_selfdestruct_logs( reverse_sorted = list(reversed(sorted_addrs)) # Runtime: selfdestruct on first call, STOP on subsequent calls - selfdestruct_target: Address | Opcodes - if to_self: - selfdestruct_target = Op.ADDRESS - else: - selfdestruct_target = beneficiary + target: Address | Opcodes = Op.ADDRESS if to_self else beneficiary runtime = ( Op.TLOAD(0) + Op.ISZERO @@ -377,7 +393,7 @@ def test_finalization_selfdestruct_logs( + Op.STOP + Op.JUMPDEST + Op.TSTORE(0, 1) - + Op.SELFDESTRUCT(selfdestruct_target) + + Op.SELFDESTRUCT(target) ) initcode = Initcode(deploy_code=runtime) initcode_len = len(initcode) @@ -497,3 +513,155 @@ def test_finalization_selfdestruct_logs( ) state_test(env=env, pre=pre, post=post, tx=tx) + + +@pytest.mark.parametrize( + "funded_after_selfdestruct", + [ + pytest.param(True, id="funded_after_selfdestruct"), + pytest.param(False, id="miner_fee_only"), + ], +) +def test_selfdestruct_finalization_after_priority_fee( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + funded_after_selfdestruct: bool, +) -> None: + """ + Verify finalization burn logs are emitted after priority fee payment. + + Sets coinbase to a contract that self-destructs in the same tx. The + finalization burn log includes the priority fee, proving finalization + happens after fee payment per EIP-7708. + + funded_after_selfdestruct: + - if True: payer sends ETH, finalization = funding + priority_fee + - if False: no payer, finalization = priority_fee only + """ + contract_balance = 1000 + funding_amount = 10_000 if funded_after_selfdestruct else 0 + + sender = pre.fund_eoa() + + factory_address = compute_create_address(address=sender, nonce=0) + created_address = compute_create_address(address=factory_address, nonce=1) + coinbase = created_address # coinbase == self-destructed contract + + # inner contract: simple SELFDESTRUCT to self + runtime_code = Op.SELFDESTRUCT(Op.ADDRESS) + initcode = Initcode(deploy_code=runtime_code) + initcode_len = len(initcode) + + gas_costs = fork.gas_costs() + mem_after_mstore = ((initcode_len + 31) // 32) * 32 + + # The base factory code: CREATE + CALL to trigger selfdestruct + factory_code = Om.MSTORE( + initcode, 0, new_memory_size=mem_after_mstore + ) + Op.CALL( + gas=100_000, + address=Op.CREATE( + value=contract_balance, + offset=0, + size=initcode_len, + init_code_size=initcode_len, + ), + address_warm=True, + ) + + # optionally add payer call to fund coinbase after selfdestruct + payer = None + payer_runtime_gas = 0 + if funded_after_selfdestruct: + payer_code = Op.SELFDESTRUCT(Op.CALLDATALOAD(0)) + payer = pre.deploy_contract(payer_code, balance=funding_amount) + factory_code += Op.MSTORE(0, created_address) + factory_code += Op.CALL( + gas=100_000, address=payer, args_offset=0, args_size=32 + ) + payer_runtime_gas = Op.SELFDESTRUCT( + Op.CALLDATALOAD(0), address_warm=True, account_new=False + ).gas_cost(fork) + + pre.fund_address(factory_address, contract_balance) + + # prio fee calc + genesis_base_fee = 7 + gas_price = 10 + base_fee = fork.base_fee_per_gas_calculator()( + parent_base_fee_per_gas=genesis_base_fee, + parent_gas_used=0, + parent_gas_limit=Environment().gas_limit, + ) + priority_fee_per_gas = gas_price - base_fee + + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + calldata=bytes(factory_code), + contract_creation=True, + ) + factory_gas = factory_code.gas_cost(fork) + initcode_exec_gas = initcode.execution_gas + code_deposit_gas = len(runtime_code) * gas_costs.GAS_CODE_DEPOSIT_PER_BYTE + inner_runtime_gas = Op.SELFDESTRUCT( + Op.ADDRESS, address_warm=True, account_new=False + ).gas_cost(fork) + + gas_used = ( + intrinsic_gas + + factory_gas + + initcode_exec_gas + + code_deposit_gas + + inner_runtime_gas + + payer_runtime_gas + ) + priority_fee = priority_fee_per_gas * gas_used + + # Finalization burn log proves coinbase received priority fee before log + finalization_balance = funding_amount + priority_fee + + expected_logs = [ + transfer_log(factory_address, created_address, contract_balance), + selfdestruct_log(created_address, contract_balance), + ] + + # if funded after selfdestruct, expect transfer log from payer + if funded_after_selfdestruct: + assert payer is not None + expected_logs.append( + transfer_log(payer, created_address, funding_amount) + ) + + # finalization selfdestruct log + expected_logs.append( + selfdestruct_log(created_address, finalization_balance) + ) + + tx = Transaction( + sender=sender, + to=None, + value=0, + data=factory_code, + gas_limit=500_000, + gas_price=gas_price, + expected_receipt=TransactionReceipt(logs=expected_logs), + ) + + post: dict[Address, Account | None] = { + created_address: Account.NONEXISTENT, + } + if payer is not None: + post[payer] = Account(balance=0) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + fee_recipient=coinbase, + header_verify=Header(base_fee_per_gas=base_fee), + ) + ], + post=post, + genesis_environment=Environment(base_fee_per_gas=genesis_base_fee), + ) From 513aaa71af0ba62e61e894428aa941accb8c83ca Mon Sep 17 00:00:00 2001 From: felipe Date: Thu, 12 Feb 2026 13:43:46 -0700 Subject: [PATCH 10/24] fix(test): Use new ``pre_alloc_mutable`` marker for eip7708 test (#2199) --- tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py index 585f69e706f..2d6066c989a 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py @@ -648,7 +648,7 @@ def test_stack_underflow_no_log( state_test(env=env, pre=pre, post={}, tx=tx) -@pytest.mark.pre_alloc_modify +@pytest.mark.pre_alloc_mutable @pytest.mark.with_all_create_opcodes def test_create_collision_no_log( state_test: StateTestFiller, From 7ae0d53e33c8c3b40288bfd7ecc6f430919b3951 Mon Sep 17 00:00:00 2001 From: fselmo Date: Fri, 13 Feb 2026 11:03:59 -0700 Subject: [PATCH 11/24] fix(test): Update test to account for recent Initcode updates --- .../eip7708_eth_transfer_logs/test_selfdestruct_logs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_selfdestruct_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_selfdestruct_logs.py index d74aa9bc635..fd9d478f30e 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_selfdestruct_logs.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_selfdestruct_logs.py @@ -601,7 +601,7 @@ def test_selfdestruct_finalization_after_priority_fee( contract_creation=True, ) factory_gas = factory_code.gas_cost(fork) - initcode_exec_gas = initcode.execution_gas + initcode_exec_gas = initcode.execution_gas(fork) code_deposit_gas = len(runtime_code) * gas_costs.GAS_CODE_DEPOSIT_PER_BYTE inner_runtime_gas = Op.SELFDESTRUCT( Op.ADDRESS, address_warm=True, account_new=False From 1f1d7578359892f906c14f3e96354181869a9352 Mon Sep 17 00:00:00 2001 From: felipe Date: Fri, 13 Feb 2026 11:54:46 -0700 Subject: [PATCH 12/24] =?UTF-8?q?=F0=9F=A7=B9=20chore:=20Remove=20duplicat?= =?UTF-8?q?e=20test=20(#2210)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: raxhvl --- .../test_transfer_logs.py | 65 ------------------- 1 file changed, 65 deletions(-) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py index 2d6066c989a..e89fdf093bf 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py @@ -311,71 +311,6 @@ def test_create_opcode_emits_log( state_test(env=env, pre=pre, post=post, tx=tx) -@pytest.mark.with_all_create_opcodes -def test_selfdestruct_during_initcode( - state_test: StateTestFiller, - env: Environment, - pre: Alloc, - sender: EOA, - create_opcode: Op, -) -> None: - """ - Test that SELFDESTRUCT during initcode emits transfer log. - - Contract is created with value, then immediately self-destructs to a - beneficiary during initcode execution (before deployment completes). - Expected logs: - - CREATE transfer: factory -> created_address - - SELFDESTRUCT transfer: created_address -> beneficiary - """ - beneficiary = pre.deploy_contract(Op.STOP) - - # Initcode that self-destructs to beneficiary (contract never deploys code) - initcode = Op.SELFDESTRUCT(beneficiary) - initcode_bytes = bytes(initcode) - initcode_len = len(initcode_bytes) - - if create_opcode == Op.CREATE: - factory_code = Op.MSTORE( - 0, Op.PUSH32(initcode_bytes.rjust(32, b"\x00")) - ) + Op.CREATE(value=1, offset=32 - initcode_len, size=initcode_len) - else: - factory_code = Op.MSTORE( - 0, Op.PUSH32(initcode_bytes.rjust(32, b"\x00")) - ) + Op.CREATE2( - value=1, offset=32 - initcode_len, size=initcode_len, salt=0 - ) - - factory = pre.deploy_contract(factory_code, balance=1) - - # Compute created address - if create_opcode == Op.CREATE: - created_address = compute_create_address(address=factory, nonce=1) - else: - created_address = compute_create2_address( - address=factory, salt=0, initcode=initcode_bytes - ) - - tx = Transaction( - sender=sender, - to=factory, - value=0, - gas_limit=200_000, - expected_receipt=TransactionReceipt( - logs=[ - # CREATE transfers value to new contract - transfer_log(factory, created_address, 1), - # SELFDESTRUCT transfers balance to beneficiary - transfer_log(created_address, beneficiary, 1), - ] - ), - ) - - # Beneficiary receives the balance, created contract is destroyed - post = {beneficiary: Account(balance=1)} - state_test(env=env, pre=pre, post=post, tx=tx) - - @pytest.mark.with_all_create_opcodes def test_initcode_calls_with_value( state_test: StateTestFiller, From 34f9953771c1fbc6f201f3c6bff0a7b5770bc1cf Mon Sep 17 00:00:00 2001 From: raxhvl <10168946+raxhvl@users.noreply.github.com> Date: Wed, 25 Feb 2026 01:27:34 +0100 Subject: [PATCH 13/24] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(tests):=20R?= =?UTF-8?q?ename=20EIP=207708=20selfdestruct=20log=20to=20burn=20(#2211)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ♻️ refactor: Rename selfdestruct log to burn * 🥢 nit: * chore(tests): fix stale selfdestruct references and rename to burn --------- Co-authored-by: raxhvl Co-authored-by: spencer-tb --- src/ethereum/forks/amsterdam/fork.py | 2 +- src/ethereum/forks/amsterdam/vm/__init__.py | 12 +++--- .../forks/amsterdam/vm/instructions/system.py | 4 +- .../eip7708_eth_transfer_logs/spec.py | 10 ++--- ...selfdestruct_logs.py => test_burn_logs.py} | 40 +++++++++---------- .../test_fork_transition.py | 12 +++--- .../test_transfer_logs.py | 2 +- 7 files changed, 39 insertions(+), 43 deletions(-) rename tests/amsterdam/eip7708_eth_transfer_logs/{test_selfdestruct_logs.py => test_burn_logs.py} (94%) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 272bc236813..677fe3009ec 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -1074,7 +1074,7 @@ def process_transaction( Log( address=vm.SYSTEM_ADDRESS, topics=( - vm.SELFDESTRUCT_TOPIC, + vm.BURN_TOPIC, Hash32(padded_address), ), data=balance.to_be_bytes32(), diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index aac70362e66..2ae8d4c984c 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -32,7 +32,7 @@ __all__ = ("Environment", "Evm", "Message") TRANSFER_TOPIC = keccak256(b"Transfer(address,address,uint256)") -SELFDESTRUCT_TOPIC = keccak256(b"Selfdestruct(address,uint256)") +BURN_TOPIC = keccak256(b"Burn(address,uint256)") SYSTEM_ADDRESS = Address( bytes.fromhex("fffffffffffffffffffffffffffffffffffffffe") ) @@ -243,22 +243,22 @@ def emit_transfer_log( evm.logs = evm.logs + (log_entry,) -def emit_selfdestruct_log( +def emit_burn_log( evm: Evm, account: Address, amount: U256, ) -> None: """ - Emit a LOG2 for self-destruct to self (balance burn) per EIP-7708. + Emit a LOG2 for ETH burn per EIP-7708. Parameters ---------- evm : The state of the ethereum virtual machine account : - The account address being selfdestructed + The account address whose ETH is being burned amount : - The amount of ETH being destroyed + The amount of ETH being burned """ if amount == 0: @@ -268,7 +268,7 @@ def emit_selfdestruct_log( log_entry = Log( address=SYSTEM_ADDRESS, topics=( - SELFDESTRUCT_TOPIC, + BURN_TOPIC, Hash32(padded_account), ), data=amount.to_be_bytes32(), diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 10089afe165..93710b641af 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -39,7 +39,7 @@ CALL_SUCCESS, Evm, Message, - emit_selfdestruct_log, + emit_burn_log, emit_transfer_log, incorporate_child_on_error, incorporate_child_on_success, @@ -614,7 +614,7 @@ def selfdestruct(evm: Evm) -> None: # or transferred to a different account if originator in tx_state.created_accounts and beneficiary == originator: # Self-destruct to self in same tx burns the balance - emit_selfdestruct_log(evm, originator, originator_balance) + emit_burn_log(evm, originator, originator_balance) elif beneficiary != originator: # Transfer to different beneficiary emit_transfer_log(evm, originator, beneficiary, originator_balance) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/spec.py b/tests/amsterdam/eip7708_eth_transfer_logs/spec.py index fb8deffdda4..c6f6560bbf6 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/spec.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/spec.py @@ -31,9 +31,7 @@ class Spec: TRANSFER_TOPIC: Hash = Hash( keccak256(b"Transfer(address,address,uint256)") ) - SELFDESTRUCT_TOPIC: Hash = Hash( - keccak256(b"Selfdestruct(address,uint256)") - ) + BURN_TOPIC: Hash = Hash(keccak256(b"Burn(address,uint256)")) def transfer_log( @@ -51,12 +49,12 @@ def transfer_log( ) -def selfdestruct_log(contract_address: Address, amount: int) -> TransactionLog: - """Create an expected Selfdestruct log for EIP-7708.""" +def burn_log(contract_address: Address, amount: int) -> TransactionLog: + """Create an expected Burn log for EIP-7708.""" return TransactionLog( address=Spec.SYSTEM_ADDRESS, topics=[ - Spec.SELFDESTRUCT_TOPIC, + Spec.BURN_TOPIC, Hash(bytes(contract_address).rjust(32, b"\x00")), ], data=Bytes(amount.to_bytes(32, "big")), diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_selfdestruct_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py similarity index 94% rename from tests/amsterdam/eip7708_eth_transfer_logs/test_selfdestruct_logs.py rename to tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py index fd9d478f30e..69fc2bb1811 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_selfdestruct_logs.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py @@ -1,7 +1,7 @@ """ -Tests for EIP-7708 Selfdestruct logs. +Tests for EIP-7708 Burn logs. -Tests for the Selfdestruct(address,uint256) log emitted when: +Tests for the Burn(address,uint256) log emitted when: - SELFDESTRUCT to self with nonzero balance - Account created and destroyed in the same transaction """ @@ -30,7 +30,7 @@ Macros as Om, ) -from .spec import ref_spec_7708, selfdestruct_log, transfer_log +from .spec import burn_log, ref_spec_7708, transfer_log REFERENCE_SPEC_GIT_PATH = ref_spec_7708.git_path REFERENCE_SPEC_VERSION = ref_spec_7708.version @@ -47,7 +47,7 @@ def test_selfdestruct_to_self_pre_existing_no_log( """ Test that selfdestruct-to-self emits NO log for pre-existing contracts. - Selfdestruct log only emitted when created and destroyed in same tx. + Burn log only emitted when created and destroyed in same tx. """ contract_balance = 2000 @@ -90,7 +90,7 @@ def test_selfdestruct_to_self_same_tx( """ Test selfdestruct-to-self for same-tx created contracts. - - With balance, SELFDESTRUCT log emitted (burns ETH). + - With balance, Burn log emitted (burns ETH). - No balance, no logs expected. """ initcode = Op.SELFDESTRUCT(Op.ADDRESS) @@ -116,7 +116,7 @@ def test_selfdestruct_to_self_same_tx( expected_logs = [ transfer_log(sender, factory, contract_balance), transfer_log(factory, created_address, contract_balance), - selfdestruct_log(created_address, contract_balance), + burn_log(created_address, contract_balance), ] else: expected_logs = [] @@ -277,12 +277,12 @@ def test_selfdestruct_same_tx_via_call( if to_self: expected_logs = [ transfer_log(factory, created_address, contract_balance), - selfdestruct_log(created_address, contract_balance), + burn_log(created_address, contract_balance), ] if call_twice and second_call_value > 0: expected_logs += [ transfer_log(factory, created_address, second_call_value), - selfdestruct_log(created_address, second_call_value), + burn_log(created_address, second_call_value), ] post = {} else: @@ -346,7 +346,7 @@ def test_selfdestruct_same_tx_via_call( pytest.param(True, id="to_self"), ], ) -def test_finalization_selfdestruct_logs( +def test_finalization_burn_logs( state_test: StateTestFiller, env: Environment, pre: Alloc, @@ -356,10 +356,10 @@ def test_finalization_selfdestruct_logs( to_self: bool, ) -> None: """ - Test Selfdestruct logs at finalization for post-selfdestruct balance. + Test Burn logs at finalization for post-selfdestruct balance. X contracts (x1, x2, x3) selfdestruct, then receive ETH via payer contracts - (p1, p2, p3). At finalization, X contracts emit SELFDESTRUCT logs for their + (p1, p2, p3). At finalization, X contracts emit Burn logs for their in lexicographical address order (only if they received ETH). When to_self=True, X contracts SELFDESTRUCT to themselves (burning ETH @@ -446,12 +446,12 @@ def test_finalization_selfdestruct_logs( ] if to_self: - # SELFDESTRUCT to self burns ETH → LOG2 Selfdestruct + # SELFDESTRUCT to self burns ETH → LOG2 Burn execution_logs.extend( [ - selfdestruct_log(x1, 1000), - selfdestruct_log(x2, 2000), - selfdestruct_log(x3, 3000), + burn_log(x1, 1000), + burn_log(x2, 2000), + burn_log(x3, 3000), ] ) beneficiary_balance = 0 @@ -489,7 +489,7 @@ def test_finalization_selfdestruct_logs( ) # Finalization logs emitted in SORTED address order (not call order) finalization_logs = [ - selfdestruct_log(addr, amounts[addr]) for addr in sorted_addrs + burn_log(addr, amounts[addr]) for addr in sorted_addrs ] post = { x1: Account.NONEXISTENT, @@ -622,7 +622,7 @@ def test_selfdestruct_finalization_after_priority_fee( expected_logs = [ transfer_log(factory_address, created_address, contract_balance), - selfdestruct_log(created_address, contract_balance), + burn_log(created_address, contract_balance), ] # if funded after selfdestruct, expect transfer log from payer @@ -632,10 +632,8 @@ def test_selfdestruct_finalization_after_priority_fee( transfer_log(payer, created_address, funding_amount) ) - # finalization selfdestruct log - expected_logs.append( - selfdestruct_log(created_address, finalization_balance) - ) + # finalization burn log + expected_logs.append(burn_log(created_address, finalization_balance)) tx = Transaction( sender=sender, diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_fork_transition.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_fork_transition.py index 5f5c5700385..96b8fb6df3a 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_fork_transition.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_fork_transition.py @@ -17,7 +17,7 @@ compute_create_address, ) -from .spec import ref_spec_7708, selfdestruct_log, transfer_log +from .spec import burn_log, ref_spec_7708, transfer_log REFERENCE_SPEC_GIT_PATH = ref_spec_7708.git_path REFERENCE_SPEC_VERSION = ref_spec_7708.version @@ -32,17 +32,17 @@ ], ) @pytest.mark.valid_at_transition_to("Amsterdam") -def test_selfdestruct_log_at_fork_transition( +def test_burn_log_at_fork_transition( blockchain_test: BlockchainTestFiller, pre: Alloc, same_tx: bool, to_self: bool, ) -> None: """ - Test selfdestruct log emission across the Amsterdam fork transition. + Test burn log emission across the Amsterdam fork transition. same_tx_to_self: Factory CREATEs and selfdestructs to self in one tx. - At/after Amsterdam emits a CREATE transfer log + Selfdestruct log. + At/after Amsterdam emits a CREATE transfer log + Burn log. pre_existing_to_self: Pre-existing contract selfdestructs to self. No logs at any fork — SELFDESTRUCT to same account emits nothing. @@ -77,11 +77,11 @@ def test_selfdestruct_log_at_fork_transition( [], [ transfer_log(factory, created[1], contract_balance), - selfdestruct_log(created[1], contract_balance), + burn_log(created[1], contract_balance), ], [ transfer_log(factory, created[2], contract_balance), - selfdestruct_log(created[2], contract_balance), + burn_log(created[2], contract_balance), ], ] post: dict = { diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py index e89fdf093bf..78698129132 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py @@ -1208,7 +1208,7 @@ def test_selfdestruct_to_self_cross_tx_no_log( Test that selfdestruct-to-self in a cross-tx context emits no log. A contract created in Tx1 is not in created_accounts during Tx2. - Selfdestruct-to-self in Tx2 emits no log per EIP-7708: no Selfdestruct + Selfdestruct-to-self in Tx2 emits no log per EIP-7708: no Burn log (not same-tx) and no Transfer log (not a different account). Tx1: Contract creation tx (to=None) deploying SELFDESTRUCT(ADDRESS), From 5eaa1ee56adfdb1252d46e06e1dc81b4a69dfe2f Mon Sep 17 00:00:00 2001 From: marioevz Date: Tue, 24 Mar 2026 16:50:29 -0600 Subject: [PATCH 14/24] fix(specs): Merge issues --- src/ethereum/forks/amsterdam/fork.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 677fe3009ec..6bb03075264 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -37,6 +37,7 @@ State, apply_changes_to_state, ) +from ethereum.utils.byte import left_pad_zero_bytes from . import vm from .block_access_lists import ( From 0fbab5b31ce80fadcdcd1b128d649dd71d51cd34 Mon Sep 17 00:00:00 2001 From: marioevz Date: Tue, 24 Mar 2026 16:50:41 -0600 Subject: [PATCH 15/24] fix(tests): Merge issues --- .../test_eip_mainnet.py | 4 ++-- .../test_fork_transition.py | 4 ++-- .../test_transfer_logs.py | 18 +++++++++--------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_eip_mainnet.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_eip_mainnet.py index 171500f5d2c..188cb79a4bf 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_eip_mainnet.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_eip_mainnet.py @@ -27,7 +27,7 @@ def test_simple_transfer_mainnet( ) -> None: """Test that a simple ETH transfer emits a transfer log on mainnet.""" sender = pre.fund_eoa() - recipient = pre.empty_account() + recipient = pre.nonexistent_account() tx = Transaction( ty=0x02, @@ -76,7 +76,7 @@ def test_selfdestruct_mainnet( ) -> None: """Test that SELFDESTRUCT emits a transfer log on mainnet.""" sender = pre.fund_eoa() - beneficiary = pre.empty_account() + beneficiary = pre.nonexistent_account() contract_code = Op.SELFDESTRUCT(beneficiary) contract = pre.deploy_contract(contract_code, balance=500) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_fork_transition.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_fork_transition.py index 96b8fb6df3a..10f99f86d48 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_fork_transition.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_fork_transition.py @@ -100,7 +100,7 @@ def test_burn_log_at_fork_transition( expected_logs = [[], [], []] post = {sender: Account(nonce=3)} else: - beneficiary = pre.empty_account() + beneficiary = pre.nonexistent_account() targets = [ pre.deploy_contract( Op.SELFDESTRUCT(beneficiary), balance=contract_balance @@ -146,7 +146,7 @@ def test_transfer_log_fork_transition( At/after Amsterdam: ETH transfers emit Transfer logs. """ sender = pre.fund_eoa() - recipient = pre.empty_account() + recipient = pre.nonexistent_account() blocks = [ Block( diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py index 78698129132..0269238f097 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py @@ -43,7 +43,7 @@ def test_simple_transfer_emits_log( sender: EOA, ) -> None: """Test that a simple ETH transfer emits a transfer log.""" - recipient = pre.empty_account() + recipient = pre.nonexistent_account() tx = Transaction( sender=sender, @@ -113,7 +113,7 @@ def test_zero_value_transfer_no_log( sender: EOA, ) -> None: """Test that a zero-value transfer does NOT emit a transfer log.""" - recipient = pre.empty_account() + recipient = pre.nonexistent_account() tx = Transaction( sender=sender, @@ -647,7 +647,7 @@ def test_selfdestruct_with_value_emits_log( sender: EOA, ) -> None: """Test that SELFDESTRUCT with value emits a transfer log.""" - beneficiary = pre.empty_account() + beneficiary = pre.nonexistent_account() contract_balance = 2000 contract_code = Op.SELFDESTRUCT(beneficiary) @@ -710,7 +710,7 @@ def test_zero_value_operations_no_log( op_type: str, ) -> None: """Test that zero-value operations do NOT emit transfer logs.""" - target = pre.empty_account() + target = pre.nonexistent_account() if op_type == "call": contract_code = Op.CALL(gas=100_000, address=target, value=0) @@ -902,7 +902,7 @@ def test_nested_calls_log_order( tx_value = 1000 # Build chain: contracts[0] -> contracts[1] -> ... -> final_recipient - final_recipient = pre.empty_account() + final_recipient = pre.nonexistent_account() contracts: list[Address] = [] expected_logs: list[TransactionLog] = [] @@ -1076,7 +1076,7 @@ def test_transfer_with_all_tx_types( typed_transaction: Transaction, ) -> None: """Test that ETH transfers emit logs for all transaction types.""" - recipient = pre.empty_account() + recipient = pre.nonexistent_account() transfer_amount = 1000 tx = typed_transaction.copy( @@ -1101,8 +1101,8 @@ def test_multiple_transfers_same_block( verifying logs don't bleed across transactions. """ sender = pre.fund_eoa() - recipient1 = pre.empty_account() - recipient2 = pre.empty_account() + recipient1 = pre.nonexistent_account() + recipient2 = pre.nonexistent_account() blocks = [ Block( @@ -1156,7 +1156,7 @@ def test_selfdestruct_then_transfer_same_block( - Tx2: sender -> contract (100) + contract -> beneficiary (100) """ sender = pre.fund_eoa() - beneficiary = pre.empty_account() + beneficiary = pre.nonexistent_account() contract_code = Op.SELFDESTRUCT(beneficiary) contract = pre.deploy_contract(contract_code, balance=500) From 4c405359f93a70c215522ca0b89f10f82b397a7d Mon Sep 17 00:00:00 2001 From: marioevz Date: Wed, 8 Apr 2026 15:50:26 -0600 Subject: [PATCH 16/24] refactor(test-forks): Add EIP-7708 --- .../forks/forks/eips/amsterdam/eip_7708.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7708.py diff --git a/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7708.py b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7708.py new file mode 100644 index 00000000000..f968d1b6d60 --- /dev/null +++ b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7708.py @@ -0,0 +1,15 @@ +""" +EIP-7708: ETH transfers and burns emit a log. + +All ETH transfers and burns emit a log. + +https://eips.ethereum.org/EIPS/eip-7708 +""" + +from ....base_fork import BaseFork + + +class EIP7708(BaseFork): + """EIP-7708 class.""" + + pass From a1861be202fb31db9e03f6e51d7457e2ee202b3e Mon Sep 17 00:00:00 2001 From: marioevz Date: Wed, 8 Apr 2026 15:50:46 -0600 Subject: [PATCH 17/24] refactor(tests): Condition EIP-7708 tests to EIP inclusion --- tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py | 2 +- .../amsterdam/eip7708_eth_transfer_logs/test_eip_mainnet.py | 2 +- .../eip7708_eth_transfer_logs/test_fork_transition.py | 6 +++--- .../eip7708_eth_transfer_logs/test_transfer_logs.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py index 69fc2bb1811..d7efab8935d 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py @@ -35,7 +35,7 @@ REFERENCE_SPEC_GIT_PATH = ref_spec_7708.git_path REFERENCE_SPEC_VERSION = ref_spec_7708.version -pytestmark = pytest.mark.valid_from("Amsterdam") +pytestmark = pytest.mark.valid_from("EIP7708") def test_selfdestruct_to_self_pre_existing_no_log( diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_eip_mainnet.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_eip_mainnet.py index 188cb79a4bf..4f8948c2a6c 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_eip_mainnet.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_eip_mainnet.py @@ -18,7 +18,7 @@ REFERENCE_SPEC_GIT_PATH = ref_spec_7708.git_path REFERENCE_SPEC_VERSION = ref_spec_7708.version -pytestmark = [pytest.mark.valid_at("Amsterdam"), pytest.mark.mainnet] +pytestmark = [pytest.mark.valid_at("EIP7708"), pytest.mark.mainnet] def test_simple_transfer_mainnet( diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_fork_transition.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_fork_transition.py index 10f99f86d48..4125f1a8273 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_fork_transition.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_fork_transition.py @@ -31,7 +31,7 @@ pytest.param(False, False, id="pre_existing_to_other"), ], ) -@pytest.mark.valid_at_transition_to("Amsterdam") +@pytest.mark.valid_at_transition_to("EIP7708") def test_burn_log_at_fork_transition( blockchain_test: BlockchainTestFiller, pre: Alloc, @@ -39,7 +39,7 @@ def test_burn_log_at_fork_transition( to_self: bool, ) -> None: """ - Test burn log emission across the Amsterdam fork transition. + Test burn log emission across the EIP-7708 fork transition. same_tx_to_self: Factory CREATEs and selfdestructs to self in one tx. At/after Amsterdam emits a CREATE transfer log + Burn log. @@ -135,7 +135,7 @@ def test_burn_log_at_fork_transition( blockchain_test(pre=pre, blocks=blocks, post=post) -@pytest.mark.valid_at_transition_to("Amsterdam") +@pytest.mark.valid_at_transition_to("EIP7708") def test_transfer_log_fork_transition( blockchain_test: BlockchainTestFiller, pre: Alloc ) -> None: diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py index 0269238f097..8946a9a25a7 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py @@ -33,7 +33,7 @@ REFERENCE_SPEC_GIT_PATH = ref_spec_7708.git_path REFERENCE_SPEC_VERSION = ref_spec_7708.version -pytestmark = pytest.mark.valid_from("Amsterdam") +pytestmark = pytest.mark.valid_from("EIP7708") def test_simple_transfer_emits_log( From 782b78d4751c67653798dc4d076e3c91aeaf56ee Mon Sep 17 00:00:00 2001 From: spencer Date: Mon, 20 Apr 2026 15:46:12 +0200 Subject: [PATCH 18/24] feat(tests): EIP-7708 - finalization burn log ordering + coinbase fee no-log (#2717) * feat(tests): EIP-7708 - multi-account finalization burn log ordering Adds a dedicated test that proves finalization burn logs are emitted in lexicographical address order when multiple accounts are marked for deletion in the same transaction. Parametrized over N in {2, 5}. N accounts are created and SELFDESTRUCT'd in the same tx, then funded via payer contracts called in REVERSE sorted address order with distinct nonzero amounts. Each destroyed account ends with a unique nonzero balance at finalization, so ordering by address vs. by call order is always distinguishable. Addresses issue #2691. * feat(tests): EIP-7708 - coinbase priority fee must not emit transfer log Adds a dedicated test proving the coinbase priority fee payment does not produce a Transfer log. A contract CALLs the coinbase address with nonzero value while the tx pays a nonzero priority fee to that same coinbase. Only the CALL-with-value must produce a Transfer log; the priority fee credit happens outside the EVM as a protocol-level balance change. An implementation that hooks every balance addition (instead of only CALL / SELFDESTRUCT / tx-level value transfers) would emit an extra Transfer log for the fee and fail the exact-log assertion. Addresses issue #2692. * feat(tests): add single account multi transfer test * fix(tests): minor nit --------- Co-authored-by: marioevz --- .../test_burn_logs.py | 211 ++++++++++++++++++ .../test_transfer_logs.py | 45 ++++ 2 files changed, 256 insertions(+) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py index d7efab8935d..89dfa574b16 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py @@ -515,6 +515,217 @@ def test_finalization_burn_logs( state_test(env=env, pre=pre, post=post, tx=tx) +@pytest.mark.parametrize( + "num_accounts", + [ + pytest.param(2, id="two_accounts"), + pytest.param(5, id="five_accounts"), + ], +) +def test_finalization_burn_logs_multi_account_ordering( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + fork: Fork, + num_accounts: int, +) -> None: + """ + Verify finalization burn logs are sorted lexicographically by address + when multiple accounts are marked for deletion in the same transaction. + + N accounts are created and SELFDESTRUCT'd in the same tx, then each + is funded by a dedicated payer contract called in REVERSE sorted + address order with a distinct nonzero amount. Every destroyed account + ends with a distinct nonzero balance at finalization, so a Burn log + is emitted for each. The resulting sequence of finalization burn logs + must appear in ascending address order regardless of call order. + """ + beneficiary = pre.deploy_contract(Op.STOP) + + factory_address = compute_create_address( + address=sender, nonce=sender.nonce + ) + created_addrs = [ + compute_create_address(address=factory_address, nonce=i + 1) + for i in range(num_accounts) + ] + sorted_addrs = sorted(created_addrs) + reverse_sorted = list(reversed(sorted_addrs)) + + # Each created contract is CALLed exactly once (to trigger SELFDESTRUCT); + # payers then forward via their own SELFDESTRUCT, so the created + # contracts are never re-invoked — no call-once guard is needed. + runtime = Op.SELFDESTRUCT(beneficiary) + initcode = Initcode(deploy_code=runtime) + initcode_len = len(initcode) + + create_balances = [1000 * (i + 1) for i in range(num_accounts)] + factory_balance = sum(create_balances) + pre.fund_address(factory_address, factory_balance) + + payer_code = Op.SELFDESTRUCT(Op.CALLDATALOAD(0)) + funding_amounts = [100 * (i + 1) for i in range(num_accounts)] + payers = [ + pre.deploy_contract(payer_code, balance=funding_amounts[i]) + for i in range(num_accounts) + ] + + factory_code: Bytecode = Om.MSTORE(initcode, 0) + for i in range(num_accounts): + factory_code += Op.TSTORE( + i, + Op.CREATE(value=create_balances[i], offset=0, size=initcode_len), + ) + for i in range(num_accounts): + factory_code += Op.CALL(gas=Op.GAS, address=Op.TLOAD(i), value=0) + for i in range(num_accounts): + factory_code += Op.MSTORE(0, reverse_sorted[i]) + factory_code += Op.CALL( + gas=Op.GAS, + address=payers[i], + args_offset=0, + args_size=32, + ) + + execution_logs = [ + transfer_log(factory_address, addr, create_balances[i]) + for i, addr in enumerate(created_addrs) + ] + execution_logs.extend( + transfer_log(addr, beneficiary, create_balances[i]) + for i, addr in enumerate(created_addrs) + ) + execution_logs.extend( + transfer_log(payers[i], reverse_sorted[i], funding_amounts[i]) + for i in range(num_accounts) + ) + + amount_by_addr = dict(zip(reverse_sorted, funding_amounts, strict=True)) + finalization_logs = [ + burn_log(addr, amount_by_addr[addr]) for addr in sorted_addrs + ] + + tx = Transaction( + sender=sender, + to=None, + value=0, + data=factory_code, + gas_limit=fork.transaction_gas_limit_cap(), + expected_receipt=TransactionReceipt( + logs=execution_logs + finalization_logs + ), + ) + + post: dict[Address, Account | None] = dict.fromkeys( + created_addrs, Account.NONEXISTENT + ) + post[beneficiary] = Account(balance=factory_balance) + for payer in payers: + post[payer] = Account(balance=0) + + state_test(env=env, pre=pre, post=post, tx=tx) + + +@pytest.mark.parametrize( + "num_transfers", + [ + pytest.param(2, id="two_transfers"), + pytest.param(5, id="five_transfers"), + ], +) +def test_finalization_burn_log_single_account_multiple_transfers( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + fork: Fork, + num_transfers: int, +) -> None: + """ + Verify finalization emits a single Burn log summing multiple ETH transfers + to one to-be-destructed account. + + A single account is created and SELFDESTRUCT'd in the same tx, then N + payer contracts each send a distinct nonzero amount to it. Exactly ONE + Burn log MUST be emitted at finalization with the combined residual + balance, a client emitting one log per transfer would fail. + """ + beneficiary = pre.deploy_contract(Op.STOP) + + factory_address = compute_create_address( + address=sender, nonce=sender.nonce + ) + x = compute_create_address(address=factory_address, nonce=1) + + # x is only CALLed once (to trigger SELFDESTRUCT); payers forward via + # their own SELFDESTRUCT, so no call-once guard is needed. + runtime = Op.SELFDESTRUCT(beneficiary) + initcode = Initcode(deploy_code=runtime) + initcode_len = len(initcode) + + create_balance = 1000 + pre.fund_address(factory_address, create_balance) + + # N payer contracts, each sending a distinct nonzero amount to x + payer_code = Op.SELFDESTRUCT(x) + funding_amounts = [100 * (i + 1) for i in range(num_transfers)] + payers = [ + pre.deploy_contract(payer_code, balance=funding_amounts[i]) + for i in range(num_transfers) + ] + + # Factory creates x, triggers its SELFDESTRUCT, then calls each payer with + # x as the beneficiary so each payer's balance is forwarded to x. + factory_code: Bytecode = ( + Om.MSTORE(initcode, 0) + + Op.TSTORE( + 0, Op.CREATE(value=create_balance, offset=0, size=initcode_len) + ) + + Op.CALL(gas=Op.GAS, address=Op.TLOAD(0), value=0) + ) + for i in range(num_transfers): + factory_code += Op.CALL( + gas=Op.GAS, + address=payers[i], + args_offset=0, + args_size=32, + ) + + execution_logs = [ + transfer_log(factory_address, x, create_balance), + transfer_log(x, beneficiary, create_balance), + ] + execution_logs.extend( + transfer_log(payers[i], x, funding_amounts[i]) + for i in range(num_transfers) + ) + + # Exactly one burn log with the SUM of transferred amounts + total_residual = sum(funding_amounts) + finalization_logs = [burn_log(x, total_residual)] + + tx = Transaction( + sender=sender, + to=None, + value=0, + data=factory_code, + gas_limit=fork.transaction_gas_limit_cap(), + expected_receipt=TransactionReceipt( + logs=execution_logs + finalization_logs + ), + ) + + post: dict[Address, Account | None] = { + x: Account.NONEXISTENT, + beneficiary: Account(balance=create_balance), + } + for payer in payers: + post[payer] = Account(balance=0) + + state_test(env=env, pre=pre, post=post, tx=tx) + + @pytest.mark.parametrize( "funded_after_selfdestruct", [ diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py index 8946a9a25a7..d99897d0248 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py @@ -18,6 +18,7 @@ Bytecode, Bytes, Environment, + Fork, Initcode, Op, StateTestFiller, @@ -27,6 +28,7 @@ compute_create2_address, compute_create_address, ) +from execution_testing.base_types import ZeroPaddedHexNumber from .spec import Spec, ref_spec_7708, transfer_log @@ -1298,3 +1300,46 @@ def test_call_to_delegated_account_with_value( post = {delegated_eoa: Account(balance=100)} state_test(env=env, pre=pre, post=post, tx=tx) + + +@pytest.mark.execute(pytest.mark.skip("Requires specific base fee")) +def test_call_with_value_to_coinbase_no_priority_fee_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + fork: Fork, +) -> None: + """ + Verify no Transfer log is emitted for the coinbase priority fee. + + A contract executes CALL with nonzero value to the coinbase address, + and the transaction pays a nonzero priority fee to that same + coinbase. Only the CALL-with-value must produce a Transfer log; the + priority fee crediting happens outside the EVM as a protocol-level + balance change and must not emit a log. + + An implementation that hooks all balance additions (instead of only + CALL / SELFDESTRUCT / tx-level value transfers) would emit an extra + Transfer log for the fee and fail the exact-log assertion. + """ + coinbase = env.fee_recipient + call_value = 1 + + caller_code = Op.CALL(gas=Op.GAS, address=coinbase, value=call_value) + caller = pre.deploy_contract(caller_code, balance=call_value) + env.base_fee_per_gas = ZeroPaddedHexNumber(7) + max_fee_per_gas = int(env.base_fee_per_gas) * 2 + tx = Transaction( + sender=sender, + to=caller, + value=0, + gas_limit=fork.transaction_gas_limit_cap(), + max_fee_per_gas=max_fee_per_gas, + max_priority_fee_per_gas=max_fee_per_gas, + expected_receipt=TransactionReceipt( + logs=[transfer_log(caller, coinbase, call_value)] + ), + ) + + state_test(env=env, pre=pre, post={}, tx=tx) From ee16914611a2242dc1449ce62f1e3c01d1d35a36 Mon Sep 17 00:00:00 2001 From: spencer-tb Date: Mon, 20 Apr 2026 22:57:49 +0200 Subject: [PATCH 19/24] fix(tests): rename GAS_CODE_DEPOSIT_PER_BYTE to CODE_DEPOSIT_PER_BYTE Align EIP-7708 selfdestruct finalization test with the gas constant rename on forks/amsterdam. --- tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py index 89dfa574b16..3ae3bab6762 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py @@ -813,7 +813,7 @@ def test_selfdestruct_finalization_after_priority_fee( ) factory_gas = factory_code.gas_cost(fork) initcode_exec_gas = initcode.execution_gas(fork) - code_deposit_gas = len(runtime_code) * gas_costs.GAS_CODE_DEPOSIT_PER_BYTE + code_deposit_gas = len(runtime_code) * gas_costs.CODE_DEPOSIT_PER_BYTE inner_runtime_gas = Op.SELFDESTRUCT( Op.ADDRESS, address_warm=True, account_new=False ).gas_cost(fork) From 70cf0a348b615798e6ee343bdc47261a5bc299a1 Mon Sep 17 00:00:00 2001 From: marioevz Date: Tue, 21 Apr 2026 12:20:50 +0200 Subject: [PATCH 20/24] fix(tests): EIP-7708 + 8037 cross-EIP fixes --- .../eip7708_eth_transfer_logs/spec.py | 4 +- .../test_burn_logs.py | 58 ++++++++++++++----- .../test_eip_mainnet.py | 8 ++- .../test_transfer_logs.py | 41 ++++++++++--- 4 files changed, 87 insertions(+), 24 deletions(-) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/spec.py b/tests/amsterdam/eip7708_eth_transfer_logs/spec.py index c6f6560bbf6..9d08cb17eaf 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/spec.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/spec.py @@ -49,7 +49,7 @@ def transfer_log( ) -def burn_log(contract_address: Address, amount: int) -> TransactionLog: +def burn_log(contract_address: Address, amount: int | None) -> TransactionLog: """Create an expected Burn log for EIP-7708.""" return TransactionLog( address=Spec.SYSTEM_ADDRESS, @@ -57,5 +57,5 @@ def burn_log(contract_address: Address, amount: int) -> TransactionLog: Spec.BURN_TOPIC, Hash(bytes(contract_address).rjust(32, b"\x00")), ], - data=Bytes(amount.to_bytes(32, "big")), + data=Bytes(amount.to_bytes(32, "big")) if amount is not None else None, ) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py index 3ae3bab6762..cbed1cded95 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py @@ -750,6 +750,8 @@ def test_selfdestruct_finalization_after_priority_fee( - if True: payer sends ETH, finalization = funding + priority_fee - if False: no payer, finalization = priority_fee only """ + genesis_base_fee = 7 + env = Environment(base_fee_per_gas=genesis_base_fee) contract_balance = 1000 funding_amount = 10_000 if funded_after_selfdestruct else 0 @@ -760,7 +762,25 @@ def test_selfdestruct_finalization_after_priority_fee( coinbase = created_address # coinbase == self-destructed contract # inner contract: simple SELFDESTRUCT to self - runtime_code = Op.SELFDESTRUCT(Op.ADDRESS) + runtime_code = ( + Op.SELFDESTRUCT( + Op.ADDRESS, + # Gas accounting + address_warm=True, + account_new=False, + self_destructed_account=True, + self_destructed_account_code_deposit=len( + Op.SELFDESTRUCT(address=Op.ADDRESS) + ), + ) + if fork.is_eip_enabled(8037) + else Op.SELFDESTRUCT( + Op.ADDRESS, + # Gas accounting + address_warm=True, + account_new=False, + ) + ) initcode = Initcode(deploy_code=runtime_code) initcode_len = len(initcode) @@ -768,10 +788,13 @@ def test_selfdestruct_finalization_after_priority_fee( mem_after_mstore = ((initcode_len + 31) // 32) * 32 # The base factory code: CREATE + CALL to trigger selfdestruct + call_gas = 100_000 + if fork.is_eip_enabled(8037): + call_gas = 500_000 factory_code = Om.MSTORE( initcode, 0, new_memory_size=mem_after_mstore ) + Op.CALL( - gas=100_000, + gas=call_gas, address=Op.CREATE( value=contract_balance, offset=0, @@ -789,7 +812,7 @@ def test_selfdestruct_finalization_after_priority_fee( payer = pre.deploy_contract(payer_code, balance=funding_amount) factory_code += Op.MSTORE(0, created_address) factory_code += Op.CALL( - gas=100_000, address=payer, args_offset=0, args_size=32 + gas=call_gas, address=payer, args_offset=0, args_size=32 ) payer_runtime_gas = Op.SELFDESTRUCT( Op.CALLDATALOAD(0), address_warm=True, account_new=False @@ -798,12 +821,11 @@ def test_selfdestruct_finalization_after_priority_fee( pre.fund_address(factory_address, contract_balance) # prio fee calc - genesis_base_fee = 7 gas_price = 10 base_fee = fork.base_fee_per_gas_calculator()( parent_base_fee_per_gas=genesis_base_fee, parent_gas_used=0, - parent_gas_limit=Environment().gas_limit, + parent_gas_limit=env.gas_limit, ) priority_fee_per_gas = gas_price - base_fee @@ -814,9 +836,7 @@ def test_selfdestruct_finalization_after_priority_fee( factory_gas = factory_code.gas_cost(fork) initcode_exec_gas = initcode.execution_gas(fork) code_deposit_gas = len(runtime_code) * gas_costs.CODE_DEPOSIT_PER_BYTE - inner_runtime_gas = Op.SELFDESTRUCT( - Op.ADDRESS, address_warm=True, account_new=False - ).gas_cost(fork) + inner_runtime_gas = runtime_code.gas_cost(fork) gas_used = ( intrinsic_gas @@ -826,10 +846,17 @@ def test_selfdestruct_finalization_after_priority_fee( + inner_runtime_gas + payer_runtime_gas ) - priority_fee = priority_fee_per_gas * gas_used + + inner_runtime_refund = runtime_code.refund(fork) + gas_refunds = inner_runtime_refund + discount = min( + gas_refunds, + gas_used // 5, # max discount EIP-3529 + ) + priority_fee = priority_fee_per_gas * (gas_used - discount) # Finalization burn log proves coinbase received priority fee before log - finalization_balance = funding_amount + priority_fee + finalization_balance: int | None = funding_amount + priority_fee expected_logs = [ transfer_log(factory_address, created_address, contract_balance), @@ -844,14 +871,19 @@ def test_selfdestruct_finalization_after_priority_fee( ) # finalization burn log + if fork.is_eip_enabled(8037): + # TODO: Fix calculation of the exact expected gas usage + finalization_balance = None expected_logs.append(burn_log(created_address, finalization_balance)) - + gas_limit = 500_000 + if fork.is_eip_enabled(8037): + gas_limit = 2_000_000 tx = Transaction( sender=sender, to=None, value=0, data=factory_code, - gas_limit=500_000, + gas_limit=gas_limit, gas_price=gas_price, expected_receipt=TransactionReceipt(logs=expected_logs), ) @@ -872,5 +904,5 @@ def test_selfdestruct_finalization_after_priority_fee( ) ], post=post, - genesis_environment=Environment(base_fee_per_gas=genesis_base_fee), + genesis_environment=env, ) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_eip_mainnet.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_eip_mainnet.py index 4f8948c2a6c..73edc070e00 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_eip_mainnet.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_eip_mainnet.py @@ -7,6 +7,7 @@ from execution_testing import ( Account, Alloc, + Fork, Op, StateTestFiller, Transaction, @@ -73,6 +74,7 @@ def test_call_with_value_mainnet( def test_selfdestruct_mainnet( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test that SELFDESTRUCT emits a transfer log on mainnet.""" sender = pre.fund_eoa() @@ -81,12 +83,16 @@ def test_selfdestruct_mainnet( contract_code = Op.SELFDESTRUCT(beneficiary) contract = pre.deploy_contract(contract_code, balance=500) + gas_limit = 100_000 + if fork.is_eip_enabled(8037): + gas_limit = 500_000 + tx = Transaction( ty=0x02, sender=sender, to=contract, value=0, - gas_limit=100_000, + gas_limit=gas_limit, expected_receipt=TransactionReceipt( logs=[transfer_log(contract, beneficiary, 500)] ), diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py index d99897d0248..3e2e7bf1e46 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py @@ -140,6 +140,7 @@ def test_contract_creation_tx( env: Environment, pre: Alloc, sender: EOA, + fork: Fork, tx_value: int, expect_log: bool, ) -> None: @@ -150,12 +151,14 @@ def test_contract_creation_tx( expected_logs = ( [transfer_log(sender, created_address, tx_value)] if expect_log else [] ) - + gas_limit = 100_000 + if fork.is_eip_enabled(8037): + gas_limit = 500_000 tx = Transaction( sender=sender, to=None, value=tx_value, - gas_limit=100_000, + gas_limit=gas_limit, data=bytes(initcode), expected_receipt=TransactionReceipt(logs=expected_logs), ) @@ -271,6 +274,7 @@ def test_create_opcode_emits_log( env: Environment, pre: Alloc, sender: EOA, + fork: Fork, create_opcode: Op, create_value: int, ) -> None: @@ -301,11 +305,15 @@ def test_create_opcode_emits_log( transfer_log(contract, created_address, create_value) ) + gas_limit = 200_000 + if fork.is_eip_enabled(8037): + gas_limit = 1_000_000 + tx = Transaction( sender=sender, to=contract, value=1, - gas_limit=200_000, + gas_limit=gas_limit, expected_receipt=TransactionReceipt(logs=expected_logs), ) @@ -517,11 +525,14 @@ def test_create_out_of_gas_no_log( env: Environment, pre: Alloc, sender: EOA, + fork: Fork, initcode: Bytecode, ) -> None: """Test that CREATE running out of gas does NOT emit transfer log.""" tx_value = 1000 gas_limit = 100_000 + if fork.is_eip_enabled(8037): + gas_limit = 500_000 create_value = 500 contract_code = Op.CALLDATACOPY( dest_offset=0, @@ -646,6 +657,7 @@ def test_selfdestruct_with_value_emits_log( state_test: StateTestFiller, env: Environment, pre: Alloc, + fork: Fork, sender: EOA, ) -> None: """Test that SELFDESTRUCT with value emits a transfer log.""" @@ -655,11 +667,15 @@ def test_selfdestruct_with_value_emits_log( contract_code = Op.SELFDESTRUCT(beneficiary) contract = pre.deploy_contract(contract_code, balance=contract_balance) + gas_limit = 100_000 + if fork.is_eip_enabled(8037): + gas_limit = 500_000 + tx = Transaction( sender=sender, to=contract, value=0, - gas_limit=100_000, + gas_limit=gas_limit, expected_receipt=TransactionReceipt( logs=[transfer_log(contract, beneficiary, contract_balance)] ), @@ -674,6 +690,7 @@ def test_selfdestruct_to_system_address( env: Environment, pre: Alloc, sender: EOA, + fork: Fork, ) -> None: """ Test SELFDESTRUCT sending ETH to the EIP-7708 system address. @@ -683,11 +700,15 @@ def test_selfdestruct_to_system_address( contract_code = Op.SELFDESTRUCT(Spec.SYSTEM_ADDRESS) contract = pre.deploy_contract(contract_code, balance=1) + gas_limit = 100_000 + if fork.is_eip_enabled(8037): + gas_limit = 500_000 + tx = Transaction( sender=sender, to=contract, value=0, - gas_limit=100_000, + gas_limit=gas_limit, expected_receipt=TransactionReceipt( logs=[transfer_log(contract, Spec.SYSTEM_ADDRESS, 1)] ), @@ -1144,7 +1165,7 @@ def test_multiple_transfers_same_block( def test_selfdestruct_then_transfer_same_block( - blockchain_test: BlockchainTestFiller, pre: Alloc + blockchain_test: BlockchainTestFiller, pre: Alloc, fork: Fork ) -> None: """ Test transfer to address that selfdestructed earlier in the same block. @@ -1163,6 +1184,10 @@ def test_selfdestruct_then_transfer_same_block( contract_code = Op.SELFDESTRUCT(beneficiary) contract = pre.deploy_contract(contract_code, balance=500) + gas_limit = 100_000 + if fork.is_eip_enabled(8037): + gas_limit = 500_000 + blocks = [ Block( txs=[ @@ -1171,7 +1196,7 @@ def test_selfdestruct_then_transfer_same_block( sender=sender, nonce=0, value=0, - gas_limit=100_000, + gas_limit=gas_limit, expected_receipt=TransactionReceipt( logs=[transfer_log(contract, beneficiary, 500)] ), @@ -1181,7 +1206,7 @@ def test_selfdestruct_then_transfer_same_block( sender=sender, nonce=1, value=100, - gas_limit=100_000, + gas_limit=gas_limit, expected_receipt=TransactionReceipt( logs=[ transfer_log(sender, contract, 100), From 155c3e136d81dcd5c61086c9d832595b7641db4a Mon Sep 17 00:00:00 2001 From: Mario Vega Date: Mon, 27 Apr 2026 16:08:54 +0200 Subject: [PATCH 21/24] feat(tests): Add EIP-7708 checks to 6780 tests (#2743) * feat(tests): Add EIP-7708 checks to 6780 tests * fix: add another call and properly accumulate so that burn logs are validated * fix(tests): Review fixes --------- Co-authored-by: carsons-eels --- .../test_journal_revert.py | 11 + .../test_reentrancy_selfdestruct_revert.py | 89 +++- .../eip6780_selfdestruct/test_selfdestruct.py | 427 +++++++++++++++++- 3 files changed, 489 insertions(+), 38 deletions(-) diff --git a/tests/cancun/eip6780_selfdestruct/test_journal_revert.py b/tests/cancun/eip6780_selfdestruct/test_journal_revert.py index 9168e1e6408..1ed5f267e5a 100644 --- a/tests/cancun/eip6780_selfdestruct/test_journal_revert.py +++ b/tests/cancun/eip6780_selfdestruct/test_journal_revert.py @@ -7,10 +7,12 @@ Account, Alloc, Environment, + Fork, Op, StateTestFiller, Storage, Transaction, + TransactionReceipt, ) REFERENCE_SPEC_GIT_PATH = "EIPS/eip-6780.md" @@ -22,6 +24,7 @@ def test_selfdestruct_balance_transfer_reverted( state_test: StateTestFiller, env: Environment, pre: Alloc, + fork: Fork, ) -> None: """ Test that SELFDESTRUCT balance transfer is reverted on sub-call revert. @@ -64,6 +67,13 @@ def test_selfdestruct_balance_transfer_reverted( sender = pre.fund_eoa() + # Under EIP-7708 the SELFDESTRUCT-triggered Transfer log is emitted inside + # the reverted sub-call, so it must be discarded together with the rest of + # the reverted state. + expected_receipt = ( + TransactionReceipt(logs=[]) if fork.is_eip_enabled(7708) else None + ) + state_test( env=env, pre=pre, @@ -78,5 +88,6 @@ def test_selfdestruct_balance_transfer_reverted( sender=sender, to=outer, gas_limit=1_000_000, + expected_receipt=expected_receipt, ), ) diff --git a/tests/cancun/eip6780_selfdestruct/test_reentrancy_selfdestruct_revert.py b/tests/cancun/eip6780_selfdestruct/test_reentrancy_selfdestruct_revert.py index 7848ea91ac6..6179045a240 100644 --- a/tests/cancun/eip6780_selfdestruct/test_reentrancy_selfdestruct_revert.py +++ b/tests/cancun/eip6780_selfdestruct/test_reentrancy_selfdestruct_revert.py @@ -1,4 +1,7 @@ -"""Suicide scenario requested test https://github.com/ethereum/tests/issues/1325.""" +""" +Self-destruct scenario requested test +https://github.com/ethereum/tests/issues/1325. +""" from typing import SupportsBytes @@ -14,9 +17,12 @@ Op, StateTestFiller, Transaction, + TransactionReceipt, ) from execution_testing.forks import Cancun +from tests.amsterdam.eip7708_eth_transfer_logs.spec import transfer_log + REFERENCE_SPEC_GIT_PATH = "EIPS/eip-6780.md" REFERENCE_SPEC_VERSION = "1b6a0e94cc47e859b9866e570391cf37dc55059a" @@ -49,7 +55,7 @@ def selfdestruct_contract_address( @pytest.fixture def executor_contract_bytecode( - first_suicide: Op, + first_selfdestruct: Op, revert_contract_address: Address, selfdestruct_contract_address: Address, ) -> Bytecode: @@ -58,9 +64,11 @@ def executor_contract_bytecode( Op.SSTORE( 1, ( - first_suicide(address=selfdestruct_contract_address, value=0) - if first_suicide in [Op.CALL, Op.CALLCODE] - else first_suicide(address=selfdestruct_contract_address) + first_selfdestruct( + address=selfdestruct_contract_address, value=0 + ) + if first_selfdestruct in [Op.CALL, Op.CALLCODE] + else first_selfdestruct(address=selfdestruct_contract_address) ), ) + Op.SSTORE(2, Op.CALL(address=revert_contract_address)) @@ -100,14 +108,14 @@ def executor_contract_address( @pytest.fixture def revert_contract_bytecode( - second_suicide: Op, + second_selfdestruct: Op, selfdestruct_contract_address: Address, ) -> Bytecode: """Contract code that performs a call and then reverts.""" call_op = ( - second_suicide(address=selfdestruct_contract_address, value=100) - if second_suicide in [Op.CALL, Op.CALLCODE] - else second_suicide(address=selfdestruct_contract_address) + second_selfdestruct(address=selfdestruct_contract_address, value=100) + if second_selfdestruct in [Op.CALL, Op.CALLCODE] + else second_selfdestruct(address=selfdestruct_contract_address) ) return Op.MSTORE(0, Op.ADD(15, call_op)) + Op.REVERT(0, 32) @@ -131,18 +139,18 @@ def revert_contract_address( @pytest.mark.valid_from("Paris") @pytest.mark.parametrize( - "first_suicide", [Op.CALL, Op.CALLCODE, Op.DELEGATECALL] + "first_selfdestruct", [Op.CALL, Op.CALLCODE, Op.DELEGATECALL] ) @pytest.mark.parametrize( - "second_suicide", [Op.CALL, Op.CALLCODE, Op.DELEGATECALL] + "second_selfdestruct", [Op.CALL, Op.CALLCODE, Op.DELEGATECALL] ) def test_reentrancy_selfdestruct_revert( pre: Alloc, env: Environment, sender: EOA, fork: Fork, - first_suicide: Op, - second_suicide: Op, + first_selfdestruct: Op, + second_selfdestruct: Op, state_test: StateTestFiller, selfdestruct_contract_bytecode: Bytecode, selfdestruct_contract_address: Address, @@ -171,23 +179,24 @@ def test_reentrancy_selfdestruct_revert( ), } - if first_suicide in [Op.CALLCODE, Op.DELEGATECALL]: + if first_selfdestruct in [Op.CALLCODE, Op.DELEGATECALL]: if fork >= Cancun: # On Cancun even callcode/delegatecall does not remove the account, # so the value remain post[executor_contract_address] = Account( storage={ - 0x01: 0x01, # First call to contract S->suicide success - 0x02: 0x00, # Second call to contract S->suicide reverted + 0x01: 0x01, # 1st call to contract S->selfdestruct success + 0x02: 0x00, # 2nd call to contract S->selfdestruct revert 0x03: 16, # Reverted value to check that revert really # worked }, ) else: - # Callcode executed first suicide from sender. sender is deleted + # Callcode executed first selfdestruct from sender. + # Sender is deleted. post[executor_contract_address] = Account.NONEXISTENT # type: ignore - # Original suicide account remains in state + # Original selfdestruct account remains in state post[selfdestruct_contract_address] = Account( balance=selfdestruct_contract_init_balance, storage={} ) @@ -196,19 +205,19 @@ def test_reentrancy_selfdestruct_revert( balance=executor_contract_init_balance, ) - # On Cancun suicide no longer destroys the account from state, just cleans - # the balance - if first_suicide in [Op.CALL]: + # On Cancun selfdestruct no longer destroys the account from state, just + # cleans the balance + if first_selfdestruct in [Op.CALL]: post[executor_contract_address] = Account( storage={ - 0x01: 0x01, # First call to contract S->suicide success - 0x02: 0x00, # Second call to contract S->suicide reverted + 0x01: 0x01, # First call to contract S->selfdestruct success + 0x02: 0x00, # Second call to contract S->selfdestruct reverted 0x03: 16, # Reverted value to check that revert really worked }, ) if fork >= Cancun: - # On Cancun suicide does not remove the account, just sends the - # balance + # On Cancun selfdestruct does not remove the account, just sends + # the balance post[selfdestruct_contract_address] = Account( balance=0, code=selfdestruct_contract_bytecode, storage={} ) @@ -220,11 +229,41 @@ def test_reentrancy_selfdestruct_revert( balance=selfdestruct_contract_init_balance, ) + # Under EIP-7708 the first SELFDESTRUCT emits a Transfer log to the + # recipient; the second SELFDESTRUCT happens inside the reverted frame so + # its logs are discarded. For CALL the transfer is from S; for + # CALLCODE/DELEGATECALL the code runs in executor's context, so the + # transfer is from executor. + expected_receipt = None + if fork.is_eip_enabled(7708): + if first_selfdestruct == Op.CALL: + expected_logs = [ + transfer_log( + selfdestruct_contract_address, + selfdestruct_recipient_address, + selfdestruct_contract_init_balance, + ) + ] + elif first_selfdestruct in [Op.CALLCODE, Op.DELEGATECALL]: + expected_logs = [ + transfer_log( + executor_contract_address, + selfdestruct_recipient_address, + executor_contract_init_balance, + ) + ] + else: + raise RuntimeError( + f"Unexpected opcode for test: {first_selfdestruct}" + ) + expected_receipt = TransactionReceipt(logs=expected_logs) + tx = Transaction( sender=sender, to=executor_contract_address, gas_limit=500_000, value=0, + expected_receipt=expected_receipt, ) state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/cancun/eip6780_selfdestruct/test_selfdestruct.py b/tests/cancun/eip6780_selfdestruct/test_selfdestruct.py index 37b589fd3f3..07a42652b2d 100644 --- a/tests/cancun/eip6780_selfdestruct/test_selfdestruct.py +++ b/tests/cancun/eip6780_selfdestruct/test_selfdestruct.py @@ -24,10 +24,16 @@ StateTestFiller, Storage, Transaction, + TransactionReceipt, compute_create_address, ) from execution_testing.forks import Cancun +from tests.amsterdam.eip7708_eth_transfer_logs.spec import ( + burn_log, + transfer_log, +) + REFERENCE_SPEC_GIT_PATH = "EIPS/eip-6780.md" REFERENCE_SPEC_VERSION = "1b6a0e94cc47e859b9866e570391cf37dc55059a" @@ -197,6 +203,7 @@ def test_create_selfdestruct_same_tx( state_test: StateTestFiller, pre: Alloc, sender: EOA, + fork: Fork, selfdestruct_code: Bytecode, sendall_recipient_addresses: List[Address], create_opcode: Op, @@ -231,8 +238,8 @@ def test_create_selfdestruct_same_tx( initcode=selfdestruct_contract_initcode, opcode=create_opcode, ) - for i in range(len(sendall_recipient_addresses)): - if sendall_recipient_addresses[i] == SELF_ADDRESS: + for i, addr in enumerate(sendall_recipient_addresses): + if addr == SELF_ADDRESS: sendall_recipient_addresses[i] = selfdestruct_contract_address if selfdestruct_contract_initial_balance > 0: pre.fund_address( @@ -281,8 +288,13 @@ def test_create_selfdestruct_same_tx( Op.EXTCODEHASH(selfdestruct_contract_address), ) + # Precompute entry_code_address (for Transfer log sender attribution) + entry_code_address = compute_create_address(address=sender, nonce=0) + # Call the self-destructing contract multiple times as required, increasing - # the wei sent each time + # the wei sent each time. Also track the sequence of EIP-7708 logs so they + # can be asserted as expected receipt logs. + expected_logs_after_tx_value: list = [] entry_code_balance = 0 for i, sendall_recipient in zip( range(call_times), cycle(sendall_recipient_addresses) @@ -303,6 +315,33 @@ def test_create_selfdestruct_same_tx( entry_code_balance += i selfdestruct_contract_current_balance += i + # CALL with value > 0 emits a Transfer log (entry_code -> contract) + if i > 0: + expected_logs_after_tx_value.append( + transfer_log( + entry_code_address, selfdestruct_contract_address, i + ) + ) + + # SELFDESTRUCT emits a Transfer log to a different address, or a Burn + # log when sending to self (contract was created in this tx). + if selfdestruct_contract_current_balance > 0: + if sendall_recipient == selfdestruct_contract_address: + expected_logs_after_tx_value.append( + burn_log( + selfdestruct_contract_address, + selfdestruct_contract_current_balance, + ) + ) + else: + expected_logs_after_tx_value.append( + transfer_log( + selfdestruct_contract_address, + sendall_recipient, + selfdestruct_contract_current_balance, + ) + ) + # Balance is always sent to other contracts if sendall_recipient != selfdestruct_contract_address: sendall_final_balances[sendall_recipient] += ( @@ -342,7 +381,7 @@ def test_create_selfdestruct_same_tx( gas_limit=500_000, ) - entry_code_address = tx.created_contract + assert tx.created_contract == entry_code_address post: Dict[Address, Account] = { entry_code_address: Account( @@ -356,11 +395,21 @@ def test_create_selfdestruct_same_tx( post[selfdestruct_contract_address] = Account.NONEXISTENT # type: ignore + if fork.is_eip_enabled(7708): + expected_logs = [] + if entry_code_balance > 0: + # tx value transfer: sender -> entry_code_address + expected_logs.append( + transfer_log(sender, entry_code_address, entry_code_balance) + ) + expected_logs.extend(expected_logs_after_tx_value) + tx.expected_receipt = TransactionReceipt(logs=expected_logs) + state_test(pre=pre, post=post, tx=tx) @pytest.mark.parametrize("create_opcode", [Op.CREATE, Op.CREATE2]) -@pytest.mark.parametrize("call_times", [0, 1]) +@pytest.mark.parametrize("call_times", [0, 1, 2]) @pytest.mark.parametrize( "selfdestruct_contract_initial_balance", [0, 100_000], @@ -370,6 +419,7 @@ def test_self_destructing_initcode( state_test: StateTestFiller, pre: Alloc, sender: EOA, + fork: Fork, selfdestruct_code: Bytecode, sendall_recipient_addresses: List[Address], create_opcode: Op, @@ -452,7 +502,7 @@ def test_self_destructing_initcode( entry_code_balance += i entry_code += Op.SSTORE( - entry_code_storage.store_next(0), + entry_code_storage.store_next(entry_code_balance), Op.BALANCE(selfdestruct_contract_address), ) @@ -489,6 +539,38 @@ def test_self_destructing_initcode( ), } + if fork.is_eip_enabled(7708): + expected_logs = [] + # tx value transfer: sender -> entry_code_address (created contract) + if entry_code_balance > 0: + expected_logs.append( + transfer_log(sender, entry_code_address, entry_code_balance) + ) + # Initcode SELFDESTRUCT sends pre-existing balance to the recipient. + if selfdestruct_contract_initial_balance > 0: + expected_logs.append( + transfer_log( + selfdestruct_contract_address, + sendall_recipient_addresses[0], + selfdestruct_contract_initial_balance, + ) + ) + # CALLs to the destroyed contract transfer ETH to it. + for i in range(call_times): + if i > 0: + expected_logs.append( + transfer_log( + entry_code_address, selfdestruct_contract_address, i + ) + ) + # At finalization the (destroyed) contract has the accumulated + # post-SELFDESTRUCT balance, which is burned. + if entry_code_balance > 0: + expected_logs.append( + burn_log(selfdestruct_contract_address, entry_code_balance) + ) + tx.expected_receipt = TransactionReceipt(logs=expected_logs) + state_test(pre=pre, post=post, tx=tx) @@ -502,6 +584,7 @@ def test_self_destructing_initcode_create_tx( state_test: StateTestFiller, pre: Alloc, sender: EOA, + fork: Fork, tx_value: int, selfdestruct_code: Bytecode, sendall_recipient_addresses: List[Address], @@ -541,6 +624,22 @@ def test_self_destructing_initcode_create_tx( ), } + if fork.is_eip_enabled(7708): + expected_logs = [] + if tx_value > 0: + expected_logs.append( + transfer_log(sender, selfdestruct_contract_address, tx_value) + ) + if sendall_amount > 0: + expected_logs.append( + transfer_log( + selfdestruct_contract_address, + sendall_recipient_addresses[0], + sendall_amount, + ) + ) + tx.expected_receipt = TransactionReceipt(logs=expected_logs) + state_test(pre=pre, post=post, tx=tx) @@ -571,6 +670,7 @@ def test_recreate_self_destructed_contract_different_txs( blockchain_test: BlockchainTestFiller, pre: Alloc, sender: EOA, + fork: Fork, selfdestruct_code: Bytecode, selfdestruct_contract_initial_balance: int, sendall_recipient_addresses: List[Address], @@ -597,7 +697,7 @@ def test_recreate_self_destructed_contract_different_txs( entry_code_storage = Storage() sendall_amount = selfdestruct_contract_initial_balance - # Bytecode used to create the contract + # Validate bytecode used to create the contract assert create_opcode != Op.CREATE, ( "cannot recreate contract using CREATE opcode" ) @@ -645,18 +745,46 @@ def test_recreate_self_destructed_contract_different_txs( selfdestruct_contract_address, selfdestruct_contract_initial_balance, ) - for i in range(len(sendall_recipient_addresses)): - if sendall_recipient_addresses[i] == SELF_ADDRESS: + for i, addr in enumerate(sendall_recipient_addresses): + if addr == SELF_ADDRESS: sendall_recipient_addresses[i] = selfdestruct_contract_address txs: List[Transaction] = [] for i in range(recreate_times + 1): + expected_receipt = None + if fork.is_eip_enabled(7708): + # First tx: contract is recreated at the pre-funded address, then + # SELFDESTRUCTs transferring initial_balance to the recipient + # (or emitting a Burn log when SD to self). Subsequent txs see + # address with 0 balance (destroyed+cleared), so no log. + tx_logs: list = [] + if i == 0 and selfdestruct_contract_initial_balance > 0: + if ( + sendall_recipient_addresses[0] + == selfdestruct_contract_address + ): + tx_logs.append( + burn_log( + selfdestruct_contract_address, + selfdestruct_contract_initial_balance, + ) + ) + else: + tx_logs.append( + transfer_log( + selfdestruct_contract_address, + sendall_recipient_addresses[0], + selfdestruct_contract_initial_balance, + ) + ) + expected_receipt = TransactionReceipt(logs=tx_logs) txs.append( Transaction( data=Hash(i), sender=sender, to=entry_code_address, gas_limit=500_000, + expected_receipt=expected_receipt, ) ) entry_code_storage[i] = selfdestruct_contract_address @@ -737,6 +865,7 @@ def test_selfdestruct_pre_existing( eip_enabled: bool, pre: Alloc, sender: EOA, + fork: Fork, selfdestruct_code: Bytecode, selfdestruct_contract_initial_balance: int, sendall_recipient_addresses: List[Address], @@ -760,8 +889,8 @@ def test_selfdestruct_pre_existing( ) entry_code_storage = Storage() - for i in range(len(sendall_recipient_addresses)): - if sendall_recipient_addresses[i] == SELF_ADDRESS: + for i, addr in enumerate(sendall_recipient_addresses): + if addr == SELF_ADDRESS: sendall_recipient_addresses[i] = selfdestruct_contract_address # Create a dict to record the expected final balances @@ -780,8 +909,12 @@ def test_selfdestruct_pre_existing( # destructing contract, as many times as required entry_code = Bytecode() + # Pre-compute the entry_code_address to use for Transfer log attribution. + entry_code_address = compute_create_address(address=sender, nonce=0) + # Call the self-destructing contract multiple times as required, increasing # the wei sent each time + expected_logs_after_tx_value: list = [] entry_code_balance = 0 for i, sendall_recipient in zip( range(call_times), cycle(sendall_recipient_addresses) @@ -802,6 +935,31 @@ def test_selfdestruct_pre_existing( entry_code_balance += i selfdestruct_contract_current_balance += i + # CALL with nonzero value emits Transfer(entry_code -> contract). + if i > 0: + expected_logs_after_tx_value.append( + transfer_log( + entry_code_address, selfdestruct_contract_address, i + ) + ) + + # SELFDESTRUCT emits Transfer to a different recipient; for a + # pre-existing contract sending to itself, no log is emitted (balance + # stays). Pre-Cancun, SD also burns on self, but EIP-7708 is + # Amsterdam+, long after EIP-6780 is enabled, so the self-keep path + # applies here. + if ( + sendall_recipient != selfdestruct_contract_address + and selfdestruct_contract_current_balance > 0 + ): + expected_logs_after_tx_value.append( + transfer_log( + selfdestruct_contract_address, + sendall_recipient, + selfdestruct_contract_current_balance, + ) + ) + # Balance is always sent to other contracts if sendall_recipient != selfdestruct_contract_address: sendall_final_balances[sendall_recipient] += ( @@ -847,7 +1005,7 @@ def test_selfdestruct_pre_existing( gas_limit=500_000, ) - entry_code_address = tx.created_contract + assert tx.created_contract == entry_code_address post: Dict[Address, Account] = { entry_code_address: Account( @@ -869,6 +1027,15 @@ def test_selfdestruct_pre_existing( else: post[selfdestruct_contract_address] = Account.NONEXISTENT # type: ignore + if fork.is_eip_enabled(7708): + expected_logs = [] + if entry_code_balance > 0: + expected_logs.append( + transfer_log(sender, entry_code_address, entry_code_balance) + ) + expected_logs.extend(expected_logs_after_tx_value) + tx.expected_receipt = TransactionReceipt(logs=expected_logs) + state_test(pre=pre, post=post, tx=tx) @@ -880,6 +1047,7 @@ def test_selfdestruct_created_same_block_different_tx( eip_enabled: bool, pre: Alloc, sender: EOA, + fork: Fork, selfdestruct_contract_initial_balance: int, sendall_recipient_addresses: List[Address], call_times: int, @@ -959,6 +1127,45 @@ def test_selfdestruct_created_same_block_different_tx( else: post[selfdestruct_contract_address] = Account.NONEXISTENT # type: ignore + tx1_receipt = None + tx2_receipt = None + if fork.is_eip_enabled(7708): + tx1_logs = [] + if selfdestruct_contract_initial_balance > 0: + tx1_logs.append( + transfer_log( + sender, + selfdestruct_contract_address, + selfdestruct_contract_initial_balance, + ) + ) + tx1_receipt = TransactionReceipt(logs=tx1_logs) + + tx2_logs = [] + if entry_code_balance > 0: + tx2_logs.append( + transfer_log(sender, entry_code_address, entry_code_balance) + ) + running_balance = selfdestruct_contract_initial_balance + for i in range(call_times): + if i > 0: + tx2_logs.append( + transfer_log( + entry_code_address, selfdestruct_contract_address, i + ) + ) + running_balance += i + if running_balance > 0: + tx2_logs.append( + transfer_log( + selfdestruct_contract_address, + sendall_recipient_addresses[0], + running_balance, + ) + ) + running_balance = 0 + tx2_receipt = TransactionReceipt(logs=tx2_logs) + txs = [ Transaction( value=selfdestruct_contract_initial_balance, @@ -966,6 +1173,7 @@ def test_selfdestruct_created_same_block_different_tx( sender=sender, to=None, gas_limit=500_000, + expected_receipt=tx1_receipt, ), Transaction( value=entry_code_balance, @@ -973,6 +1181,7 @@ def test_selfdestruct_created_same_block_different_tx( sender=sender, to=None, gas_limit=500_000, + expected_receipt=tx2_receipt, ), ] @@ -988,6 +1197,7 @@ def test_calling_from_new_contract_to_pre_existing_contract( state_test: StateTestFiller, pre: Alloc, sender: EOA, + fork: Fork, sendall_recipient_addresses: List[Address], create_opcode: Op, call_opcode: Op, @@ -1121,6 +1331,35 @@ def test_calling_from_new_contract_to_pre_existing_contract( gas_limit=500_000, ) + if fork.is_eip_enabled(7708): + # The new contract's body is a DELEGATECALL/CALLCODE to the pre- + # existing selfdestruct code, so SELFDESTRUCT runs in the NEW + # contract's context and transfers its balance to the recipient. + expected_logs = [] + if entry_code_balance > 0: + expected_logs.append( + transfer_log(sender, entry_code_address, entry_code_balance) + ) + running_balance = selfdestruct_contract_initial_balance + for i in range(call_times): + if i > 0: + expected_logs.append( + transfer_log( + entry_code_address, selfdestruct_contract_address, i + ) + ) + running_balance += i + if running_balance > 0: + expected_logs.append( + transfer_log( + selfdestruct_contract_address, + sendall_recipient_addresses[0], + running_balance, + ) + ) + running_balance = 0 + tx.expected_receipt = TransactionReceipt(logs=expected_logs) + state_test(pre=pre, post=post, tx=tx) @@ -1135,6 +1374,7 @@ def test_calling_from_pre_existing_contract_to_new_contract( eip_enabled: bool, pre: Alloc, sender: EOA, + fork: Fork, selfdestruct_code: Bytecode, sendall_recipient_addresses: List[Address], call_opcode: Op, @@ -1277,6 +1517,42 @@ def test_calling_from_pre_existing_contract_to_new_contract( else: post[caller_address] = Account.NONEXISTENT # type: ignore + if fork.is_eip_enabled(7708): + # SELFDESTRUCT runs in the caller (pre-existing) contract's context + # via DELEGATECALL/CALLCODE, so the Transfer log attributes the + # balance flow from the caller to the recipient. + expected_logs = [] + if entry_code_balance > 0: + expected_logs.append( + transfer_log(sender, entry_code_address, entry_code_balance) + ) + if selfdestruct_contract_initial_balance > 0: + # CREATE with value: entry_code -> new selfdestruct_contract + expected_logs.append( + transfer_log( + entry_code_address, + selfdestruct_contract_address, + selfdestruct_contract_initial_balance, + ) + ) + caller_running_balance = pre_existing_contract_initial_balance + for i in range(call_times): + if i > 0: + expected_logs.append( + transfer_log(entry_code_address, caller_address, i) + ) + caller_running_balance += i + if caller_running_balance > 0: + expected_logs.append( + transfer_log( + caller_address, + sendall_recipient_addresses[0], + caller_running_balance, + ) + ) + caller_running_balance = 0 + tx.expected_receipt = TransactionReceipt(logs=expected_logs) + state_test(pre=pre, post=post, tx=tx) @@ -1297,6 +1573,7 @@ def test_create_selfdestruct_same_tx_increased_nonce( state_test: StateTestFiller, pre: Alloc, sender: EOA, + fork: Fork, selfdestruct_code: Bytecode, sendall_recipient_addresses: List[Address], create_opcode: Op, @@ -1377,8 +1654,11 @@ def test_create_selfdestruct_same_tx_increased_nonce( Op.EXTCODEHASH(selfdestruct_contract_address), ) + entry_code_address = compute_create_address(address=sender, nonce=0) + # Call the self-destructing contract multiple times as required, increasing # the wei sent each time + expected_logs_after_tx_value: list = [] entry_code_balance = 0 for i, sendall_recipient in zip( range(call_times), cycle(sendall_recipient_addresses) @@ -1399,6 +1679,28 @@ def test_create_selfdestruct_same_tx_increased_nonce( entry_code_balance += i selfdestruct_contract_current_balance += i + # CALL with value > 0 emits a Transfer log (entry_code -> contract). + # The inner CREATE(value=0) prepended to selfdestruct_code does not + # emit a log (zero value). + if i > 0: + expected_logs_after_tx_value.append( + transfer_log( + entry_code_address, selfdestruct_contract_address, i + ) + ) + + # SELFDESTRUCT always sends to a pre-deployed recipient in this test + # (SELF_ADDRESS is not parametrized here), so a Transfer log is + # emitted whenever the contract has a nonzero balance. + if selfdestruct_contract_current_balance > 0: + expected_logs_after_tx_value.append( + transfer_log( + selfdestruct_contract_address, + sendall_recipient, + selfdestruct_contract_current_balance, + ) + ) + # Balance is always sent to other contracts if sendall_recipient != selfdestruct_contract_address: sendall_final_balances[sendall_recipient] += ( @@ -1438,7 +1740,7 @@ def test_create_selfdestruct_same_tx_increased_nonce( gas_limit=1_000_000, ) - entry_code_address = tx.created_contract + assert tx.created_contract == entry_code_address post: Dict[Address, Account] = { entry_code_address: Account( @@ -1468,6 +1770,15 @@ def test_create_selfdestruct_same_tx_increased_nonce( post[selfdestruct_contract_address] = Account.NONEXISTENT # type: ignore + if fork.is_eip_enabled(7708): + expected_logs = [] + if entry_code_balance > 0: + expected_logs.append( + transfer_log(sender, entry_code_address, entry_code_balance) + ) + expected_logs.extend(expected_logs_after_tx_value) + tx.expected_receipt = TransactionReceipt(logs=expected_logs) + state_test(pre=pre, post=post, tx=tx) @@ -1478,6 +1789,7 @@ def test_create_and_destroy_multiple_contracts_same_tx( state_test: StateTestFiller, pre: Alloc, sender: EOA, + fork: Fork, num_contracts: int, selfdestruct_contract_initial_balance: int, ) -> None: @@ -1583,6 +1895,23 @@ def test_create_and_destroy_multiple_contracts_same_tx( for addr in contract_addresses: post[addr] = Account.NONEXISTENT # type: ignore + if fork.is_eip_enabled(7708): + # Each contract SELFDESTRUCTs to a shared recipient after being created + # in the same tx. CREATE2 uses value=0 and CALLs use value=0, so the + # only Transfer logs are emitted when each contract's pre-funded + # balance is sent to the recipient via SELFDESTRUCT. + expected_logs = [] + if selfdestruct_contract_initial_balance > 0: + for addr in contract_addresses: + expected_logs.append( + transfer_log( + addr, + sendall_recipient, + selfdestruct_contract_initial_balance, + ) + ) + tx.expected_receipt = TransactionReceipt(logs=expected_logs) + state_test(pre=pre, post=post, tx=tx) @@ -1593,6 +1922,7 @@ def test_create_multiple_contracts_destroy_one_then_destroy_other_next_tx( eip_enabled: bool, pre: Alloc, sender: EOA, + fork: Fork, selfdestruct_contract_initial_balance: int, ) -> None: """ @@ -1694,16 +2024,48 @@ def test_create_multiple_contracts_destroy_one_then_destroy_other_next_tx( + Op.STOP ) + tx1_receipt = None + tx2_receipt = None + if fork.is_eip_enabled(7708): + # Tx1: only A SELFDESTRUCTs (flag=1), transferring its pre-funded + # balance to the shared recipient. B gets called with flag=0 and + # returns without emitting any log. + tx1_logs = [] + if selfdestruct_contract_initial_balance > 0: + tx1_logs.append( + transfer_log( + contract_a_address, + sendall_recipient, + selfdestruct_contract_initial_balance, + ) + ) + tx1_receipt = TransactionReceipt(logs=tx1_logs) + + # Tx2: B (now pre-existing) SELFDESTRUCTs transferring its + # pre-funded balance to the recipient. + tx2_logs = [] + if selfdestruct_contract_initial_balance > 0: + tx2_logs.append( + transfer_log( + contract_b_address, + sendall_recipient, + selfdestruct_contract_initial_balance, + ) + ) + tx2_receipt = TransactionReceipt(logs=tx2_logs) + txs = [ Transaction( sender=sender, to=entry_code_address, gas_limit=1_000_000, + expected_receipt=tx1_receipt, ), Transaction( sender=sender, to=tx2_caller, gas_limit=500_000, + expected_receipt=tx2_receipt, ), ] @@ -1742,6 +2104,7 @@ def test_parent_creates_child_selfdestruct_one( state_test: StateTestFiller, pre: Alloc, sender: EOA, + fork: Fork, destroy_parent: bool, selfdestruct_contract_initial_balance: int, ) -> None: @@ -1857,6 +2220,22 @@ def test_parent_creates_child_selfdestruct_one( storage={0: 1}, ) + if fork.is_eip_enabled(7708): + # Only the SELFDESTRUCT that actually runs emits a log. Both parent + # and child are pre-funded via pre.fund_address, so whichever + # SELFDESTRUCTs to the shared recipient transfers its initial_balance. + expected_logs = [] + if selfdestruct_contract_initial_balance > 0: + source = parent_address if destroy_parent else child_address + expected_logs.append( + transfer_log( + source, + sendall_recipient, + selfdestruct_contract_initial_balance, + ) + ) + tx.expected_receipt = TransactionReceipt(logs=expected_logs) + state_test(pre=pre, post=post, tx=tx) @@ -1868,6 +2247,7 @@ def test_recursive_contract_creation_and_selfdestruct( state_test: StateTestFiller, pre: Alloc, sender: EOA, + fork: Fork, recursion_depth: int, selfdestruct_on_unwind: bool, selfdestruct_contract_initial_balance: int, @@ -2022,4 +2402,25 @@ def test_recursive_contract_creation_and_selfdestruct( storage={0: 1}, ) + if fork.is_eip_enabled(7708): + # CREATE/CALL all use value=0, so the only Transfer logs come from + # each SELFDESTRUCT that runs. On unwind every contract SDs, starting + # from the deepest; otherwise only the deepest SDs. + expected_logs = [] + if selfdestruct_contract_initial_balance > 0: + sd_sources = ( + list(reversed(contract_addresses)) + if selfdestruct_on_unwind + else [contract_addresses[-1]] + ) + for addr in sd_sources: + expected_logs.append( + transfer_log( + addr, + sendall_recipient, + selfdestruct_contract_initial_balance, + ) + ) + tx.expected_receipt = TransactionReceipt(logs=expected_logs) + state_test(pre=pre, post=post, tx=tx) From 7c84033f5e6769df481641510d672e8f777f1ac8 Mon Sep 17 00:00:00 2001 From: danceratopz Date: Mon, 11 May 2026 08:37:09 +0200 Subject: [PATCH 22/24] feat(tests): cover EIP-7708 CREATE log rollback on outer revert (#2785) * feat(tests): cover EIP-7708 CREATE log rollback on outer revert Mirror `test_inner_call_succeeds_outer_reverts_no_log` for the bal-devnet-5 update that brings CREATE/CREATE2 under EIP-7708. A factory CREATEs a child with value (the deployment succeeds and a `factory -> created` log is emitted in the child frame) and then REVERTs; the receipt must record no logs because the outer revert discards the factory's frame along with every log it produced. * feat(tests): Review comments --------- Co-authored-by: marioevz --- .../test_transfer_logs.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py index 3e2e7bf1e46..23a89d74aab 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py @@ -22,6 +22,7 @@ Initcode, Op, StateTestFiller, + Storage, Transaction, TransactionLog, TransactionReceipt, @@ -905,6 +906,77 @@ def test_inner_call_succeeds_outer_reverts_no_log( state_test(env=env, pre=pre, post={}, tx=tx) +@pytest.mark.with_all_create_opcodes +def test_inner_create_succeeds_outer_reverts_no_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + fork: Fork, + create_opcode: Op, +) -> None: + """ + Test that a CREATE/CREATE2 transfer log is rolled back on outer revert. + + The factory CREATE/CREATE2s a child with value (the deployment succeeds + and a `factory -> created` log is emitted in the child frame), then the + factory itself REVERTs. Per EIP-7708 the rollback semantics mirror those + of CALL: the child log is discarded together with the rest of the + factory's frame, so the transaction receipt records no logs. + """ + create_value = 1 + initcode = Op.RETURN(0, 0) + initcode_len = len(initcode) + + factory_code = ( + Op.MSTORE(0, Op.PUSH32(bytes(initcode).rjust(32, b"\x00"))) + + Op.MSTORE( + 32, + create_opcode( + value=create_value, + offset=32 - initcode_len, + size=initcode_len, + ), + ) + + Op.REVERT(32, 32) + ) + factory = pre.deploy_contract(factory_code, balance=create_value) + + entry_storage = Storage() + expected_create_address = compute_create_address( + address=factory, + nonce=1, + salt=0, + initcode=initcode, + opcode=create_opcode, + ) + entry_code = Op.CALL( + address=factory, ret_offset=0, ret_size=32 + ) + Op.SSTORE( + entry_storage.store_next(expected_create_address), Op.MLOAD(0) + ) + entry = pre.deploy_contract(entry_code) + + gas_limit = 200_000 + if fork.is_eip_enabled(8037): + gas_limit = 1_000_000 + + tx = Transaction( + sender=sender, + to=entry, + value=0, + gas_limit=gas_limit, + expected_receipt=TransactionReceipt(logs=[]), + ) + + state_test( + env=env, + pre=pre, + post={entry: Account(storage=entry_storage)}, + tx=tx, + ) + + @pytest.mark.parametrize( "call_depth", [ From 8e7231c6b4e8d027501dba77f3176c2daef4b9fc Mon Sep 17 00:00:00 2001 From: carsons-eels Date: Tue, 12 May 2026 23:40:42 -0400 Subject: [PATCH 23/24] fix(specs-spec, tests): CR comment fixes --- .../forks/amsterdam/vm/instructions/system.py | 11 +- .../forks/amsterdam/vm/interpreter.py | 2 - .../eip7708_eth_transfer_logs/spec.py | 2 +- .../test_burn_logs.py | 70 ++++++--- .../test_transfer_logs.py | 139 +++++++++++++++--- 5 files changed, 165 insertions(+), 59 deletions(-) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 93710b641af..b5d0c9c3990 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -610,20 +610,15 @@ def selfdestruct(evm: Evm) -> None: # Transfer balance move_ether(tx_state, originator, beneficiary, originator_balance) - # EIP-7708: Emit appropriate log based on whether ETH is burned - # or transferred to a different account + # Emit transfer or burn log if originator in tx_state.created_accounts and beneficiary == originator: - # Self-destruct to self in same tx burns the balance emit_burn_log(evm, originator, originator_balance) elif beneficiary != originator: - # Transfer to different beneficiary emit_transfer_log(evm, originator, beneficiary, originator_balance) - # register account for deletion only if it was created - # in the same transaction + # Register account for deletion iff created in same transaction if originator in tx_state.created_accounts: - # If beneficiary is the same as originator, then - # the ether is burnt. + # If beneficiary and originator are the same then the ether is burnt. set_account_balance(tx_state, originator, U256(0)) evm.accounts_to_delete.add(originator) diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index 11c263561ef..522411b5018 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -262,7 +262,6 @@ def process_message(message: Message) -> Evm: accessed_storage_keys=message.accessed_storage_keys, ) - # take snapshot of state before processing the message snapshot = copy_tx_state(tx_state) if message.should_transfer_value and message.value != 0: @@ -272,7 +271,6 @@ def process_message(message: Message) -> Evm: message.current_target, message.value, ) - # EIP-7708: Only emit transfer log to a different account if message.caller != message.current_target: emit_transfer_log( evm, message.caller, message.current_target, message.value diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/spec.py b/tests/amsterdam/eip7708_eth_transfer_logs/spec.py index 9d08cb17eaf..f95378a6380 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/spec.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/spec.py @@ -14,7 +14,7 @@ class ReferenceSpec: ref_spec_7708 = ReferenceSpec( - "EIPS/eip-7708.md", "172188d7b090ed1afb876140f45e19ac00cba4bb" + "EIPS/eip-7708.md", "43a7f15cd1105f308086bed6a61e3155039271fc" ) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py index cbed1cded95..f8f8c5eeeff 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py @@ -15,6 +15,7 @@ Block, BlockchainTestFiller, Bytecode, + Conditional, Environment, Fork, Header, @@ -259,14 +260,18 @@ def test_selfdestruct_same_tx_via_call( factory_code = ( Om.MSTORE(initcode, 0) - + Op.TSTORE( + + Op.SSTORE( 0, Op.CREATE(value=create_value, offset=0, size=initcode_len) ) - + Op.CALL(gas=100_000, address=Op.TLOAD(0), value=first_call_value) + + Op.SSTORE( + 1, + Op.CALL(gas=100_000, address=Op.SLOAD(0), value=first_call_value), + ) ) if call_twice: - factory_code += Op.CALL( - gas=100_000, address=Op.TLOAD(0), value=second_call_value + factory_code += Op.SSTORE( + 2, + Op.CALL(gas=100_000, address=Op.SLOAD(0), value=second_call_value), ) factory = pre.deploy_contract( @@ -274,6 +279,13 @@ def test_selfdestruct_same_tx_via_call( ) created_address = compute_create_address(address=factory, nonce=1) + factory_storage = { + 0: created_address, + 1: 1, + } + if call_twice: + factory_storage[2] = 1 + if to_self: expected_logs = [ transfer_log(factory, created_address, contract_balance), @@ -284,7 +296,7 @@ def test_selfdestruct_same_tx_via_call( transfer_log(factory, created_address, second_call_value), burn_log(created_address, second_call_value), ] - post = {} + post = {factory: Account(storage=factory_storage)} else: expected_logs = [ transfer_log(factory, created_address, contract_balance), @@ -296,7 +308,8 @@ def test_selfdestruct_same_tx_via_call( transfer_log(created_address, beneficiary, second_call_value), ] post = { - beneficiary: Account(balance=contract_balance + second_call_value) + beneficiary: Account(balance=contract_balance + second_call_value), + factory: Account(storage=factory_storage), } tx = Transaction( @@ -385,15 +398,10 @@ def test_finalization_burn_logs( # Runtime: selfdestruct on first call, STOP on subsequent calls target: Address | Opcodes = Op.ADDRESS if to_self else beneficiary - runtime = ( - Op.TLOAD(0) - + Op.ISZERO - + Op.PUSH1(8) - + Op.JUMPI - + Op.STOP - + Op.JUMPDEST - + Op.TSTORE(0, 1) - + Op.SELFDESTRUCT(target) + runtime = Conditional( + condition=Op.ISZERO(Op.TLOAD(0)), + if_true=Op.TSTORE(0, 1) + Op.SELFDESTRUCT(target), + if_false=Op.STOP, ) initcode = Initcode(deploy_code=runtime) initcode_len = len(initcode) @@ -679,17 +687,23 @@ def test_finalization_burn_log_single_account_multiple_transfers( # x as the beneficiary so each payer's balance is forwarded to x. factory_code: Bytecode = ( Om.MSTORE(initcode, 0) - + Op.TSTORE( + + Op.SSTORE( 0, Op.CREATE(value=create_balance, offset=0, size=initcode_len) ) - + Op.CALL(gas=Op.GAS, address=Op.TLOAD(0), value=0) + + Op.SSTORE( + 1, + Op.CALL(gas=Op.GAS, address=Op.SLOAD(0), value=0), + ) ) for i in range(num_transfers): - factory_code += Op.CALL( - gas=Op.GAS, - address=payers[i], - args_offset=0, - args_size=32, + factory_code += Op.SSTORE( + 2 + i, + Op.CALL( + gas=Op.GAS, + address=payers[i], + args_offset=0, + args_size=32, + ), ) execution_logs = [ @@ -716,9 +730,14 @@ def test_finalization_burn_log_single_account_multiple_transfers( ), ) + factory_storage = {0: x, 1: 1} + for i in range(num_transfers): + factory_storage[2 + i] = 1 + post: dict[Address, Account | None] = { x: Account.NONEXISTENT, beneficiary: Account(balance=create_balance), + factory_address: Account(storage=factory_storage), } for payer in payers: post[payer] = Account(balance=0) @@ -872,12 +891,15 @@ def test_selfdestruct_finalization_after_priority_fee( # finalization burn log if fork.is_eip_enabled(8037): - # TODO: Fix calculation of the exact expected gas usage - finalization_balance = None + raise Exception( + "Test needs update: recompute exact gas usage with 8037" + ) + expected_logs.append(burn_log(created_address, finalization_balance)) gas_limit = 500_000 if fork.is_eip_enabled(8037): gas_limit = 2_000_000 + tx = Transaction( sender=sender, to=None, diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py index 23a89d74aab..61c216406f8 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py @@ -168,6 +168,54 @@ def test_contract_creation_tx( state_test(env=env, pre=pre, post=post, tx=tx) +@pytest.mark.parametrize( + "collision_nonce,collision_code", + [ + pytest.param(0, b"\x00", id="non_empty_code"), + pytest.param(1, b"", id="non_empty_nonce"), + ], +) +@pytest.mark.pre_alloc_mutable +def test_contract_creation_tx_collision( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + collision_nonce: int, + collision_code: bytes, +) -> None: + """ + Test that a contract-creating transaction with an address collision + emits no log. + + Per EIP-7610, contract creation aborts when the target address already + has non-empty code or nonce. The collision check happens before any + value transfer, so EIP-7708 emits no Transfer log. + """ + sender = pre.fund_eoa() + tx = Transaction( + sender=sender, + to=None, + value=1000, + gas_limit=200_000, + data=bytes(Op.RETURN(0, 0)), + expected_receipt=TransactionReceipt(logs=[]), + ) + + collision_address = tx.created_contract + pre[collision_address] = Account( + nonce=collision_nonce, + code=collision_code, + ) + + post = { + collision_address: Account( + nonce=collision_nonce, + code=collision_code, + ), + } + state_test(env=env, pre=pre, post=post, tx=tx) + + @pytest.mark.with_all_call_opcodes def test_call_opcodes_transfer_log_behavior( state_test: StateTestFiller, @@ -223,6 +271,50 @@ def test_call_opcodes_transfer_log_behavior( state_test(env=env, pre=pre, post=post, tx=tx) +@pytest.mark.with_all_call_opcodes( + selector=lambda call_opcode: call_opcode in (Op.CALL, Op.CALLCODE) +) +def test_call_opcodes_insufficient_balance_no_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + call_opcode: Op, +) -> None: + """ + Test CALL/CALLCODE with value exceeding caller balance. + + The opcode returns 0 (does not revert), transfers nothing, and emits + no transfer log. + + Note CALLCODE never emits a transfer log regardless + of balance — it's a self-transfer exempted by EIP-7708 — so for that + opcode the meaningful assertion is that the return value is 0. + """ + caller_balance = 1 + attempted_value = 100 + callee = pre.deploy_contract(Op.STOP) + + contract_code = Op.SSTORE( + 0, call_opcode(gas=100_000, address=callee, value=attempted_value) + ) + contract = pre.deploy_contract(contract_code, balance=caller_balance) + + tx = Transaction( + sender=sender, + to=contract, + value=0, + gas_limit=200_000, + expected_receipt=TransactionReceipt(logs=[]), + ) + + post = { + contract: Account(storage={0: 0}, balance=caller_balance), + callee: Account(balance=0), + } + state_test(env=env, pre=pre, post=post, tx=tx) + + def test_delegatecall_inner_call_with_value( state_test: StateTestFiller, env: Environment, @@ -996,34 +1088,29 @@ def test_nested_calls_log_order( transfer_value = 100 tx_value = 1000 - # Build chain: contracts[0] -> contracts[1] -> ... -> final_recipient - final_recipient = pre.nonexistent_account() - contracts: list[Address] = [] - expected_logs: list[TransactionLog] = [] - - # Build contracts in reverse order (deepest first) - next_target = final_recipient + # Build the chain from innermost outward by prepending each new caller. + # Once finished, accounts[0] is the entry contract (the tx target) and + # accounts[-1] is the final recipient. + accounts: list[Address] = [pre.nonexistent_account()] for _ in range(call_depth): - contract_code = Op.CALL( - gas=500_000, address=next_target, value=transfer_value + contract_code = Op.SSTORE( + 0, + Op.CALL(gas=500_000, address=accounts[0], value=transfer_value), + ) + accounts.insert( + 0, pre.deploy_contract(contract_code, balance=transfer_value) ) - # Each contract needs enough balance for its transfer - contract = pre.deploy_contract(contract_code, balance=transfer_value) - contracts.insert(0, contract) - next_target = contract - - # First contract is the tx target - entry_contract = contracts[0] - # Build expected logs in chronological order - # First: tx-level transfer (sender -> entry_contract) - expected_logs.append(transfer_log(sender, entry_contract, tx_value)) + entry_contract = accounts[0] + final_recipient = accounts[-1] - # Then: each CALL in order + expected_logs: list[TransactionLog] = [ + transfer_log(sender, entry_contract, tx_value) + ] for i in range(call_depth): - from_addr = contracts[i] - to_addr = contracts[i + 1] if i + 1 < call_depth else final_recipient - expected_logs.append(transfer_log(from_addr, to_addr, transfer_value)) + expected_logs.append( + transfer_log(accounts[i], accounts[i + 1], transfer_value) + ) tx = Transaction( sender=sender, @@ -1033,7 +1120,11 @@ def test_nested_calls_log_order( expected_receipt=TransactionReceipt(logs=expected_logs), ) - post = {final_recipient: Account(balance=transfer_value)} + post: dict[Address, Account] = { + final_recipient: Account(balance=transfer_value) + } + for chain_contract in accounts[:-1]: + post[chain_contract] = Account(storage={0: 1}) state_test(env=env, pre=pre, post=post, tx=tx) From d5857efa293782d3f353ea504f64590608621bdf Mon Sep 17 00:00:00 2001 From: Mario Vega Date: Wed, 13 May 2026 11:17:20 -0600 Subject: [PATCH 24/24] Apply suggestion from @marioevz --- .../test_blob_reserve_price_with_bpo_transitions.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/osaka/eip7918_blob_reserve_price/test_blob_reserve_price_with_bpo_transitions.py b/tests/osaka/eip7918_blob_reserve_price/test_blob_reserve_price_with_bpo_transitions.py index 14e8da4b8e9..810bc410635 100644 --- a/tests/osaka/eip7918_blob_reserve_price/test_blob_reserve_price_with_bpo_transitions.py +++ b/tests/osaka/eip7918_blob_reserve_price/test_blob_reserve_price_with_bpo_transitions.py @@ -557,9 +557,7 @@ def get_fork_scenarios(fork: TransitionFork) -> Iterator[ParameterSet]: ], get_fork_scenarios, ) -@pytest.mark.valid_at_transition_to( - "Osaka", subsequent_forks=True, until="BPO4" -) +@pytest.mark.valid_at_transition_to("Osaka", subsequent_forks=True) @pytest.mark.valid_for_bpo_forks() @pytest.mark.slow() def test_reserve_price_at_transition(