diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/eth_config/networks.yml b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/eth_config/networks.yml index 4b067f50bd..ce92de2959 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/eth_config/networks.yml +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/eth_config/networks.yml @@ -5,7 +5,7 @@ Mainnet: Frontier: 0 Homestead: 1150000 DAOFork: 1920000 - Tangerine: 2463000 + TangerineWhistle: 2463000 SpuriousDragon: 2675000 Byzantium: 4370000 Constantinople: 7280000 diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/eth_config/tests/test_execute_eth_config.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/eth_config/tests/test_execute_eth_config.py index 0f2f0826bb..2e47ecbfdf 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/eth_config/tests/test_execute_eth_config.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/eth_config/tests/test_execute_eth_config.py @@ -293,7 +293,7 @@ Frontier: 0 Homestead: 1150000 DAOFork: 1920000 - Tangerine: 2463000 + TangerineWhistle: 2463000 SpuriousDragon: 2675000 Byzantium: 4370000 Constantinople: 7280000 diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py index dd0a271fa4..5eca78a165 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py @@ -1208,3 +1208,54 @@ def parametrize_fork( metafunc.parametrize( param_names, param_values, scope="function", indirect=indirect ) + + +def pytest_collection_modifyitems( + config: pytest.Config, items: List[pytest.Item] +) -> None: + """ + Filter tests based on param-level validity markers. + + The pytest_generate_tests hook only considers function-level validity markers. + This hook runs after parametrization and can access all markers including + param-level ones, allowing us to properly filter tests based on param-level + valid_from/valid_until markers. + """ + items_to_remove = [] + + for i, item in enumerate(items): + # Get fork from params if available + params = None + if hasattr(item, "callspec"): + params = item.callspec.params + elif hasattr(item, "params"): + params = item.params + + if not params or "fork" not in params or params["fork"] is None: + continue + + fork: Fork = params["fork"] + + # Get all markers including param-level ones + markers = item.iter_markers() + + # Calculate valid fork set from all markers + # If this raises (e.g., duplicate markers from combining function-level + # and param-level), exit immediately with error + try: + valid_fork_set = ValidityMarker.get_test_fork_set_from_markers( + markers + ) + except Exception as e: + pytest.exit( + f"Error in test '{item.name}': {e}", + returncode=pytest.ExitCode.USAGE_ERROR, + ) + + # If the fork is not in the valid set, mark for removal + if fork not in valid_fork_set: + items_to_remove.append(i) + + # Remove items in reverse order to maintain indices + for i in reversed(items_to_remove): + del items[i] diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_bad_validity_markers.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_bad_validity_markers.py index aefd981690..c8f3000e39 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_bad_validity_markers.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_bad_validity_markers.py @@ -236,3 +236,73 @@ def test_invalid_validity_markers( errors=1, ) assert error_string in "\n".join(result.stdout.lines) + + +# --- Tests for param-level marker errors --- # + + +param_level_marker_error_test_cases = ( + ( + "param_level_valid_from_with_function_level_valid_from", + ( + """ + import pytest + @pytest.mark.parametrize( + "value", + [ + pytest.param(True, marks=pytest.mark.valid_from("Paris")), + ], + ) + @pytest.mark.valid_from("Berlin") + def test_case(state_test, value): + assert 1 + """, + "Too many 'valid_from' markers applied to test", + ), + ), + ( + "param_level_valid_until_with_function_level_valid_until", + ( + """ + import pytest + @pytest.mark.parametrize( + "value", + [ + pytest.param(True, marks=pytest.mark.valid_until("Cancun")), + ], + ) + @pytest.mark.valid_until("Prague") + def test_case(state_test, value): + assert 1 + """, + "Too many 'valid_until' markers applied to test", + ), + ), +) + + +@pytest.mark.parametrize( + "test_function, error_string", + [test_case for _, test_case in param_level_marker_error_test_cases], + ids=[test_id for test_id, _ in param_level_marker_error_test_cases], +) +def test_param_level_marker_errors( + pytester: pytest.Pytester, error_string: str, test_function: str +) -> None: + """ + Test that combining function-level and param-level validity markers + of the same type produces an error. + + Unlike function-level errors (caught during test generation), param-level + errors are caught during collection and cause pytest to exit immediately. + """ + pytester.makepyfile(test_function) + pytester.copy_example( + name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini" + ) + result = pytester.runpytest("-c", "pytest-fill.ini") + + # pytest.exit() causes the run to terminate with no test outcomes + assert result.ret != 0, "Expected non-zero exit code" + stdout = "\n".join(result.stdout.lines) + assert error_string in stdout, f"Expected '{error_string}' in output" diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_forks.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_forks.py index ed04021ded..67daaeee06 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_forks.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_forks.py @@ -41,11 +41,14 @@ def test_all_forks({StateTest.pytest_parameter_name()}): forks_under_test = forks_from_until(all_forks[0], all_forks[-1]) expected_skipped = 2 # eels doesn't support Constantinople expected_passed = ( - len(forks_under_test) * len(StateTest.supported_fixture_formats) + len([f for f in forks_under_test if not f.ignore()]) + * len(StateTest.supported_fixture_formats) - expected_skipped ) stdout = "\n".join(result.stdout.lines) for test_fork in forks_under_test: + if test_fork.ignore(): + continue for fixture_format in StateTest.supported_fixture_formats: if isinstance(fixture_format, LabeledFixtureFormat): fixture_format_label = fixture_format.label diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_markers.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_markers.py index fbbc1b3123..9379d90b40 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_markers.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_markers.py @@ -222,3 +222,182 @@ def test_fork_markers( *pytest_args, ) result.assert_outcomes(**outcomes) + + +# --- Tests for param-level validity markers --- # + + +def generate_param_level_marker_test() -> str: + """Generate a test function with param-level fork validity markers.""" + return """ +import pytest + +@pytest.mark.parametrize( + "value", + [ + pytest.param( + True, + id="from_tangerine", + marks=pytest.mark.valid_from("TangerineWhistle"), + ), + pytest.param( + False, + id="from_paris", + marks=pytest.mark.valid_from("Paris"), + ), + ], +) +@pytest.mark.state_test_only +def test_param_level_valid_from(state_test, value): + pass +""" + + +def generate_param_level_valid_until_test() -> str: + """Generate a test function with param-level valid_until markers.""" + return """ +import pytest + +@pytest.mark.parametrize( + "value", + [ + pytest.param( + True, + id="until_cancun", + marks=pytest.mark.valid_until("Cancun"), + ), + pytest.param( + False, + id="until_paris", + marks=pytest.mark.valid_until("Paris"), + ), + ], +) +@pytest.mark.state_test_only +def test_param_level_valid_until(state_test, value): + pass +""" + + +def generate_param_level_mixed_test() -> str: + """Generate a test with both function-level and param-level markers.""" + return """ +import pytest + +@pytest.mark.parametrize( + "value", + [ + pytest.param( + True, + id="all_forks", + marks=pytest.mark.valid_from("TangerineWhistle"), + ), + pytest.param( + False, + id="paris_only", + marks=pytest.mark.valid_from("Paris"), + ), + ], +) +@pytest.mark.valid_until("Cancun") +@pytest.mark.state_test_only +def test_mixed_function_and_param_markers(state_test, value): + pass +""" + + +@pytest.mark.parametrize( + "test_function,pytest_args,outcomes", + [ + pytest.param( + generate_param_level_marker_test(), + ["--from=Paris", "--until=Cancun"], + # from_tangerine: Paris, Shanghai, Cancun = 3 forks + # from_paris: Paris, Shanghai, Cancun = 3 forks + # Total: 6 tests + {"passed": 6, "failed": 0, "skipped": 0, "errors": 0}, + id="param_level_valid_from_paris_to_cancun", + ), + pytest.param( + generate_param_level_marker_test(), + ["--from=Berlin", "--until=Shanghai"], + # from_tangerine: Berlin, London, Paris, Shanghai = 4 forks + # from_paris: Paris, Shanghai = 2 forks + # Total: 6 tests + {"passed": 6, "failed": 0, "skipped": 0, "errors": 0}, + id="param_level_valid_from_berlin_to_shanghai", + ), + pytest.param( + generate_param_level_marker_test(), + ["--from=Berlin", "--until=London"], + # from_tangerine: Berlin, London = 2 forks + # from_paris: none (Paris > London) + # Total: 2 tests + {"passed": 2, "failed": 0, "skipped": 0, "errors": 0}, + id="param_level_valid_from_berlin_to_london", + ), + pytest.param( + generate_param_level_valid_until_test(), + ["--from=Paris", "--until=Prague"], + # until_cancun: Paris, Shanghai, Cancun = 3 forks + # until_paris: Paris = 1 fork + # Total: 4 tests + {"passed": 4, "failed": 0, "skipped": 0, "errors": 0}, + id="param_level_valid_until_paris_to_prague", + ), + pytest.param( + generate_param_level_valid_until_test(), + ["--from=Shanghai", "--until=Prague"], + # until_cancun: Shanghai, Cancun = 2 forks + # until_paris: none (Shanghai > Paris) + # Total: 2 tests + {"passed": 2, "failed": 0, "skipped": 0, "errors": 0}, + id="param_level_valid_until_shanghai_to_prague", + ), + pytest.param( + generate_param_level_mixed_test(), + ["--from=Berlin", "--until=Prague"], + # Function marker: valid_until("Cancun") limits to <= Cancun + # all_forks (TangerineWhistle): Berlin, London, Paris, Shanghai, Cancun = 5 + # paris_only: Paris, Shanghai, Cancun = 3 + # Total: 8 tests + {"passed": 8, "failed": 0, "skipped": 0, "errors": 0}, + id="mixed_markers_berlin_to_prague", + ), + pytest.param( + generate_param_level_mixed_test(), + ["--from=Paris", "--until=Shanghai"], + # Function marker: valid_until("Cancun") limits to <= Cancun + # Command line: --until=Shanghai further limits to <= Shanghai + # all_forks: Paris, Shanghai = 2 forks + # paris_only: Paris, Shanghai = 2 forks + # Total: 4 tests + {"passed": 4, "failed": 0, "skipped": 0, "errors": 0}, + id="mixed_markers_paris_to_shanghai", + ), + ], +) +def test_param_level_validity_markers( + pytester: pytest.Pytester, + test_function: str, + outcomes: dict, + pytest_args: List[str], +) -> None: + """ + Test param-level validity markers (valid_from, valid_until on pytest.param). + + The pytest_collection_modifyitems hook filters tests based on param-level + markers after parametrization, allowing different parameter values to have + different fork validity ranges. + """ + pytester.makepyfile(test_function) + pytester.copy_example( + name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini" + ) + result = pytester.runpytest( + "-c", + "pytest-fill.ini", + "-v", + *pytest_args, + ) + result.assert_outcomes(**outcomes) diff --git a/packages/testing/src/execution_testing/client_clis/clis/evmone.py b/packages/testing/src/execution_testing/client_clis/clis/evmone.py index da9c0a1d7d..e3e5c89c72 100644 --- a/packages/testing/src/execution_testing/client_clis/clis/evmone.py +++ b/packages/testing/src/execution_testing/client_clis/clis/evmone.py @@ -47,6 +47,12 @@ class EvmOneTransitionTool(TransitionTool): supports_opcode_count: ClassVar[bool] = True supports_blob_params: ClassVar[bool] = True + # evmone uses space-separated fork names for some forks + fork_name_map: ClassVar[Dict[str, str]] = { + "TangerineWhistle": "Tangerine Whistle", + "SpuriousDragon": "Spurious Dragon", + } + def __init__( self, *, diff --git a/packages/testing/src/execution_testing/client_clis/transition_tool.py b/packages/testing/src/execution_testing/client_clis/transition_tool.py index 149b0b837d..1ac1b4cffe 100644 --- a/packages/testing/src/execution_testing/client_clis/transition_tool.py +++ b/packages/testing/src/execution_testing/client_clis/transition_tool.py @@ -145,6 +145,7 @@ class TransitionTool(EthereumCLI): supports_xdist: ClassVar[bool] = True supports_blob_params: ClassVar[bool] = False + fork_name_map: ClassVar[Dict[str, str]] = {} @abstractmethod def __init__( @@ -326,13 +327,19 @@ def _evaluate_filesystem( } output_paths["body"] = os.path.join("output", "txs.rlp") + # Get fork name and apply any tool-specific mapping + fork_name = ( + t8n_data.fork_name_if_supports_blob_params + if self.supports_blob_params + else t8n_data.fork_name + ) + fork_name = self.fork_name_map.get(fork_name, fork_name) + # Construct args for evmone-t8n binary args = [ str(self.binary), "--state.fork", - t8n_data.fork_name_if_supports_blob_params - if self.supports_blob_params - else t8n_data.fork_name, + fork_name, "--input.alloc", input_paths["alloc"], "--input.env", diff --git a/packages/testing/src/execution_testing/forks/__init__.py b/packages/testing/src/execution_testing/forks/__init__.py index 009d13837f..fb5e9b4c76 100644 --- a/packages/testing/src/execution_testing/forks/__init__.py +++ b/packages/testing/src/execution_testing/forks/__init__.py @@ -25,6 +25,8 @@ Paris, Prague, Shanghai, + SpuriousDragon, + TangerineWhistle, ) from .forks.transition import ( BerlinToLondonAt5, @@ -97,6 +99,8 @@ "Frontier", "GrayGlacier", "Homestead", + "TangerineWhistle", + "SpuriousDragon", "InvalidForkError", "Istanbul", "London", diff --git a/packages/testing/src/execution_testing/forks/base_fork.py b/packages/testing/src/execution_testing/forks/base_fork.py index 7d4e13eb92..2e3f23f160 100644 --- a/packages/testing/src/execution_testing/forks/base_fork.py +++ b/packages/testing/src/execution_testing/forks/base_fork.py @@ -576,6 +576,12 @@ def get_reward(cls, *, block_number: int = 0, timestamp: int = 0) -> int: # Transaction related abstract methods + @classmethod + @abstractmethod + def supports_protected_txs(cls) -> bool: + """Return whether the fork implements EIP-155 transaction protection""" + pass + @classmethod @abstractmethod def tx_types( diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index c6b45b426f..f8c582ad12 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -568,6 +568,13 @@ def get_reward(cls, *, block_number: int = 0, timestamp: int = 0) -> int: del block_number, timestamp return 5_000_000_000_000_000_000 + @classmethod + def supports_protected_txs(cls) -> bool: + """ + At Genesis, fork does not have support for EIP-155 protected transactions. + """ + return False + @classmethod def tx_types( cls, *, block_number: int = 0, timestamp: int = 0 @@ -935,19 +942,24 @@ class DAOFork(Homestead, ignore=True): pass -class Tangerine(DAOFork, ignore=True): - """Tangerine fork (EIP-150).""" +class TangerineWhistle(DAOFork, ignore=True): + """TangerineWhistle fork (EIP-150).""" pass -class SpuriousDragon(Tangerine, ignore=True): - """SpuriousDragon fork (EIP-155, EIP-158).""" +class SpuriousDragon(TangerineWhistle, ignore=True): + """SpuriousDragon fork.""" - pass + @classmethod + def supports_protected_txs(cls) -> bool: + """ + At Genesis, supports EIP-155 protected transactions. + """ + return True -class Byzantium(Homestead): +class Byzantium(SpuriousDragon): """Byzantium fork.""" @classmethod diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index 4509c5917f..e56fb0cccd 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -196,7 +196,6 @@ def set_delegation(message: Message) -> U256: authority_account = get_account(state, authority) authority_code = authority_account.code - track_address(message.tx_env.state_changes, authority) if authority_code and not is_valid_delegation(authority_code): diff --git a/src/ethereum/forks/amsterdam/vm/instructions/environment.py b/src/ethereum/forks/amsterdam/vm/instructions/environment.py index 3d23b8f136..5ee84a3d49 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/environment.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/environment.py @@ -37,7 +37,6 @@ calculate_blob_gas_price, calculate_gas_extend_memory, charge_gas, - check_gas, ) from ..stack import pop, push @@ -81,16 +80,16 @@ def balance(evm: Evm) -> None: # GAS is_cold_access = address not in evm.accessed_addresses gas_cost = GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS - check_gas(evm, gas_cost) if is_cold_access: evm.accessed_addresses.add(address) - track_address(evm.state_changes, address) + charge_gas(evm, gas_cost) # OPERATION # Non-existent accounts default to EMPTY_ACCOUNT, which has balance 0. state = evm.message.block_env.state balance = get_account(state, address).balance + track_address(evm.state_changes, address) push(evm.stack, balance) @@ -351,15 +350,15 @@ def extcodesize(evm: Evm) -> None: access_gas_cost = ( GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS ) - check_gas(evm, access_gas_cost) if is_cold_access: evm.accessed_addresses.add(address) - track_address(evm.state_changes, address) + charge_gas(evm, access_gas_cost) # OPERATION state = evm.message.block_env.state code = get_account(state, address).code + track_address(evm.state_changes, address) codesize = U256(len(code)) push(evm.stack, codesize) @@ -397,16 +396,16 @@ def extcodecopy(evm: Evm) -> None: ) total_gas_cost = access_gas_cost + copy_gas_cost + extend_memory.cost - check_gas(evm, total_gas_cost) if is_cold_access: evm.accessed_addresses.add(address) - track_address(evm.state_changes, address) + charge_gas(evm, total_gas_cost) # OPERATION evm.memory += b"\x00" * extend_memory.expand_by state = evm.message.block_env.state code = get_account(state, address).code + track_address(evm.state_changes, address) value = buffer_read(code, code_start_index, size) memory_write(evm.memory, memory_start_index, value) @@ -491,15 +490,15 @@ def extcodehash(evm: Evm) -> None: access_gas_cost = ( GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS ) - check_gas(evm, access_gas_cost) if is_cold_access: evm.accessed_addresses.add(address) - track_address(evm.state_changes, address) + charge_gas(evm, access_gas_cost) # OPERATION state = evm.message.block_env.state account = get_account(state, address) + track_address(evm.state_changes, address) if account == EMPTY_ACCOUNT: codehash = U256(0) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 02604f68f2..9b54fab312 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -120,7 +120,6 @@ def generic_create( evm.accessed_addresses.add(contract_address) track_address(evm.state_changes, contract_address) - if account_has_code_or_nonce( state, contract_address ) or account_has_storage(state, contract_address): @@ -640,7 +639,8 @@ def selfdestruct(evm: Evm) -> None: state = evm.message.block_env.state if is_cold_access: evm.accessed_addresses.add(beneficiary) - track_address(evm.state_changes, beneficiary) + + track_address(evm.state_changes, beneficiary) if ( not is_account_alive(state, beneficiary) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py index 99f2ab58ba..c0e8886d35 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py @@ -1729,254 +1729,6 @@ def test_bal_extcodecopy_and_oog( ) -@pytest.mark.parametrize( - "self_destruct_in_same_tx", [True, False], ids=["same_tx", "new_tx"] -) -@pytest.mark.parametrize( - "pre_funded", [True, False], ids=["pre_funded", "not_pre_funded"] -) -def test_bal_self_destruct( - pre: Alloc, - blockchain_test: BlockchainTestFiller, - self_destruct_in_same_tx: bool, - pre_funded: bool, -) -> None: - """Ensure BAL captures balance changes caused by `SELFDESTRUCT`.""" - alice = pre.fund_eoa() - bob = pre.fund_eoa(amount=0) - - selfdestruct_code = ( - Op.SLOAD(0x01) # Read from storage slot 0x01 - + Op.SSTORE(0x02, 0x42) # Write to storage slot 0x02 - + Op.SELFDESTRUCT(bob) - ) - # A pre existing self-destruct contract with initial storage - kaboom = pre.deploy_contract(code=selfdestruct_code, storage={0x01: 0x123}) - - # A template for self-destruct contract - self_destruct_init_code = Initcode(deploy_code=selfdestruct_code) - template = pre.deploy_contract(code=self_destruct_init_code) - - transfer_amount = expected_recipient_balance = 100 - pre_fund_amount = 10 - - if self_destruct_in_same_tx: - # The goal is to create a self-destructing contract in the same - # transaction to trigger deletion of code as per EIP-6780. - # The factory contract below creates a new self-destructing - # contract and calls it in this transaction. - - bytecode_size = len(self_destruct_init_code) - factory_bytecode = ( - # Clone template memory - Op.EXTCODECOPY(template, 0, 0, bytecode_size) - # Fund 100 wei and deploy the clone - + Op.CREATE(transfer_amount, 0, bytecode_size) - # Call the clone, which self-destructs - + Op.CALL(1_000_000, Op.DUP6, 0, 0, 0, 0, 0) - + Op.STOP - ) - - factory = pre.deploy_contract(code=factory_bytecode) - kaboom_same_tx = compute_create_address(address=factory, nonce=1) - - # Determine which account will be self-destructed - self_destructed_account = ( - kaboom_same_tx if self_destruct_in_same_tx else kaboom - ) - - if pre_funded: - expected_recipient_balance += pre_fund_amount - pre.fund_address( - address=self_destructed_account, amount=pre_fund_amount - ) - - tx = Transaction( - sender=alice, - to=factory if self_destruct_in_same_tx else kaboom, - value=transfer_amount, - gas_limit=1_000_000, - ) - - block = Block( - txs=[tx], - expected_block_access_list=BlockAccessListExpectation( - account_expectations={ - alice: BalAccountExpectation( - nonce_changes=[ - BalNonceChange(block_access_index=1, post_nonce=1) - ], - ), - bob: BalAccountExpectation( - balance_changes=[ - BalBalanceChange( - block_access_index=1, - post_balance=expected_recipient_balance, - ) - ] - ), - self_destructed_account: BalAccountExpectation( - balance_changes=[ - BalBalanceChange(block_access_index=1, post_balance=0) - ] - if pre_funded - else [], - # Accessed slots for same-tx are recorded as reads (0x02) - storage_reads=[0x01, 0x02] - if self_destruct_in_same_tx - else [0x01], - # Storage changes are recorded for non-same-tx - # self-destructs - storage_changes=[ - BalStorageSlot( - slot=0x02, - slot_changes=[ - BalStorageChange( - block_access_index=1, post_value=0x42 - ) - ], - ) - ] - if not self_destruct_in_same_tx - else [], - code_changes=[], # should not be present - nonce_changes=[], # should not be present - ), - } - ), - ) - - post: Dict[Address, Account] = { - alice: Account(nonce=1), - bob: Account(balance=expected_recipient_balance), - } - - # If the account was self-destructed in the same transaction, - # we expect the account to non-existent and its balance to be 0. - if self_destruct_in_same_tx: - post.update( - { - factory: Account( - nonce=2, # incremented after CREATE - balance=0, # spent on CREATE - code=factory_bytecode, - ), - kaboom_same_tx: Account.NONEXISTENT, # type: ignore - # The pre-existing contract remains unaffected - kaboom: Account( - balance=0, code=selfdestruct_code, storage={0x01: 0x123} - ), - } - ) - else: - post.update( - { - # This contract was self-destructed in a separate tx. - # From EIP 6780: `SELFDESTRUCT` does not delete any data - # (including storage keys, code, or the account itself). - kaboom: Account( - balance=0, - code=selfdestruct_code, - storage={0x01: 0x123, 0x2: 0x42}, - ), - } - ) - - blockchain_test( - pre=pre, - blocks=[block], - post=post, - ) - - -@pytest.mark.parametrize("oog_before_state_access", [True, False]) -def test_bal_self_destruct_oog( - pre: Alloc, - blockchain_test: BlockchainTestFiller, - fork: Fork, - oog_before_state_access: bool, -) -> None: - """ - Test SELFDESTRUCT BAL behavior at gas boundaries. - - SELFDESTRUCT has two gas checkpoints: - 1. static checks: G_SELF_DESTRUCT + G_COLD_ACCOUNT_ACCESS - OOG here = no state access, beneficiary NOT in BAL - 2. state access: same as static checks, plus G_NEW_ACCOUNT for new account - OOG here = enough gas to access state but not enough for new account, - beneficiary IS in BAL - """ - alice = pre.fund_eoa() - # always use new account so we incur extra G_NEW_ACCOUNT cost - # there is no other gas boundary to test between cold access - # and new account - beneficiary = pre.empty_account() - - # selfdestruct_contract: PUSH20 SELFDESTRUCT - selfdestruct_code = Op.SELFDESTRUCT(beneficiary) - selfdestruct_contract = pre.deploy_contract( - code=selfdestruct_code, balance=1000 - ) - - # Gas needed inside the CALL for SELFDESTRUCT: - # - PUSH20: G_VERY_LOW = 3 - # - SELFDESTRUCT: G_SELF_DESTRUCT - # - G_COLD_ACCOUNT_ACCESS (beneficiary cold access) - gas_costs = fork.gas_costs() - exact_static_gas = ( - gas_costs.G_VERY_LOW - + gas_costs.G_SELF_DESTRUCT - + gas_costs.G_COLD_ACCOUNT_ACCESS - ) - - # subtract one from the exact gas to trigger OOG before state access - oog_gas = ( - exact_static_gas - 1 if oog_before_state_access else exact_static_gas - ) - - # caller_contract: CALL with oog_gas - caller_code = Op.CALL(gas=oog_gas, address=selfdestruct_contract) - caller_contract = pre.deploy_contract(code=caller_code) - - tx = Transaction( - sender=alice, - to=caller_contract, - gas_limit=100_000, - ) - - account_expectations: Dict[Address, BalAccountExpectation | None] = { - alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], - ), - caller_contract: BalAccountExpectation.empty(), - selfdestruct_contract: BalAccountExpectation.empty(), - # beneficiary only in BAL if we passed check_gas (state accessed) - beneficiary: None - if oog_before_state_access - else BalAccountExpectation.empty(), - } - - block = Block( - txs=[tx], - expected_block_access_list=BlockAccessListExpectation( - account_expectations=account_expectations - ), - ) - - blockchain_test( - pre=pre, - blocks=[block], - post={ - alice: Account(nonce=1), - caller_contract: Account(code=caller_code), - # selfdestruct_contract still exists - SELFDESTRUCT failed - selfdestruct_contract: Account( - balance=1000, code=selfdestruct_code - ), - }, - ) - - def test_bal_storage_write_read_same_frame( pre: Alloc, blockchain_test: BlockchainTestFiller, @@ -2873,95 +2625,6 @@ def test_bal_transient_storage_not_tracked( ) -@pytest.mark.pre_alloc_group( - "selfdestruct_to_precompile", - reason="Modifies precompile balance, must be isolated in EngineX format", -) -def test_bal_selfdestruct_to_precompile( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """ - Test BAL with SELFDESTRUCT to precompile (ecrecover 0x01). - - Victim (balance=100) selfdestructs to precompile 0x01. - - Expected BAL: - - Victim: balance_changes (100→0) - - Precompile 0x01: balance_changes (0→100), no code/nonce changes - """ - alice = pre.fund_eoa() - - contract_balance = 100 - ecrecover_precompile = Address(1) # 0x0000...0001 - - # Contract that selfdestructs to ecrecover precompile - victim_code = Op.SELFDESTRUCT(ecrecover_precompile) - - victim = pre.deploy_contract(code=victim_code, balance=contract_balance) - - # Caller that triggers the selfdestruct - caller_code = Op.CALL(100_000, victim, 0, 0, 0, 0, 0) + Op.STOP - caller = pre.deploy_contract(code=caller_code) - - tx = Transaction( - sender=alice, - to=caller, - gas_limit=1_000_000, - ) - - block = Block( - txs=[tx], - expected_block_access_list=BlockAccessListExpectation( - account_expectations={ - alice: BalAccountExpectation( - nonce_changes=[ - BalNonceChange(block_access_index=1, post_nonce=1) - ], - ), - caller: BalAccountExpectation.empty(), - # Victim (selfdestructing contract): balance changes 100→0 - # Explicitly verify ALL fields to avoid false positives - victim: BalAccountExpectation( - nonce_changes=[], # Contract nonce unchanged - balance_changes=[ - BalBalanceChange(block_access_index=1, post_balance=0) - ], - code_changes=[], # Code unchanged (post-Cancun) - storage_changes=[], # No storage changes - storage_reads=[], # No storage reads - ), - # Precompile receives selfdestruct balance - # Explicitly verify ALL fields to avoid false positives - ecrecover_precompile: BalAccountExpectation( - nonce_changes=[], # MUST NOT have nonce changes - balance_changes=[ - BalBalanceChange( - block_access_index=1, post_balance=contract_balance - ) - ], - code_changes=[], # MUST NOT have code changes - storage_changes=[], # MUST NOT have storage changes - storage_reads=[], # MUST NOT have storage reads - ), - } - ), - ) - - blockchain_test( - pre=pre, - blocks=[block], - post={ - alice: Account(nonce=1), - caller: Account(), - # Victim still exists with 0 balance (post-Cancun SELFDESTRUCT) - victim: Account(balance=0), - # Precompile has received the balance - ecrecover_precompile: Account(balance=contract_balance), - }, - ) - - def test_bal_create_early_failure( pre: Alloc, blockchain_test: BlockchainTestFiller, diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index b605f43136..29d77ea46a 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -5,7 +5,13 @@ | `test_bal_nonce_changes` | Ensure BAL captures changes to nonce | Alice sends 100 wei to Bob | BAL MUST include changes to Alice's nonce. | ✅ Completed | | `test_bal_balance_changes` | Ensure BAL captures changes to balance | Alice sends 100 wei to Bob | BAL MUST include balance change for Alice, Bob, and Coinbase | ✅ Completed | | `test_bal_code_changes` | Ensure BAL captures changes to account code | Alice deploys factory contract that creates new contract | BAL MUST include code changes for newly deployed contract | ✅ Completed | -| `test_bal_self_destruct` | Ensure BAL captures storage access and balance changes caused by `SELFDESTRUCT` | Parameterized test: Alice interacts with a contract (either existing or created same-tx) that reads from storage slot 0x01, writes to storage slot 0x02, then executes `SELFDESTRUCT` with Bob as recipient. Contract may be pre-funded with 10 wei | BAL MUST include Alice's nonce change (increment) and Bob's balance change (100 or 110 depending on pre-funding). For the self-destructing contract: storage_reads=[0x01], empty storage_changes=[], and if pre-funded, balance_changes with post_balance=0; if not pre-funded, no balance change recorded. MUST NOT have code_changes or nonce_changes entries | ✅ Completed | +| `test_selfdestruct_to_account` (TangerineWhistle) | Ensure BAL captures SELFDESTRUCT success boundary for account beneficiaries | Victim executes `SELFDESTRUCT(beneficiary)` at exact gas boundary. Tests final gas boundary where operation completes. Parametrized: is_success (exact_gas/exact_gas_minus_1), beneficiary (EOA/contract), warm (cold/warm where warm=Berlin+), same_tx (pre_deploy/same_tx), originator_balance (0/1), beneficiary_balance (0/1). File: `tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py`. | exact_gas: Beneficiary in BAL with `balance_changes`, victim destroyed (pre-Cancun/same_tx) or preserved (>=Cancun). exact_gas_minus_1: OOG, beneficiary in BAL only if G_NEW_ACCOUNT was part of gas calculation. | ✅ Completed | +| `test_selfdestruct_state_access_boundary` (TangerineWhistle) | Ensure BAL correctly tracks beneficiary access at state access boundary (consensus check) | Victim executes `SELFDESTRUCT(beneficiary)` at state access boundary (base + cold). Verifies beneficiary is accessed before G_NEW_ACCOUNT check. Parametrized: is_success (exact_gas/exact_gas_minus_1), beneficiary (EOA/contract), warm (cold/warm), same_tx, originator_balance (0/1), beneficiary_balance (0/1). File: `tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py`. | exact_gas: Beneficiary **IN** BAL (state accessed). exact_gas_minus_1: Beneficiary **NOT** in BAL (OOG before state access). Operation may succeed at exact_gas if no G_NEW_ACCOUNT needed. | ✅ Completed | +| `test_selfdestruct_to_self` (TangerineWhistle) | Ensure BAL captures SELFDESTRUCT where beneficiary is self at gas boundary | Victim executes `SELFDESTRUCT(ADDRESS)` - selfdestructs to itself. Always warm, always alive (no G_NEW_ACCOUNT, no cold access). Gas = G_BASE + G_SELF_DESTRUCT. Parametrized: is_success (exact_gas/exact_gas_minus_1), originator_balance (0/1), same_tx (pre_deploy/same_tx). File: `tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py`. | exact_gas_minus_1: Victim in BAL with unchanged state. exact_gas: Pre-Cancun/same_tx: destroyed, balance=0. >=Cancun pre-existing: preserved with original balance. | ✅ Completed | +| `test_selfdestruct_to_precompile` (TangerineWhistle) | Ensure BAL captures SELFDESTRUCT success boundary for precompile beneficiaries | Victim executes `SELFDESTRUCT(precompile)` at exact gas boundary. Precompiles are always warm (no cold access charge). Parametrized: is_success (exact_gas/exact_gas_minus_1), all precompiles via `@pytest.mark.with_all_precompiles`, same_tx (pre_deploy/same_tx), originator_balance (0/1), beneficiary_balance (0/1). File: `tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py`. | exact_gas: Precompile in BAL with `balance_changes`, victim destroyed (pre-Cancun/same_tx) or preserved (>=Cancun). exact_gas_minus_1: OOG, precompile in BAL only if G_NEW_ACCOUNT was part of gas calculation. | ✅ Completed | +| `test_selfdestruct_to_precompile_state_access_boundary` (TangerineWhistle) | Ensure BAL correctly tracks precompile access at state access boundary (consensus check) | Victim executes `SELFDESTRUCT(precompile)` at state access boundary (base only, precompiles always warm). Verifies precompile is accessed before G_NEW_ACCOUNT check. Parametrized: is_success (exact_gas/exact_gas_minus_1), all precompiles, same_tx, originator_balance (0/1), beneficiary_balance (0/1). File: `tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py`. | exact_gas: Precompile **IN** BAL (state accessed). exact_gas_minus_1: Precompile **NOT** in BAL (OOG before state access). Operation may succeed at exact_gas if no G_NEW_ACCOUNT needed. | ✅ Completed | +| `test_selfdestruct_to_system_contract` (Cancun) | Ensure BAL captures SELFDESTRUCT success boundary for system contract beneficiaries | Victim executes `SELFDESTRUCT(system_contract)` at exact gas boundary. System contracts are always warm (no cold access charge) and always have code (no G_NEW_ACCOUNT charge). Gas = G_VERY_LOW + G_SELF_DESTRUCT. Parametrized: is_success (exact_gas/exact_gas_minus_1), all system contracts via `@pytest.mark.with_all_system_contracts`, same_tx (pre_deploy/same_tx), originator_balance (0/1). File: `tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py`. | exact_gas: System contract in BAL with `balance_changes` if originator had balance, victim destroyed (same_tx) or balance=0 (pre-existing). exact_gas_minus_1: OOG, system contract not in BAL (no state access). | ✅ Completed | +| `test_initcode_selfdestruct_to_self` (TangerineWhistle) | Ensure BAL captures SELFDESTRUCT during initcode where beneficiary is self | Initcode executes `SELFDESTRUCT(ADDRESS)` during CREATE, before any code is deployed. Contract has nonce=1 (post-EIP-161), making it non-empty. Always warm (executing contract), no G_NEW_ACCOUNT (nonce > 0). Gas boundary testing not possible (CREATE uses all available gas). Parametrized: originator_balance (0/1). File: `tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py`. | Contract created and destroyed in same tx - victim has empty BAL changes. Caller has `nonce_changes` (incremented by CREATE) and `balance_changes` if originator had balance. Victim is NONEXISTENT in post state. | ✅ Completed | | `test_bal_account_access_target` | Ensure BAL captures target addresses of account access opcodes | Alice calls `Oracle` contract which uses account access opcodes (`BALANCE`, `EXTCODESIZE`, `EXTCODECOPY`, `EXTCODEHASH`, `CALL`, `CALLCODE`, `DELEGATECALL`, `STATICCALL`) on `TargetContract`. | BAL MUST include Alice, `Oracle`, and `TargetContract` with empty changes for `TargetContract` and nonce changes for Alice. | ✅ Completed | | `test_bal_call_no_delegation_and_oog_before_target_access` | Ensure BAL handles OOG before target access and success for non-delegated CALL | Parametrized: target warm/cold, target empty/existing, value 0/1, memory expansion, OOG boundary (before_target_access/success). | OOG: target in BAL ONLY if pre-warmed. Success: target always in BAL with balance changes when value > 0. | ✅ Completed | | `test_bal_call_no_delegation_oog_after_target_access` | Ensure BAL includes target but excludes value transfer when OOG after target access | Hardcoded: empty target, value=1 (required for create_cost gap). Parametrized: warm/cold, memory expansion. | Target always in BAL. No balance changes (value transfer fails after G_NEW_ACCOUNT check). | ✅ Completed | @@ -27,7 +33,7 @@ | `test_bal_aborted_storage_access` | Ensure BAL captures storage access in aborted transactions correctly | Alice calls contract that reads storage slot `0x01`, writes to slot `0x02`, then aborts with `REVERT`/`INVALID` | BAL MUST include storage_reads for slots `0x01` and `0x02` (aborted writes become reads), empty storage_changes. Only nonce changes for Alice. | ✅ Completed | | `test_bal_aborted_account_access` | Ensure BAL captures account access in aborted transactions for all account accessing opcodes | Alice calls `AbortContract` that performs account access operations (`BALANCE`, `EXTCODESIZE`, `EXTCODECOPY`, `EXTCODEHASH`, `CALL`, `CALLCODE`, `DELEGATECALL`, `STATICCALL`) on `TargetContract` and aborts via `REVERT`/`INVALID` | BAL MUST include Alice, `TargetContract`, and `AbortContract` in account_changes and nonce changes for Alice. | ✅ Completed | | `test_bal_pure_contract_call` | Ensure BAL captures contract access for pure computation calls | Alice calls `PureContract` that performs pure arithmetic (ADD operation) without storage or balance changes | BAL MUST include Alice and `PureContract` in `account_changes`, and `nonce_changes` for Alice. | ✅ Completed | -| `test_bal_create2_to_A_read_then_selfdestruct` | BAL records balance change for A and storage access (no persistent change) | Tx0: Alice sends ETH to address **A**. Tx1: Deployer `CREATE2` a contract **at A**; contract does `SLOAD(B)` and immediately `SELFDESTRUCT(beneficiary=X)` in the same tx. | BAL **MUST** include **A** with `balance_changes` (funding in Tx0 and transfer on selfdestruct in Tx1). BAL **MUST** include storage key **B** as an accessed `StorageKey`, and **MUST NOT** include **B** under `storage_changes` (no persistence due to same-tx create+destruct). | 🟡 Planned | +| `test_bal_create2_to_A_read_then_selfdestruct` | BAL records balance change for A and storage access (no persistent change) | Tx0: Alice sends ETH to address **A**. Tx1: Deployer `CREATE2` a contract **at A**; contract does `SLOAD(B)` and immediately `SELFDESTRUCT(beneficiary=X)` in the same tx. | BAL **MUST** include **A** with `balance_changes` (funding in Tx0 and transfer on selfdestruct in Tx1). BAL **MUST** include storage key **B** as an accessed `StorageKey`, and **MUST NOT** include **B** under `storage_changes` (no persistence due to same-tx create+destruct). | 🟡 Planned | | `test_bal_create2_to_A_write_then_selfdestruct` | BAL records balance change for A and storage access even if a write occurred (no persistent change) | Tx0: Alice sends ETH to **A**. Tx1: Deployer `CREATE2` contract **at A**; contract does `SSTORE(B, v)` (optionally `SLOAD(B)`), then `SELFDESTRUCT(beneficiary=Y)` in the same tx. | BAL **MUST** include **A** with `balance_changes` (Tx0 fund; Tx1 outflow to `Y`). BAL **MUST** include **B** as `StorageKey` accessed, and **MUST NOT** include **B** under `storage_changes` (ephemeral write discarded because the contract was created and destroyed in the same tx). | 🟡 Planned | | `test_bal_precompile_funded` | BAL records precompile value transfer with or without balance change | Alice sends value to precompile (all precompiles) via direct transaction. Parameterized: (1) with value (1 ETH), (2) without value (0 ETH). | For with_value: BAL **MUST** include precompile with `balance_changes`. For no_value: BAL **MUST** include precompile with empty `balance_changes`. No `storage_changes` or `code_changes` in either case. | ✅ Completed | | `test_bal_precompile_call` | BAL records precompile when called via contract | Alice calls Oracle contract which calls precompile (all precompiles) via CALL opcode with 0 ETH | BAL **MUST** include Alice with `nonce_changes`, Oracle with empty changes, and precompile with empty changes. No `balance_changes`, `storage_changes`, or `code_changes` for precompile. | ✅ Completed | @@ -103,8 +109,6 @@ | `test_bal_call_revert_insufficient_funds` | Ensure BAL handles CALL failure due to insufficient balance (not OOG) | Contract (balance=100, storage slot 0x02=0xDEAD) executes: `SLOAD(0x01), CALL(target, value=1000), SSTORE(0x02, result)`. CALL fails because 1000 > 100. Target address 0xDEAD (pre-existing with non-zero balance to avoid pruning). Note: slot 0x02 must start non-zero so SSTORE(0) is a change. | BAL **MUST** include: (1) Contract with `storage_reads` for slot 0x01, `storage_changes` for slot 0x02 (value=0, CALL returned failure). (2) Target (0xDEAD) **MUST** appear in BAL with empty changes - target is accessed before balance check fails. | ✅ Completed | | `test_bal_lexicographic_address_ordering` | Ensure BAL enforces strict lexicographic byte-wise ordering | Pre-fund three addresses with specific byte patterns: `addr_low = 0x0000...0001`, `addr_mid = 0x0000...0100`, `addr_high = 0x0100...0000`. Contract touches them in reverse order: `BALANCE(addr_high), BALANCE(addr_low), BALANCE(addr_mid)`. Additionally, include two endian-trap addresses that are byte-reversals of each other: `addr_endian_low = 0x0100000000000000000000000000000000000002`, `addr_endian_high = 0x0200000000000000000000000000000000000001`. Note: `reverse(addr_endian_low) = addr_endian_high`. Correct lexicographic order: `addr_endian_low < addr_endian_high` (0x01 < 0x02 at byte 0). If implementation incorrectly reverses bytes before comparing, it would get `addr_endian_low > addr_endian_high` (wrong). | BAL account list **MUST** be sorted lexicographically by address bytes: `addr_low` < `addr_mid` < `addr_high` < `addr_endian_low` < `addr_endian_high`, regardless of access order. The endian-trap addresses specifically catch byte-reversal bugs where addresses are compared with wrong byte order. Complements `test_bal_invalid_account_order` which tests rejection; this tests correct generation. | ✅ Completed | | `test_bal_transient_storage_not_tracked` | Ensure BAL excludes EIP-1153 transient storage operations | Contract executes: `TSTORE(0x01, 0x42)` (transient write), `TLOAD(0x01)` (transient read), `SSTORE(0x02, result)` (persistent write using transient value). | BAL **MUST** include slot 0x02 in `storage_changes` (persistent storage was modified). BAL **MUST NOT** include slot 0x01 in `storage_reads` or `storage_changes` (transient storage is not persisted, not needed for stateless execution). This verifies TSTORE/TLOAD don't pollute BAL. | ✅ Completed | -| `test_bal_selfdestruct_to_precompile` | Ensure BAL captures SELFDESTRUCT with precompile as beneficiary | Caller triggers victim contract (balance=100) to execute `SELFDESTRUCT(0x0000...0001)` (ecrecover precompile). Precompile starts with balance=0. | BAL **MUST** include: (1) Contract with `balance_changes` (100→0, loses balance to selfdestruct). (2) Precompile address 0x01 with `balance_changes` (0→100, receives selfdestruct balance). Precompile **MUST NOT** have `code_changes` or `nonce_changes`. This complements `test_bal_withdrawal_to_precompiles` (withdrawal) and `test_bal_precompile_funded` (tx value). | ✅ Completed | -| `test_bal_self_destruct_oog` | Ensure BAL correctly tracks SELFDESTRUCT beneficiary based on gas boundaries | Alice calls `Caller` contract which CALLs `SelfDestructContract` with precisely controlled gas. `SelfDestructContract` attempts SELFDESTRUCT to new account `Beneficiary`. Static gas = G_VERY_LOW + G_SELF_DESTRUCT + G_COLD_ACCOUNT_ACCESS. Parameterized: (1) OOG before state access (gas = static - 1), (2) OOG after state access (gas = static, but insufficient for G_NEW_ACCOUNT). | For OOG before state access: BAL **MUST NOT** include `Beneficiary` (no state access occurred). For OOG after state access: BAL **MUST** include `Beneficiary` with empty changes (state was accessed before G_NEW_ACCOUNT check failed). Both cases: Alice with `nonce_changes`, `Caller` and `SelfDestructContract` with empty changes. Contract balance unchanged. | ✅ Completed | | `test_bal_withdrawal_to_7702_delegation` | Ensure BAL correctly handles withdrawal to a 7702 delegated account (no code execution on recipient) | Tx1: Alice authorizes delegation to Oracle (sets code to `0xef0100\|\|Oracle`). Withdrawal: 10 gwei sent to Alice. Single block with tx + withdrawal. | BAL **MUST** include: (1) Alice at block_access_index=1 with `code_changes` (delegation), `nonce_changes`. (2) Alice at block_access_index=2 with `balance_changes` (receives withdrawal). **Oracle MUST NOT appear** - withdrawals credit balance without executing recipient code, so delegation target is never accessed. This complements `test_bal_selfdestruct_to_7702_delegation` (selfdestruct) and `test_bal_withdrawal_no_evm_execution` (withdrawal to contract). | ✅ Completed | | `test_init_collision_create_tx` | Ensure BAL tracks CREATE collisions correctly (pre-Amsterdam test with BAL) | CREATE transaction targeting address with existing storage aborts | BAL **MUST** show empty expectations for collision address (no changes occur due to abort) | ✅ Completed | | `test_call_to_pre_authorized_oog` | Ensure BAL handles OOG during EIP-7702 delegation access (pre-Amsterdam test with BAL) | Call to delegated account that OOGs before accessing delegation contract | BAL **MUST** include auth_signer (code read for delegation check) but **MUST NOT** include delegation contract (OOG before access) | ✅ Completed | diff --git a/tests/berlin/eip2930_access_list/test_tx_type.py b/tests/berlin/eip2930_access_list/test_tx_type.py index c9e22e55d9..4eb7434abe 100644 --- a/tests/berlin/eip2930_access_list/test_tx_type.py +++ b/tests/berlin/eip2930_access_list/test_tx_type.py @@ -13,7 +13,6 @@ TransactionException, ) from execution_testing import Opcodes as Op -from execution_testing.forks import Byzantium from .spec import ref_spec_2930 @@ -62,7 +61,7 @@ def test_eip2930_tx_validity( sender=sender, gas_limit=100_000, access_list=[], - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), error=TransactionException.TYPE_1_TX_PRE_FORK if not valid else None, ) diff --git a/tests/byzantium/eip196_ec_add_mul/test_gas.py b/tests/byzantium/eip196_ec_add_mul/test_gas.py index 1bce8fc06d..a459446c8d 100644 --- a/tests/byzantium/eip196_ec_add_mul/test_gas.py +++ b/tests/byzantium/eip196_ec_add_mul/test_gas.py @@ -8,7 +8,6 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Byzantium from execution_testing.forks.helpers import Fork from execution_testing.vm import Opcodes as Op @@ -56,7 +55,7 @@ def test_gas_costs( to=account, sender=pre.fund_eoa(), gas_limit=100_0000, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), ) post = {account: Account(storage={0: 1 if enough_gas else 0})} diff --git a/tests/byzantium/eip197_ec_pairing/test_gas.py b/tests/byzantium/eip197_ec_pairing/test_gas.py index 738edfa1c7..33f598d9e2 100644 --- a/tests/byzantium/eip197_ec_pairing/test_gas.py +++ b/tests/byzantium/eip197_ec_pairing/test_gas.py @@ -8,7 +8,6 @@ Transaction, ) from execution_testing.base_types.base_types import Address -from execution_testing.forks import Byzantium from execution_testing.forks.helpers import Fork from execution_testing.vm import Opcodes as Op @@ -50,7 +49,7 @@ def test_gas_costs( to=account, sender=pre.fund_eoa(), gas_limit=100_0000, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), ) post = {account: Account(storage={0: 1 if enough_gas else 0})} diff --git a/tests/frontier/create/test_create_deposit_oog.py b/tests/frontier/create/test_create_deposit_oog.py index 5e0fee4d78..ebea03557e 100644 --- a/tests/frontier/create/test_create_deposit_oog.py +++ b/tests/frontier/create/test_create_deposit_oog.py @@ -14,7 +14,7 @@ Transaction, compute_create_address, ) -from execution_testing.forks import Byzantium, Frontier, Homestead +from execution_testing.forks import Frontier, Homestead SLOT_CREATE_RESULT = 1 SLOT_CREATE_RESULT_PRE = 0xDEADBEEF @@ -65,7 +65,7 @@ def test_create_deposit_oog( gas_limit=tx_gas_limit, to=code, sender=sender, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), ) post = { diff --git a/tests/frontier/create/test_create_one_byte.py b/tests/frontier/create/test_create_one_byte.py index ece0763403..1b44d25bba 100644 --- a/tests/frontier/create/test_create_one_byte.py +++ b/tests/frontier/create/test_create_one_byte.py @@ -17,7 +17,7 @@ Transaction, compute_create_address, ) -from execution_testing.forks import Byzantium, London +from execution_testing.forks import London @pytest.mark.ported_from( @@ -100,7 +100,7 @@ def test_create_one_byte( data=b"", nonce=0, sender=sender, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), ) post = { diff --git a/tests/frontier/create/test_create_suicide_during_init.py b/tests/frontier/create/test_create_suicide_during_init.py index f8a1d3d43a..2c932a4926 100644 --- a/tests/frontier/create/test_create_suicide_during_init.py +++ b/tests/frontier/create/test_create_suicide_during_init.py @@ -14,7 +14,6 @@ Transaction, compute_create_address, ) -from execution_testing.forks import Byzantium class Operation(Enum): @@ -93,7 +92,7 @@ def test_create_suicide_during_transaction_create( data=contract_initcode, value=tx_value, sender=sender, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), ) post = { diff --git a/tests/frontier/create/test_create_suicide_store.py b/tests/frontier/create/test_create_suicide_store.py index 717d307843..12c60a5d14 100644 --- a/tests/frontier/create/test_create_suicide_store.py +++ b/tests/frontier/create/test_create_suicide_store.py @@ -19,7 +19,6 @@ Transaction, compute_create_address, ) -from execution_testing.forks import Byzantium class Operation(IntEnum): @@ -147,7 +146,7 @@ def test_create_suicide_store( to=create_contract, data=suicide_initcode, sender=sender, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), ) post = { diff --git a/tests/frontier/opcodes/test_all_opcodes.py b/tests/frontier/opcodes/test_all_opcodes.py index 8cc62b9ec3..a5fed1f68e 100644 --- a/tests/frontier/opcodes/test_all_opcodes.py +++ b/tests/frontier/opcodes/test_all_opcodes.py @@ -21,7 +21,6 @@ UndefinedOpcodes, gas_test, ) -from execution_testing.forks import Byzantium REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" @@ -183,7 +182,7 @@ def test_stack_overflow( gas_limit=100_000, to=contract, sender=pre.fund_eoa(), - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), ) expected_storage = { slot_code_worked: value_code_failed if fails else value_code_worked diff --git a/tests/frontier/opcodes/test_blockhash.py b/tests/frontier/opcodes/test_blockhash.py index 9c42639ed7..de0b7a3034 100644 --- a/tests/frontier/opcodes/test_blockhash.py +++ b/tests/frontier/opcodes/test_blockhash.py @@ -9,7 +9,6 @@ Op, Transaction, ) -from execution_testing.forks import Byzantium from execution_testing.forks.helpers import Fork @@ -60,7 +59,7 @@ def test_genesis_hash_available( sender=sender, to=contract, gas_limit=100_000, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), ) ] if not setup_blocks_empty @@ -76,7 +75,7 @@ def test_genesis_hash_available( sender=sender, to=contract, gas_limit=100_000, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), ) ] ) diff --git a/tests/frontier/opcodes/test_call_and_callcode_gas_calculation.py b/tests/frontier/opcodes/test_call_and_callcode_gas_calculation.py index ae3dd53df2..3c178fb506 100644 --- a/tests/frontier/opcodes/test_call_and_callcode_gas_calculation.py +++ b/tests/frontier/opcodes/test_call_and_callcode_gas_calculation.py @@ -51,7 +51,11 @@ StateTestFiller, Transaction, ) -from execution_testing.forks.forks.forks import Berlin, Byzantium, Homestead +from execution_testing.forks.forks.forks import ( + Berlin, + Byzantium, + Homestead, +) from execution_testing.forks.helpers import Fork @@ -196,7 +200,7 @@ def caller_tx(sender: EOA, caller_address: Address, fork: Fork) -> Transaction: value=1, gas_limit=500_000, sender=sender, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), ) diff --git a/tests/frontier/opcodes/test_calldatacopy.py b/tests/frontier/opcodes/test_calldatacopy.py index 822246aab5..de7ea05366 100644 --- a/tests/frontier/opcodes/test_calldatacopy.py +++ b/tests/frontier/opcodes/test_calldatacopy.py @@ -10,7 +10,6 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Byzantium @pytest.mark.ported_from( @@ -193,7 +192,7 @@ def test_calldatacopy( data=tx_data, gas_limit=100_000, gas_price=0x0A, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), sender=pre.fund_eoa(), to=to, value=0x01, diff --git a/tests/frontier/opcodes/test_calldataload.py b/tests/frontier/opcodes/test_calldataload.py index f6728e6a45..0df9f6a00f 100644 --- a/tests/frontier/opcodes/test_calldataload.py +++ b/tests/frontier/opcodes/test_calldataload.py @@ -10,7 +10,6 @@ Transaction, ) from execution_testing import Macros as Om -from execution_testing.forks import Byzantium @pytest.mark.ported_from( @@ -92,7 +91,7 @@ def test_calldataload( tx = Transaction( data=calldata, gas_limit=100_000, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), sender=pre.fund_eoa(), to=to, ) @@ -101,7 +100,7 @@ def test_calldataload( tx = Transaction( data=calldata, gas_limit=100_000, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), sender=pre.fund_eoa(), to=contract_address, ) diff --git a/tests/frontier/opcodes/test_calldatasize.py b/tests/frontier/opcodes/test_calldatasize.py index d21e73ca0a..735fa4afa9 100644 --- a/tests/frontier/opcodes/test_calldatasize.py +++ b/tests/frontier/opcodes/test_calldatasize.py @@ -10,7 +10,6 @@ Transaction, ) from execution_testing import Macros as Om -from execution_testing.forks import Byzantium @pytest.mark.ported_from( @@ -69,7 +68,7 @@ def test_calldatasize( tx = Transaction( gas_limit=100_000, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), sender=pre.fund_eoa(), to=to, ) @@ -78,7 +77,7 @@ def test_calldatasize( tx = Transaction( data=calldata, gas_limit=100_000, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), sender=pre.fund_eoa(), to=contract_address, ) diff --git a/tests/frontier/opcodes/test_dup.py b/tests/frontier/opcodes/test_dup.py index 249210c07d..1b54a7c79e 100644 --- a/tests/frontier/opcodes/test_dup.py +++ b/tests/frontier/opcodes/test_dup.py @@ -5,12 +5,12 @@ Account, Alloc, Environment, + Fork, Op, StateTestFiller, Storage, Transaction, ) -from execution_testing.forks import Frontier, Homestead @pytest.mark.parametrize( @@ -38,7 +38,7 @@ @pytest.mark.with_all_evm_code_types def test_dup( state_test: StateTestFiller, - fork: str, + fork: Fork, dup_opcode: Op, pre: Alloc, ) -> None: @@ -73,7 +73,7 @@ def test_dup( to=account, gas_limit=500000, gas_price=10, - protected=False if fork in [Frontier, Homestead] else True, + protected=fork.supports_protected_txs(), data="", sender=sender, ) diff --git a/tests/frontier/opcodes/test_push.py b/tests/frontier/opcodes/test_push.py index b8d6d8a44e..a05ff2f774 100644 --- a/tests/frontier/opcodes/test_push.py +++ b/tests/frontier/opcodes/test_push.py @@ -18,7 +18,6 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Frontier, Homestead def get_input_for_push_opcode(opcode: Op) -> bytes: @@ -77,7 +76,7 @@ def test_push( sender=pre.fund_eoa(), to=contract, gas_limit=500_000, - protected=False if fork in [Frontier, Homestead] else True, + protected=fork.supports_protected_txs(), ) post = {} @@ -149,7 +148,7 @@ def test_stack_overflow( sender=pre.fund_eoa(), to=contract, gas_limit=500_000, - protected=False if fork in [Frontier, Homestead] else True, + protected=fork.supports_protected_txs(), ) post = {} diff --git a/tests/frontier/opcodes/test_swap.py b/tests/frontier/opcodes/test_swap.py index f291164c95..efc1c42b1c 100644 --- a/tests/frontier/opcodes/test_swap.py +++ b/tests/frontier/opcodes/test_swap.py @@ -13,7 +13,6 @@ Bytecode, Environment, ) -from execution_testing.forks import Frontier, Homestead from execution_testing import Op from execution_testing import ( StateTestFiller, @@ -76,7 +75,7 @@ def test_swap( sender=pre.fund_eoa(), to=contract_address, gas_limit=500_000, - protected=False if fork in [Frontier, Homestead] else True, + protected=fork.supports_protected_txs(), ) # Calculate expected storage values after SWAP and storage operations @@ -146,7 +145,7 @@ def test_stack_underflow( sender=pre.fund_eoa(), to=contract, gas_limit=500_000, - protected=False if fork in [Frontier, Homestead] else True, + protected=fork.supports_protected_txs(), ) # Define the expected post-state. diff --git a/tests/frontier/precompiles/test_ecrecover.py b/tests/frontier/precompiles/test_ecrecover.py index d248b67f4c..a6fa669bef 100644 --- a/tests/frontier/precompiles/test_ecrecover.py +++ b/tests/frontier/precompiles/test_ecrecover.py @@ -8,7 +8,6 @@ StateTestFiller, Transaction, ) -from execution_testing.forks.forks.forks import Byzantium from execution_testing.forks.helpers import Fork from execution_testing.vm import Opcodes as Op @@ -388,7 +387,7 @@ def test_precompiles( to=account, sender=pre.fund_eoa(), gas_limit=1_000_000, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), ) post = {account: Account(storage={0: output})} diff --git a/tests/frontier/precompiles/test_ripemd.py b/tests/frontier/precompiles/test_ripemd.py index dc34ce1ad8..129c555845 100644 --- a/tests/frontier/precompiles/test_ripemd.py +++ b/tests/frontier/precompiles/test_ripemd.py @@ -8,7 +8,6 @@ StateTestFiller, Transaction, ) -from execution_testing.forks.forks.forks import Byzantium from execution_testing.forks.helpers import Fork from execution_testing.vm import Opcodes as Op @@ -176,7 +175,7 @@ def test_precompiles( sender=pre.fund_eoa(), gas_limit=1_000_0000, data=msg, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), ) post = {account: Account(storage={0: output if not oog else 0})} diff --git a/tests/homestead/yul/__init__.py b/tests/homestead/yul/__init__.py deleted file mode 100644 index 172309b311..0000000000 --- a/tests/homestead/yul/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests using Yul source for contracts.""" diff --git a/tests/london/eip1559_fee_market_change/test_tx_type.py b/tests/london/eip1559_fee_market_change/test_tx_type.py index d4e2caaa84..c8ade2fb6b 100644 --- a/tests/london/eip1559_fee_market_change/test_tx_type.py +++ b/tests/london/eip1559_fee_market_change/test_tx_type.py @@ -13,7 +13,6 @@ TransactionException, ) from execution_testing import Opcodes as Op -from execution_testing.forks import Byzantium from .spec import ref_spec_1559 @@ -62,7 +61,7 @@ def test_eip1559_tx_validity( sender=sender, gas_limit=100_000, max_priority_fee_per_gas=1, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), error=TransactionException.TYPE_2_TX_PRE_FORK if not valid else None, ) diff --git a/tests/tangerine_whistle/__init__.py b/tests/tangerine_whistle/__init__.py new file mode 100644 index 0000000000..2d6e14d640 --- /dev/null +++ b/tests/tangerine_whistle/__init__.py @@ -0,0 +1,4 @@ +""" +Test cases for EVM functionality introduced in Tangerine, [EIP-608: Hardfork +Meta - Tangerine Whistle](https://eips.ethereum.org/EIPS/eip-608). +""" diff --git a/tests/tangerine_whistle/eip150_operation_gas_costs/__init__.py b/tests/tangerine_whistle/eip150_operation_gas_costs/__init__.py new file mode 100644 index 0000000000..87e9060643 --- /dev/null +++ b/tests/tangerine_whistle/eip150_operation_gas_costs/__init__.py @@ -0,0 +1 @@ +"""Tests for EIP-150 operation gas costs in the Tangerine Whistle fork.""" diff --git a/tests/tangerine_whistle/eip150_operation_gas_costs/spec.py b/tests/tangerine_whistle/eip150_operation_gas_costs/spec.py new file mode 100644 index 0000000000..edd24dd82d --- /dev/null +++ b/tests/tangerine_whistle/eip150_operation_gas_costs/spec.py @@ -0,0 +1,21 @@ +""" +[EIP-150: Operation Gas Costs](https://eips.ethereum.org/EIPS/eip-150) +introduced changes to the gas costs of certain EVM operations to mitigate DOS +attacks. This module contains tests that verify the correct implementation +of these gas cost changes in the Ethereum Virtual Machine (EVM). +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ReferenceSpec: + """Defines the reference spec version and git path.""" + + git_path: str + version: str + + +ref_spec_150 = ReferenceSpec( + "EIPS/eip-150.md", "34acf72522b989d86e76efcaf42eba4cdb0b31ad" +) diff --git a/tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py b/tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py new file mode 100644 index 0000000000..d2ba634cf4 --- /dev/null +++ b/tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py @@ -0,0 +1,1197 @@ +""" +Tests for EIP-150 SELFDESTRUCT operation gas costs. + +EIP-150 introduced G_SELF_DESTRUCT for SELFDESTRUCT and precise gas +boundaries for state access during the operation. +""" + +from typing import Dict + +import pytest +from execution_testing import ( + EOA, + AccessList, + Account, + Address, + Alloc, + BalAccountExpectation, + BalBalanceChange, + BalCodeChange, + BalNonceChange, + Block, + BlockAccessListExpectation, + BlockchainTestFiller, + Initcode, + Op, + Transaction, + compute_create_address, +) +from execution_testing import ( + Macros as Om, +) +from execution_testing.forks import ( + Berlin, + Cancun, + SpuriousDragon, +) +from execution_testing.forks.helpers import Fork + +from .spec import ref_spec_150 + +REFERENCE_SPEC_GIT_PATH = ref_spec_150.git_path +REFERENCE_SPEC_VERSION = ref_spec_150.version + + +# --- helper functions --- # + + +def calculate_selfdestruct_gas( + fork: Fork, + beneficiary_warm: bool, + beneficiary_dead: bool, + originator_balance: int, +) -> int: + """Calculate exact gas needed for SELFDESTRUCT.""" + gas_costs = fork.gas_costs() + gas = ( + # PUSH + SELFDESTRUCT + gas_costs.G_VERY_LOW + gas_costs.G_SELF_DESTRUCT + ) + + # Cold access cost (>=Berlin only) + if fork >= Berlin and not beneficiary_warm: + gas += gas_costs.G_COLD_ACCOUNT_ACCESS + + # G_NEW_ACCOUNT: + # - Pre-EIP-161 (TangerineWhistle): charged when beneficiary is dead + # - Post-EIP-161 (>=SpuriousDragon): charged when beneficiary is dead + # AND originator has balance > 0 + if beneficiary_dead: + if fork >= SpuriousDragon: + if originator_balance > 0: + gas += gas_costs.G_NEW_ACCOUNT + else: + # Pre-EIP-161: always charged when beneficiary is dead + gas += gas_costs.G_NEW_ACCOUNT + + return gas + + +def setup_selfdestruct_test( + pre: Alloc, + fork: Fork, + beneficiary: Address, + originator_balance: int, + same_tx: bool, + beneficiary_warm: bool, + inner_call_gas: int, +) -> tuple[Address, Address, Address, Transaction]: + """ + Set up SELFDESTRUCT test with caller contract pattern. + + Returns: (alice, caller, victim, tx) + """ + alice = pre.fund_eoa() + victim_code = Op.SELFDESTRUCT(beneficiary) + + if same_tx: + # Deploy and selfdestruct in same transaction via factory + initcode = Initcode(deploy_code=victim_code) + initcode_len = len(initcode) + + factory_code = Om.MSTORE(initcode, 0) + Op.CALL( + gas=inner_call_gas, + address=Op.CREATE( + value=originator_balance, offset=0, size=initcode_len + ), + ) + caller = pre.deploy_contract( + code=factory_code, balance=originator_balance + ) + victim = compute_create_address(address=caller, nonce=1) + else: + # Pre-existing contract + victim = pre.deploy_contract( + code=victim_code, balance=originator_balance + ) + caller = pre.deploy_contract( + code=Op.CALL(gas=inner_call_gas, address=victim) + ) + + # Warm beneficiary via access list (>=Berlin only, + # doesn't add to BAL >= Amsterdam) + access_list = ( + [AccessList(address=beneficiary, storage_keys=[])] + if beneficiary_warm and fork >= Berlin + else None + ) + + tx = Transaction( + sender=alice, + to=caller, + gas_limit=500_000, + protected=fork.supports_protected_txs(), + access_list=access_list, + ) + + return alice, caller, victim, tx + + +def build_bal_expectations( + fork: Fork, + alice: Address, + caller: Address, + victim: Address, + beneficiary: Address, + originator_balance: int, + beneficiary_initial_balance: int, + same_tx: bool, + success: bool, + beneficiary_in_bal: bool, +) -> BlockAccessListExpectation | None: + """Build BAL expectations for >=Amsterdam.""" + if not fork.header_bal_hash_required(): + return None + + victim_code = Op.SELFDESTRUCT(beneficiary) + + # Beneficiary expectation + if not beneficiary_in_bal: + beneficiary_expectation: BalAccountExpectation | None = None + elif not success: + beneficiary_expectation = BalAccountExpectation.empty() + else: + # Success: balance transferred + final_balance = beneficiary_initial_balance + originator_balance + if final_balance > beneficiary_initial_balance: + beneficiary_expectation = BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=final_balance + ) + ], + ) + else: + beneficiary_expectation = BalAccountExpectation.empty() + + # Victim expectation + if same_tx: + if success: + # Created and destroyed in same tx - no net changes + victim_expectation = BalAccountExpectation.empty() + else: + # OOG: CREATE succeeded but SELFDESTRUCT failed + # Only include balance_changes if originator_balance > 0 + victim_expectation = BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + code_changes=[ + BalCodeChange( + block_access_index=1, new_code=bytes(victim_code) + ) + ], + ) + if originator_balance > 0: + victim_expectation.balance_changes.append( + BalBalanceChange( + block_access_index=1, post_balance=originator_balance + ) + ) + else: + if success and originator_balance > 0: + victim_expectation = BalAccountExpectation( + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=0) + ], + ) + else: + victim_expectation = BalAccountExpectation.empty() + + # Caller expectation + if same_tx: + caller_expectation = BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=2)], + ) + if originator_balance > 0: + caller_expectation.balance_changes.append( + BalBalanceChange(block_access_index=1, post_balance=0) + ) + else: + caller_expectation = BalAccountExpectation.empty() + + return BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + caller: caller_expectation, + victim: victim_expectation, + beneficiary: beneficiary_expectation, + } + ) + + +def build_post_state( + fork: Fork, + alice: Address, + caller: Address, + victim: Address, + beneficiary: Address, + originator_balance: int, + beneficiary_initial_balance: int, + same_tx: bool, + success: bool, + beneficiary_has_code: bool = False, +) -> dict: + """Build expected post state.""" + victim_code = Op.SELFDESTRUCT(beneficiary) + caller_nonce = 2 if same_tx else 1 + + if success: + contract_destroyed = fork < Cancun or same_tx + final_beneficiary_balance = ( + beneficiary_initial_balance + originator_balance + ) + + if contract_destroyed: + post: dict = { + alice: Account(nonce=1), + caller: Account(nonce=caller_nonce), + victim: Account.NONEXISTENT, + } + else: + # >=Cancun pre-existing: code preserved, balance transferred + post = { + alice: Account(nonce=1), + caller: Account(nonce=caller_nonce), + victim: Account(balance=0, code=victim_code), + } + + # Beneficiary: verify balance if non-empty, NONEXISTENT if empty + # Pre-EIP-161: empty accounts touched during execution persist + if final_beneficiary_balance > 0 or beneficiary_has_code: + post[beneficiary] = Account(balance=final_beneficiary_balance) + elif fork >= SpuriousDragon: + # EIP-161 (>=SpuriousDragon): empty accounts are deleted + post[beneficiary] = Account.NONEXISTENT + else: + # Pre-EIP-161: empty accounts persist after being touched + post[beneficiary] = Account(balance=0) + else: + # OOG: SELFDESTRUCT failed + if same_tx: + post = { + alice: Account(nonce=1), + caller: Account(nonce=caller_nonce, balance=0), + victim: Account(balance=originator_balance, code=victim_code), + } + else: + post = { + alice: Account(nonce=1), + caller: Account(nonce=caller_nonce), + victim: Account(balance=originator_balance, code=victim_code), + } + + return post + + +# --- tests --- # + + +@pytest.mark.parametrize( + "is_success", [True, False], ids=["exact_gas", "exact_gas_minus_1"] +) +@pytest.mark.parametrize( + "beneficiary", ["eoa", "contract"], ids=["eoa", "contract"] +) +@pytest.mark.parametrize( + "warm", + [ + pytest.param( + False, id="cold", marks=pytest.mark.valid_from("TangerineWhistle") + ), + pytest.param(True, id="warm", marks=pytest.mark.valid_from("Berlin")), + ], +) +@pytest.mark.parametrize( + "same_tx", [False, True], ids=["pre_deploy", "same_tx"] +) +@pytest.mark.parametrize( + "originator_balance", + [0, 1], + ids=["no_balance", "has_balance"], +) +@pytest.mark.parametrize( + "beneficiary_initial_balance", + [0, 1], + ids=["dead_beneficiary", "alive_beneficiary"], +) +def test_selfdestruct_to_account( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + is_success: bool, + beneficiary: str, + warm: bool, + same_tx: bool, + originator_balance: int, + beneficiary_initial_balance: int, +) -> None: + """ + Test SELFDESTRUCT success boundary for account beneficiaries. + + - exact_gas: succeeds, balance transferred, contract destroyed + - exact_gas_minus_1: OOG, operation fails + """ + # Create beneficiary + if beneficiary == "eoa": + beneficiary_addr: EOA | Address = pre.fund_eoa( + amount=beneficiary_initial_balance + ) + else: + beneficiary_addr = pre.deploy_contract( + code=Op.STOP, balance=beneficiary_initial_balance + ) + + # Determine if beneficiary is dead (for G_NEW_ACCOUNT calculation) + # Contract with code is NOT dead even with balance=0 + beneficiary_dead = ( + beneficiary_initial_balance == 0 and beneficiary == "eoa" + ) + + # Calculate exact gas for success (includes G_NEW_ACCOUNT if applicable) + inner_call_gas = calculate_selfdestruct_gas( + fork, + beneficiary_warm=warm, + beneficiary_dead=beneficiary_dead, + originator_balance=originator_balance, + ) + if not is_success: + inner_call_gas -= 1 + + # In BAL if: success OR G_NEW_ACCOUNT charged (OOG after access) + needs_new_account = False + if beneficiary_dead: + if fork >= SpuriousDragon: + needs_new_account = originator_balance > 0 + else: + needs_new_account = True + + beneficiary_in_bal = is_success or needs_new_account + + alice, caller, victim, tx = setup_selfdestruct_test( + pre, + fork, + beneficiary_addr, + originator_balance, + same_tx, + beneficiary_warm=warm, + inner_call_gas=inner_call_gas, + ) + + expected_bal = build_bal_expectations( + fork, + alice, + caller, + victim, + beneficiary_addr, + originator_balance, + beneficiary_initial_balance, + same_tx, + success=is_success, + beneficiary_in_bal=beneficiary_in_bal, + ) + + post = build_post_state( + fork, + alice, + caller, + victim, + beneficiary_addr, + originator_balance, + beneficiary_initial_balance, + same_tx, + success=is_success, + beneficiary_has_code=(beneficiary == "contract"), + ) + + blockchain_test( + pre=pre, + blocks=[Block(txs=[tx], expected_block_access_list=expected_bal)], + post=post, + ) + + +@pytest.mark.parametrize( + "is_success", [True, False], ids=["exact_gas", "exact_gas_minus_1"] +) +@pytest.mark.parametrize( + "beneficiary", ["eoa", "contract"], ids=["eoa", "contract"] +) +@pytest.mark.parametrize( + "warm", + [ + pytest.param( + False, id="cold", marks=pytest.mark.valid_from("TangerineWhistle") + ), + pytest.param(True, id="warm", marks=pytest.mark.valid_from("Berlin")), + ], +) +@pytest.mark.parametrize( + "same_tx", [False, True], ids=["pre_deploy", "same_tx"] +) +@pytest.mark.parametrize( + "originator_balance", + [0, 1], + ids=["no_balance", "has_balance"], +) +@pytest.mark.parametrize( + "beneficiary_initial_balance", + [0, 1], + ids=["dead_beneficiary", "alive_beneficiary"], +) +def test_selfdestruct_state_access_boundary( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + is_success: bool, + beneficiary: str, + warm: bool, + same_tx: bool, + originator_balance: int, + beneficiary_initial_balance: int, +) -> None: + """ + Test state access boundary for account beneficiaries. + + Consensus check: beneficiary must be accessed at base cost boundary, + before G_NEW_ACCOUNT is evaluated. + + - exact_gas: beneficiary IS accessed (in BAL) + - exact_gas_minus_1: beneficiary NOT accessed (not in BAL) + """ + # Create beneficiary + if beneficiary == "eoa": + beneficiary_addr: EOA | Address = pre.fund_eoa( + amount=beneficiary_initial_balance + ) + else: + beneficiary_addr = pre.deploy_contract( + code=Op.STOP, balance=beneficiary_initial_balance + ) + + # Determine if beneficiary is dead (for G_NEW_ACCOUNT calculation) + # Contract with code is NOT dead even with balance=0 + beneficiary_dead = ( + beneficiary_initial_balance == 0 and beneficiary == "eoa" + ) + + # Calculate gas for state access boundary only (base + cold access) + # Does NOT include G_NEW_ACCOUNT + gas_costs = fork.gas_costs() + inner_call_gas = gas_costs.G_VERY_LOW + gas_costs.G_SELF_DESTRUCT + if fork >= Berlin and not warm: + inner_call_gas += gas_costs.G_COLD_ACCOUNT_ACCESS + + if not is_success: + inner_call_gas -= 1 + + # Determine if operation succeeds at this gas level + # At state access boundary, we have enough gas for base + cold access + # Operation succeeds if NO G_NEW_ACCOUNT is needed: + # - Beneficiary is alive (has balance or has code) + # - OR beneficiary is dead but originator_balance=0 (>=SpuriousDragon) + needs_new_account = False + if beneficiary_dead: + if fork >= SpuriousDragon: + needs_new_account = originator_balance > 0 + else: + needs_new_account = True + + # At exact_gas: success if no G_NEW_ACCOUNT needed + # At exact_gas_minus_1: always OOG (before state access) + operation_success = is_success and not needs_new_account + + alice, caller, victim, tx = setup_selfdestruct_test( + pre, + fork, + beneficiary_addr, + originator_balance, + same_tx, + beneficiary_warm=warm, + inner_call_gas=inner_call_gas, + ) + + # Key difference: beneficiary_in_bal depends on is_success + # exact_gas: state accessed, beneficiary in BAL + # exact_gas_minus_1: OOG before state access, beneficiary NOT in BAL + expected_bal = build_bal_expectations( + fork, + alice, + caller, + victim, + beneficiary_addr, + originator_balance, + beneficiary_initial_balance, + same_tx, + success=operation_success, + beneficiary_in_bal=is_success, + ) + + post = build_post_state( + fork, + alice, + caller, + victim, + beneficiary_addr, + originator_balance, + beneficiary_initial_balance, + same_tx, + success=operation_success, + beneficiary_has_code=(beneficiary == "contract"), + ) + + blockchain_test( + pre=pre, + blocks=[Block(txs=[tx], expected_block_access_list=expected_bal)], + post=post, + ) + + +@pytest.mark.parametrize( + "is_success", [True, False], ids=["exact_gas", "exact_gas_minus_1"] +) +@pytest.mark.with_all_precompiles +@pytest.mark.pre_alloc_group("precompile_funding") +@pytest.mark.parametrize( + "same_tx", [False, True], ids=["pre_deploy", "same_tx"] +) +@pytest.mark.parametrize( + "originator_balance", + [0, 1], + ids=["no_balance", "has_balance"], +) +@pytest.mark.parametrize( + "beneficiary_initial_balance", + [0, 1], + ids=["dead_beneficiary", "alive_beneficiary"], +) +@pytest.mark.valid_from("TangerineWhistle") +def test_selfdestruct_to_precompile( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + is_success: bool, + precompile: Address, + same_tx: bool, + originator_balance: int, + beneficiary_initial_balance: int, +) -> None: + """ + Test SELFDESTRUCT success boundary for precompile beneficiaries. + + Precompiles are always warm (no cold access charge). + + - exact_gas: succeeds, balance transferred, contract destroyed + - exact_gas_minus_1: OOG, operation fails + """ + # Fund precompile if needed + if beneficiary_initial_balance > 0: + pre.fund_address(precompile, beneficiary_initial_balance) + + # Precompiles are dead when they have no balance + beneficiary_dead = beneficiary_initial_balance == 0 + + # Calculate exact gas for success (includes G_NEW_ACCOUNT if applicable) + # Precompiles are always warm + inner_call_gas = calculate_selfdestruct_gas( + fork, + beneficiary_warm=True, # Precompiles are always warm + beneficiary_dead=beneficiary_dead, + originator_balance=originator_balance, + ) + if not is_success: + inner_call_gas -= 1 + + # In BAL if: success OR G_NEW_ACCOUNT charged (OOG after access) + needs_new_account = False + if beneficiary_dead: + if fork >= SpuriousDragon: + needs_new_account = originator_balance > 0 + else: + needs_new_account = True + + beneficiary_in_bal = is_success or needs_new_account + + alice, caller, victim, tx = setup_selfdestruct_test( + pre, + fork, + precompile, + originator_balance, + same_tx, + beneficiary_warm=True, # Precompiles are always warm + inner_call_gas=inner_call_gas, + ) + + expected_bal = build_bal_expectations( + fork, + alice, + caller, + victim, + precompile, + originator_balance, + beneficiary_initial_balance, + same_tx, + success=is_success, + beneficiary_in_bal=beneficiary_in_bal, + ) + + post = build_post_state( + fork, + alice, + caller, + victim, + precompile, + originator_balance, + beneficiary_initial_balance, + same_tx, + success=is_success, + beneficiary_has_code=False, # Precompiles don't have stored code + ) + + blockchain_test( + pre=pre, + blocks=[Block(txs=[tx], expected_block_access_list=expected_bal)], + post=post, + ) + + +@pytest.mark.parametrize( + "is_success", [True, False], ids=["exact_gas", "exact_gas_minus_1"] +) +@pytest.mark.with_all_precompiles +@pytest.mark.pre_alloc_group("precompile_funding") +@pytest.mark.parametrize( + "same_tx", [False, True], ids=["pre_deploy", "same_tx"] +) +@pytest.mark.parametrize( + "originator_balance", + [0, 1], + ids=["no_balance", "has_balance"], +) +@pytest.mark.parametrize( + "beneficiary_initial_balance", + [0, 1], + ids=["dead_beneficiary", "alive_beneficiary"], +) +@pytest.mark.valid_from("TangerineWhistle") +def test_selfdestruct_to_precompile_state_access_boundary( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + is_success: bool, + precompile: Address, + same_tx: bool, + originator_balance: int, + beneficiary_initial_balance: int, +) -> None: + """ + Test state access boundary for precompile beneficiaries. + + Consensus check: precompile must be accessed at base cost boundary, + before G_NEW_ACCOUNT is evaluated. Precompiles are always warm. + + - exact_gas: precompile IS accessed (in BAL) + - exact_gas_minus_1: precompile NOT accessed (not in BAL) + """ + # Fund precompile if needed + if beneficiary_initial_balance > 0: + pre.fund_address(precompile, beneficiary_initial_balance) + + beneficiary_dead = beneficiary_initial_balance == 0 + + # State access boundary: base cost only (no G_NEW_ACCOUNT) + gas_costs = fork.gas_costs() + inner_call_gas = gas_costs.G_VERY_LOW + gas_costs.G_SELF_DESTRUCT + + if not is_success: + inner_call_gas -= 1 + + # Success at base cost if no G_NEW_ACCOUNT needed + needs_new_account = False + if beneficiary_dead: + if fork >= SpuriousDragon: + needs_new_account = originator_balance > 0 + else: + needs_new_account = True + + operation_success = is_success and not needs_new_account + + alice, caller, victim, tx = setup_selfdestruct_test( + pre, + fork, + precompile, + originator_balance, + same_tx, + beneficiary_warm=True, # Precompiles are always warm + inner_call_gas=inner_call_gas, + ) + + # Key difference: beneficiary_in_bal depends on is_success + # exact_gas: state accessed, precompile in BAL + # exact_gas_minus_1: OOG before state access, precompile NOT in BAL + expected_bal = build_bal_expectations( + fork, + alice, + caller, + victim, + precompile, + originator_balance, + beneficiary_initial_balance, + same_tx, + success=operation_success, + beneficiary_in_bal=is_success, + ) + + post = build_post_state( + fork, + alice, + caller, + victim, + precompile, + originator_balance, + beneficiary_initial_balance, + same_tx, + success=operation_success, + beneficiary_has_code=False, # Precompiles don't have stored code + ) + + blockchain_test( + pre=pre, + blocks=[Block(txs=[tx], expected_block_access_list=expected_bal)], + post=post, + ) + + +@pytest.mark.parametrize( + "is_success", [True, False], ids=["exact_gas", "exact_gas_minus_1"] +) +@pytest.mark.with_all_system_contracts +@pytest.mark.parametrize( + "same_tx", [False, True], ids=["pre_deploy", "same_tx"] +) +@pytest.mark.parametrize( + "originator_balance", + [0, 1], + ids=["no_balance", "has_balance"], +) +@pytest.mark.valid_from("Cancun") +def test_selfdestruct_to_system_contract( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + is_success: bool, + system_contract: Address, + same_tx: bool, + originator_balance: int, +) -> None: + """ + Test SELFDESTRUCT success boundary for system contract beneficiaries. + + System contracts are always warm (no cold access charge) and always have + code (so beneficiary is never dead, no G_NEW_ACCOUNT charge). + + - exact_gas: succeeds, balance transferred + - exact_gas_minus_1: OOG, operation fails + """ + # Calculate exact gas for success + # System contracts are always warm and never dead (have code) + inner_call_gas = calculate_selfdestruct_gas( + fork, + beneficiary_warm=True, + beneficiary_dead=False, + originator_balance=originator_balance, + ) + if not is_success: + inner_call_gas -= 1 + + alice, caller, victim, tx = setup_selfdestruct_test( + pre, + fork, + system_contract, + originator_balance, + same_tx, + beneficiary_warm=True, + inner_call_gas=inner_call_gas, + ) + + # Build minimal BAL expectations for test-specific accounts only + expected_bal: BlockAccessListExpectation | None = None + if fork.header_bal_hash_required(): + account_expectations: Dict[Address, BalAccountExpectation | None] = { + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + } + + # Victim expectation + if same_tx: + if is_success: + # Created and destroyed in same tx - no net changes + victim_expectation = BalAccountExpectation.empty() + else: + # OOG: contract created but selfdestruct failed + victim_expectation = BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + code_changes=[ + BalCodeChange( + block_access_index=1, + new_code=bytes(Op.SELFDESTRUCT(system_contract)), + ) + ], + ) + if originator_balance > 0: + victim_expectation.balance_changes.append( + BalBalanceChange( + block_access_index=1, + post_balance=originator_balance, + ) + ) + # Caller nonce incremented for CREATE + caller_expectation = BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=2) + ], + ) + if originator_balance > 0 and is_success: + caller_expectation.balance_changes.append( + BalBalanceChange(block_access_index=1, post_balance=0) + ) + account_expectations[caller] = caller_expectation + else: + # Pre-existing victim + if is_success and originator_balance > 0: + victim_expectation = BalAccountExpectation( + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=0) + ], + ) + else: + victim_expectation = BalAccountExpectation.empty() + account_expectations[caller] = BalAccountExpectation.empty() + + account_expectations[victim] = victim_expectation + + # System contract receives balance if success and originator + # had balance + if is_success and originator_balance > 0: + account_expectations[system_contract] = BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=originator_balance + ) + ], + ) + + expected_bal = BlockAccessListExpectation( + account_expectations=account_expectations + ) + + post = build_post_state( + fork, + alice, + caller, + victim, + system_contract, + originator_balance, + beneficiary_initial_balance=0, + same_tx=same_tx, + success=is_success, + beneficiary_has_code=True, + ) + + blockchain_test( + pre=pre, + blocks=[Block(txs=[tx], expected_block_access_list=expected_bal)], + post=post, + ) + + +@pytest.mark.parametrize( + "is_success", [True, False], ids=["exact_gas", "exact_gas_minus_1"] +) +@pytest.mark.parametrize( + "originator_balance", + [0, 1], + ids=["no_balance", "has_balance"], +) +@pytest.mark.parametrize( + "same_tx", [False, True], ids=["pre_deploy", "same_tx"] +) +@pytest.mark.valid_from("TangerineWhistle") +def test_selfdestruct_to_self( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + is_success: bool, + originator_balance: int, + same_tx: bool, +) -> None: + """ + Test SELFDESTRUCT where beneficiary is the executing contract itself. + + Uses Op.SELFDESTRUCT(Op.ADDRESS) - the victim selfdestructs to itself. + + Key characteristics: + - Beneficiary is always warm (it's the executing contract) + - Beneficiary is always alive (EIP-161 nonce=1) + - No G_NEW_ACCOUNT charge + - No cold access charge (>=Berlin) + - Balance is "transferred" to self (no net change until destruction) + + Gas boundary: + - exact_gas: SELFDESTRUCT completes successfully + - exact_gas_minus_1: OOG, SELFDESTRUCT fails + + Post-destruction behavior (is_success=True only): + - Pre-Cancun or same_tx: contract destroyed, balance = 0 + - >=Cancun pre-existing: contract NOT destroyed, balance preserved + """ + alice = pre.fund_eoa() + victim_code = Op.SELFDESTRUCT(Op.ADDRESS) + + # Gas: ADDRESS + SELFDESTRUCT (no cold access, no G_NEW_ACCOUNT) + # Note: ADDRESS opcode costs G_BASE, not G_VERY_LOW like PUSH + gas_costs = fork.gas_costs() + base_gas = gas_costs.G_BASE + gas_costs.G_SELF_DESTRUCT + inner_call_gas = base_gas if is_success else base_gas - 1 + + if same_tx: + # Deploy and selfdestruct in same transaction via factory + initcode = Initcode(deploy_code=victim_code) + initcode_len = len(initcode) + + factory_code = Om.MSTORE(initcode, 0) + Op.CALL( + gas=inner_call_gas, + address=Op.CREATE( + value=originator_balance, offset=0, size=initcode_len + ), + ) + caller = pre.deploy_contract( + code=factory_code, + balance=originator_balance, + ) + victim = compute_create_address(address=caller, nonce=1) + else: + # Pre-existing contract + victim = pre.deploy_contract( + code=victim_code, balance=originator_balance + ) + caller_code = Op.CALL(gas=inner_call_gas, address=victim) + caller = pre.deploy_contract(code=caller_code) + + tx = Transaction( + sender=alice, + to=caller, + gas_limit=500_000, + protected=fork.supports_protected_txs(), + ) + + # Build BAL expectations + expected_bal: BlockAccessListExpectation | None = None + if fork.header_bal_hash_required(): + if same_tx: + if is_success: + # Created and destroyed in same tx - no net changes for victim + victim_expectation = BalAccountExpectation.empty() + else: + # OOG: CREATE succeeded but SELFDESTRUCT failed + victim_expectation = BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + code_changes=[ + BalCodeChange( + block_access_index=1, + new_code=bytes(victim_code), + ) + ], + ) + if originator_balance > 0: + victim_expectation.balance_changes.append( + BalBalanceChange( + block_access_index=1, + post_balance=originator_balance, + ) + ) + + caller_expectation = BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=2) + ], + ) + if originator_balance > 0: + caller_expectation.balance_changes.append( + BalBalanceChange(block_access_index=1, post_balance=0) + ) + else: + # Pre-existing: victim in BAL + if not is_success: + # OOG: victim accessed but no state changes + victim_expectation = BalAccountExpectation.empty() + elif fork >= Cancun: + # >=Cancun success: contract survives with original balance + victim_expectation = BalAccountExpectation.empty() + elif originator_balance > 0: + # Pre-Cancun success: contract destroyed + victim_expectation = BalAccountExpectation( + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=0) + ], + ) + else: + victim_expectation = BalAccountExpectation.empty() + caller_expectation = BalAccountExpectation.empty() + + expected_bal = BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + caller: caller_expectation, + victim: victim_expectation, + } + ) + + # Build post state + caller_nonce = 2 if same_tx else 1 + + if not is_success: + # OOG: SELFDESTRUCT failed, contract survives + if same_tx: + post: dict = { + alice: Account(nonce=1), + caller: Account(nonce=caller_nonce, balance=0), + victim: Account(balance=originator_balance, code=victim_code), + } + else: + post = { + alice: Account(nonce=1), + caller: Account(nonce=caller_nonce), + victim: Account(balance=originator_balance, code=victim_code), + } + else: + contract_destroyed = fork < Cancun or same_tx + if contract_destroyed: + post = { + alice: Account(nonce=1), + caller: Account(nonce=caller_nonce), + victim: Account.NONEXISTENT, + } + else: + # >=Cancun pre-existing: code preserved, balance preserved + post = { + alice: Account(nonce=1), + caller: Account(nonce=caller_nonce), + victim: Account(balance=originator_balance, code=victim_code), + } + + blockchain_test( + pre=pre, + blocks=[Block(txs=[tx], expected_block_access_list=expected_bal)], + post=post, + ) + + +@pytest.mark.parametrize( + "originator_balance", + [0, 1], + ids=["no_balance", "has_balance"], +) +@pytest.mark.valid_from("TangerineWhistle") +def test_initcode_selfdestruct_to_self( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + originator_balance: int, +) -> None: + """ + Test SELFDESTRUCT during initcode execution where beneficiary is self. + + Unlike test_selfdestruct_to_self, this tests the case where the initcode + itself executes SELFDESTRUCT(ADDRESS) during contract creation, before + any code is deployed. + + Key characteristics: + - During initcode, the contract has no code yet + - Contract has nonce=1 (post-EIP-161) making it non-empty + - Beneficiary is always warm (it's the executing contract) + - No G_NEW_ACCOUNT charge (contract has nonce > 0) + - No cold access charge (>=Berlin) + + Note: Gas boundary testing not possible for initcode since CREATE + doesn't accept a gas parameter - it uses all available gas. + """ + alice = pre.fund_eoa() + initcode = Op.SELFDESTRUCT(Op.ADDRESS) + initcode_len = len(initcode) + + factory_code = Om.MSTORE(initcode, 0) + Op.CREATE( + value=originator_balance, offset=0, size=initcode_len + ) + caller = pre.deploy_contract(code=factory_code, balance=originator_balance) + victim = compute_create_address(address=caller, nonce=1) + + tx = Transaction( + sender=alice, + to=caller, + gas_limit=500_000, + protected=fork.supports_protected_txs(), + ) + + # Build BAL expectations + expected_bal: BlockAccessListExpectation | None = None + if fork.header_bal_hash_required(): + # Contract created and immediately destroyed - no net changes + # for victim + caller_expectation = BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=2)], + ) + if originator_balance > 0: + caller_expectation.balance_changes.append( + BalBalanceChange(block_access_index=1, post_balance=0) + ) + + expected_bal = BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + caller: caller_expectation, + victim: BalAccountExpectation.empty(), + } + ) + + # Contract was created and destroyed in same tx + post: dict = { + alice: Account(nonce=1), + caller: Account(nonce=2), + victim: Account.NONEXISTENT, + } + + blockchain_test( + pre=pre, + blocks=[Block(txs=[tx], expected_block_access_list=expected_bal)], + post=post, + )