diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini b/packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini index 179ac2082f0..ab71acc6b8f 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini +++ b/packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini @@ -22,6 +22,7 @@ addopts = --ignore tests/cancun/eip4844_blobs/point_evaluation_vectors/ --ignore tests/json_loader --ignore tests/evm_tools + --ignore tests/ported_static # these customizations require the pytest-custom-report plugin report_passed_verbose = FILLED report_xpassed_verbose = XFILLED diff --git a/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_2780.py b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_2780.py index 095bf9331ea..cfdc195dc6b 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_2780.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_2780.py @@ -12,7 +12,7 @@ from execution_testing.base_types import AccessList from execution_testing.base_types.conversions import BytesConvertible -from execution_testing.vm import OpcodeBase +from execution_testing.vm import OpcodeBase, Opcodes from .....recipient_type import RecipientType from ....base_fork import ( @@ -37,6 +37,7 @@ def gas_costs(cls) -> GasCosts: COLD_ACCOUNT_COST_CODE=2_600, COLD_ACCOUNT_COST_NO_CODE=500, STATE_UPDATE=1_000, + TRANSFER_LOG_COST=1_756, ) @classmethod @@ -78,6 +79,7 @@ def fn( " RecipientType.DELEGATION_7702" ) + log_cost = 0 if contract_creation or recipient_type == RecipientType.SELF: access_cost = 0 update_cost = 0 @@ -86,6 +88,7 @@ def fn( update_cost = 0 if sends_value: update_cost += gas_costs.STATE_UPDATE + log_cost = gas_costs.TRANSFER_LOG_COST else: if recipient_is_warm: access_cost = gas_costs.WARM_ACCESS @@ -109,8 +112,9 @@ def fn( update_cost = gas_costs.NEW_ACCOUNT else: update_cost = gas_costs.STATE_UPDATE + log_cost = gas_costs.TRANSFER_LOG_COST - intrinsic_cost += access_cost + update_cost + intrinsic_cost += access_cost + update_cost + log_cost if return_cost_deducted_prior_execution: return intrinsic_cost @@ -138,8 +142,18 @@ def _calculate_call_gas( - ``COLD_ACCOUNT_COST_CODE`` (2600) for targets with code Value transfer replaces ``CALL_VALUE`` (9000) with: - - ``2 * STATE_UPDATE`` (2000) for non-empty targets - - ``STATE_UPDATE + NEW_ACCOUNT`` (26000) for empty targets + - ``STATE_UPDATE`` (1000) for self-calls (``caller == to``); no + ``TRANSFER_LOG_COST`` applies since EIP-7708 does not emit a log + for self-transfers. ``CALLCODE`` is always a self-call because + it runs target code in the caller's own context. + - ``2 * STATE_UPDATE + TRANSFER_LOG_COST`` (3756) for existing + non-self targets. + - ``STATE_UPDATE + NEW_ACCOUNT + TRANSFER_LOG_COST`` (27756) for + empty non-self targets. + + Self-call scenarios for ``CALL`` are indicated by the + ``self_call`` metadata flag; it defaults to ``False`` so existing + non-self tests remain unaffected. """ metadata = opcode.metadata @@ -152,10 +166,21 @@ def _calculate_call_gas( value_cost = 0 if "value_transfer" in metadata and metadata["value_transfer"]: - if metadata["account_new"]: - value_cost = gas_costs.STATE_UPDATE + gas_costs.NEW_ACCOUNT + is_self_call = opcode == Opcodes.CALLCODE or metadata.get( + "self_call", False + ) + if is_self_call: + value_cost = gas_costs.STATE_UPDATE + elif metadata["account_new"]: + value_cost = ( + gas_costs.STATE_UPDATE + + gas_costs.NEW_ACCOUNT + + gas_costs.TRANSFER_LOG_COST + ) else: - value_cost = 2 * gas_costs.STATE_UPDATE + value_cost = ( + 2 * gas_costs.STATE_UPDATE + gas_costs.TRANSFER_LOG_COST + ) delegation_cost = 0 if metadata["delegated_address"] or metadata["delegated_address_warm"]: @@ -166,6 +191,24 @@ def _calculate_call_gas( return access_cost + value_cost + delegation_cost + @classmethod + def _calculate_selfdestruct_gas( + cls, opcode: OpcodeBase, gas_costs: GasCosts + ) -> int: + """ + SELFDESTRUCT adds ``TRANSFER_LOG_COST`` when the destruction + moves non-zero balance to a different beneficiary, mirroring the + runtime rule in EIP-2780. + """ + base_cost = super(EIP2780, cls)._calculate_selfdestruct_gas( + opcode, gas_costs + ) + + if opcode.metadata.get("transfers_value", False): + base_cost += gas_costs.TRANSFER_LOG_COST + + return base_cost + @classmethod def _with_account_access( cls, diff --git a/packages/testing/src/execution_testing/forks/gas_costs.py b/packages/testing/src/execution_testing/forks/gas_costs.py index e218771d7d5..8c5d2fd72f5 100644 --- a/packages/testing/src/execution_testing/forks/gas_costs.py +++ b/packages/testing/src/execution_testing/forks/gas_costs.py @@ -151,3 +151,4 @@ class GasCosts: COLD_ACCOUNT_COST_CODE: int = 0 COLD_ACCOUNT_COST_NO_CODE: int = 0 STATE_UPDATE: int = 0 + TRANSFER_LOG_COST: int = 0 diff --git a/packages/testing/src/execution_testing/vm/opcodes.py b/packages/testing/src/execution_testing/vm/opcodes.py index 58d1a9815a3..acd90316fcf 100644 --- a/packages/testing/src/execution_testing/vm/opcodes.py +++ b/packages/testing/src/execution_testing/vm/opcodes.py @@ -5165,6 +5165,7 @@ class Opcodes(Opcode, Enum): "address_has_code": True, "value_transfer": False, "account_new": False, + "self_call": False, "new_memory_size": 0, "old_memory_size": 0, "delegated_address": False, @@ -5216,6 +5217,8 @@ class Opcodes(Opcode, Enum): - address_warm: whether the address is already warm (default: False) - value_transfer: whether value is being transferred (default: False) - account_new: whether creating a new account (default: False) + - self_call: whether ``caller == to`` so the value transfer is a + self-transfer (default: False); consumed by EIP-2780 - new_memory_size: memory size after expansion in bytes (default: 0) - old_memory_size: memory size before expansion in bytes (default: 0) - delegated_address: whether the target is a delegated account @@ -5631,7 +5634,11 @@ class Opcodes(Opcode, Enum): 0xFF, popped_stack_items=1, kwargs=["address"], - metadata={"address_warm": False, "account_new": False}, + metadata={ + "address_warm": False, + "account_new": False, + "transfers_value": False, + }, ) """ SELFDESTRUCT(address) @@ -5659,6 +5666,9 @@ class Opcodes(Opcode, Enum): (default: False) - account_new: whether creating a new beneficiary account, requires non-zero balance in the source account (default: False) + - transfers_value: whether the destruction moves non-zero balance to + a different beneficiary (default: False); consumed + by EIP-2780 to charge ``TRANSFER_LOG_COST`` Source: [evm.codes/#FF](https://www.evm.codes/#FF) """ diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index be0d3e530af..88352ce691c 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -1022,7 +1022,9 @@ def calculate_recipient_gas_cost( if tx.to in PRE_COMPILED_CONTRACTS: if tx.value > U256(0): - tx_recipient_cost += GasCosts.STATE_UPDATE + tx_recipient_cost += ( + GasCosts.STATE_UPDATE + GasCosts.TRANSFER_LOG_COST + ) return tx_recipient_cost is_cold_access = tx.to not in access_list_addresses @@ -1056,6 +1058,7 @@ def calculate_recipient_gas_cost( tx_recipient_cost += GasCosts.NEW_ACCOUNT else: tx_recipient_cost += GasCosts.STATE_UPDATE + tx_recipient_cost += GasCosts.TRANSFER_LOG_COST return tx_recipient_cost diff --git a/src/ethereum/forks/amsterdam/vm/gas.py b/src/ethereum/forks/amsterdam/vm/gas.py index 63af2ca38ba..1401703e940 100644 --- a/src/ethereum/forks/amsterdam/vm/gas.py +++ b/src/ethereum/forks/amsterdam/vm/gas.py @@ -75,6 +75,15 @@ class GasCosts: [EIP-2780]: https://eips.ethereum.org/EIPS/eip-2780 """ + TRANSFER_LOG_COST = Uint(1756) + """ + Gas cost for the [EIP-7708] transfer log (LOG3 equivalent: + `375 + 3*375 + 32*8`) charged on every nonzero-value transfer to a + different account. Introduced by [EIP-2780]. + + [EIP-2780]: https://eips.ethereum.org/EIPS/eip-2780 + [EIP-7708]: https://eips.ethereum.org/EIPS/eip-7708 + """ # Contract Creation CODE_DEPOSIT_PER_BYTE = Uint(200) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 0376bb7a46b..fc7b0637b57 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -402,10 +402,18 @@ def call(evm: Evm) -> None: call_value_cost = Uint(0) if value > U256(0): - if call_target == EMPTY_ACCOUNT: - call_value_cost += GasCosts.STATE_UPDATE + GasCosts.NEW_ACCOUNT + if evm.message.current_target == to: + call_value_cost += GasCosts.STATE_UPDATE + elif call_target == EMPTY_ACCOUNT: + call_value_cost += ( + GasCosts.STATE_UPDATE + + GasCosts.NEW_ACCOUNT + + GasCosts.TRANSFER_LOG_COST + ) else: - call_value_cost += Uint(2) * GasCosts.STATE_UPDATE + call_value_cost += ( + Uint(2) * GasCosts.STATE_UPDATE + GasCosts.TRANSFER_LOG_COST + ) extra_gas = access_gas_cost + call_value_cost ( @@ -496,10 +504,10 @@ def callcode(evm: Evm) -> None: if is_cold_access: access_gas_cost = GasCosts.COLD_ACCOUNT_COST_NO_CODE - # Cost is simply dependent on value since the contract is always there - call_value_cost = ( - Uint(0) if value == 0 else Uint(2) * GasCosts.STATE_UPDATE - ) + # CALLCODE executes the target's code in the caller's own context, so + # the value transfer is from the caller to itself. Only one + # STATE_UPDATE is charged and no TRANSFER_LOG_COST applies. + call_value_cost = Uint(0) if value == 0 else GasCosts.STATE_UPDATE # check static gas before state access check_gas(evm, access_gas_cost + extend_memory.cost + call_value_cost) @@ -600,16 +608,18 @@ def selfdestruct(evm: Evm) -> None: if is_cold_access: evm.accessed_addresses.add(beneficiary) - if ( - not is_account_alive(tx_state, beneficiary) - and get_account(tx_state, evm.message.current_target).balance != 0 - ): + originator = evm.message.current_target + originator_balance = get_account(tx_state, originator).balance + + if not is_account_alive( + tx_state, beneficiary + ) and originator_balance > U256(0): gas_cost += GasCosts.OPCODE_SELFDESTRUCT_NEW_ACCOUNT - charge_gas(evm, gas_cost) + if originator != beneficiary and originator_balance > U256(0): + gas_cost += GasCosts.TRANSFER_LOG_COST - originator = evm.message.current_target - originator_balance = get_account(tx_state, originator).balance + charge_gas(evm, gas_cost) # Transfer balance move_ether(tx_state, originator, beneficiary, originator_balance) diff --git a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/spec.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/spec.py index a66954f6b7e..8c1804ad804 100644 --- a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/spec.py +++ b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/spec.py @@ -13,5 +13,5 @@ class ReferenceSpec: ref_spec_2780 = ReferenceSpec( git_path="EIPS/eip-2780.md", - version="5c092808affade87ad04086b8ce0c41cb8d2b5dd", + version="c550387e917485af69a6999aea45270a555e2eb7", ) diff --git a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_value_move_to_precompiles.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_value_move_to_precompiles.py index 46dd31d9e1b..493e0fd3b42 100644 --- a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_value_move_to_precompiles.py +++ b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_value_move_to_precompiles.py @@ -96,7 +96,8 @@ def test_value_move_to_precompiles( Ensure value moving transactions to precompiles charge gas correctly. Under EIP-2780, precompile recipients have zero access cost (they are - always warm). Value transfer to a precompile incurs only G_STATE_UPDATE. + always warm). Value transfer to a precompile incurs a + ``G_STATE_UPDATE`` plus the EIP-7708 ``TRANSFER_LOG_COST``. """ sender_initial_balance = 10**18 sender = pre.fund_eoa(sender_initial_balance) diff --git a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_value_moving_calls.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_value_moving_calls.py index 47a86d45d6d..5f9e5b38def 100644 --- a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_value_moving_calls.py +++ b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_value_moving_calls.py @@ -75,6 +75,7 @@ def _run_call_test( has_value_transfer: bool, account_new: bool, post_fn: PostFn, + is_self_call: bool = False, ) -> None: """ Core logic shared by all CALL-family opcode tests. @@ -108,13 +109,20 @@ def _run_call_test( ) bytecode_cost = gsc.VERY_LOW * n_args - # Value cost depends on whether the target is new or existing. + # Value cost depends on whether the transfer is a self-call and on + # whether the target is new or existing. Self-calls charge only a + # single STATE_UPDATE with no TRANSFER_LOG_COST (EIP-7708 does not + # emit a log for self-transfers). value_cost = 0 if has_value_transfer and value > 0: - if account_new: - value_cost = gsc.STATE_UPDATE + gsc.NEW_ACCOUNT + if is_self_call: + value_cost = gsc.STATE_UPDATE + elif account_new: + value_cost = ( + gsc.STATE_UPDATE + gsc.NEW_ACCOUNT + gsc.TRANSFER_LOG_COST + ) else: - value_cost = 2 * gsc.STATE_UPDATE + value_cost = 2 * gsc.STATE_UPDATE + gsc.TRANSFER_LOG_COST # Gas for the tested threshold, minus 1 for OOG. scenario_gas = compute_scenario_gas(access, gsc) @@ -185,9 +193,10 @@ def test_call( """ Test CALL opcode gas charging under EIP-2780. - CALL transfers value from caller to target. With value > 0, - the value cost is 2 * STATE_UPDATE for existing targets - or STATE_UPDATE + NEW_ACCOUNT for new accounts. + CALL transfers value from caller to target. With value > 0, the + value cost is ``2 * STATE_UPDATE + TRANSFER_LOG_COST`` for existing + targets or ``STATE_UPDATE + NEW_ACCOUNT + TRANSFER_LOG_COST`` for + new accounts. """ if account_new and access == AccessScenario.COLD_CODE: pytest.skip("Empty target has no code") @@ -277,8 +286,10 @@ def test_callcode( """ Test CALLCODE opcode gas charging under EIP-2780. - CALLCODE transfers value to self (caller), so there is no - net balance change even on success with value > 0. + CALLCODE transfers value to self (caller), so there is no net + balance change even on success with value > 0. The value cost is + a single ``STATE_UPDATE`` with no ``TRANSFER_LOG_COST`` because + EIP-7708 does not emit a log for self-transfers. """ def caller_code_fn(target: Address, val: int) -> Bytecode: @@ -316,6 +327,7 @@ def post_fn( has_value_transfer=True, account_new=False, post_fn=post_fn, + is_self_call=True, ) 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 index c81aff2a27b..abc24328fe4 100644 --- a/tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py +++ b/tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py @@ -64,11 +64,16 @@ def calculate_selfdestruct_gas( # Pre-EIP-161: always charged when beneficiary is dead needs_new_account = True - # PUSH + SELFDESTRUCT (with metadata for warm/cold and new account) + # PUSH + SELFDESTRUCT (with metadata for warm/cold and new account). + # ``transfers_value`` triggers the EIP-2780 TRANSFER_LOG_COST when + # destruction moves non-zero balance to a different beneficiary; the + # tests here always use a distinct beneficiary, so it reduces to + # "does the originator hold any balance?". return Op.SELFDESTRUCT( 0, # beneficiary address (generates a PUSH) address_warm=beneficiary_warm or fork < Berlin, account_new=needs_new_account, + transfers_value=originator_balance > 0, ).gas_cost(fork) @@ -367,7 +372,10 @@ def test_selfdestruct_to_account( if not is_success: inner_call_gas -= 1 - # In BAL if: success OR NEW_ACCOUNT charged (OOG after access) + # In BAL if: success OR a post-state-access charge applies (OOG + # happens after state access, so beneficiary is still recorded). + # Post-state-access charges are NEW_ACCOUNT and, on >= Amsterdam, + # TRANSFER_LOG_COST (since beneficiary != originator here). needs_new_account = False if beneficiary_dead: if fork >= SpuriousDragon: @@ -375,7 +383,13 @@ def test_selfdestruct_to_account( else: needs_new_account = True - beneficiary_in_bal = is_success or needs_new_account + transfers_value = originator_balance > 0 + transfer_log_charged = ( + fork.gas_costs().TRANSFER_LOG_COST > 0 and transfers_value + ) + beneficiary_in_bal = ( + is_success or needs_new_account or transfer_log_charged + ) alice, caller, victim, tx = setup_selfdestruct_test( pre, @@ -507,9 +521,19 @@ def test_selfdestruct_state_access_boundary( else: needs_new_account = True - # At exact_gas: success if no NEW_ACCOUNT needed - # At exact_gas_minus_1: always OOG (before state access) - operation_success = is_success and not needs_new_account + # At exact_gas: success if no post-state-access charge applies. + # Post-state-access charges are NEW_ACCOUNT (when beneficiary is + # dead and originator has balance) and, on >= Amsterdam, + # TRANSFER_LOG_COST (when originator has balance and beneficiary is + # a different account — always true in this test). + # At exact_gas_minus_1: always OOG (before state access). + transfers_value = originator_balance > 0 + transfer_log_charged = ( + fork.gas_costs().TRANSFER_LOG_COST > 0 and transfers_value + ) + operation_success = ( + is_success and not needs_new_account and not transfer_log_charged + ) alice, caller, victim, tx = setup_selfdestruct_test( pre, @@ -613,7 +637,10 @@ def test_selfdestruct_to_precompile( if not is_success: inner_call_gas -= 1 - # In BAL if: success OR NEW_ACCOUNT charged (OOG after access) + # In BAL if: success OR a post-state-access charge applies + # (OOG happens after state access, so beneficiary is still + # recorded). Post-state-access charges are NEW_ACCOUNT and, on + # >= Amsterdam, TRANSFER_LOG_COST. needs_new_account = False if beneficiary_dead: if fork >= SpuriousDragon: @@ -621,7 +648,13 @@ def test_selfdestruct_to_precompile( else: needs_new_account = True - beneficiary_in_bal = is_success or needs_new_account + transfers_value = originator_balance > 0 + transfer_log_charged = ( + fork.gas_costs().TRANSFER_LOG_COST > 0 and transfers_value + ) + beneficiary_in_bal = ( + is_success or needs_new_account or transfer_log_charged + ) alice, caller, victim, tx = setup_selfdestruct_test( pre, @@ -720,7 +753,10 @@ def test_selfdestruct_to_precompile_state_access_boundary( if not is_success: inner_call_gas -= 1 - # Success at base cost if no NEW_ACCOUNT needed + # Success at base cost if no post-state-access charge applies. + # Post-state-access charges are NEW_ACCOUNT and, on >= Amsterdam, + # TRANSFER_LOG_COST (when originator has balance — precompile + # beneficiary is always a different account). needs_new_account = False if beneficiary_dead: if fork >= SpuriousDragon: @@ -728,7 +764,13 @@ def test_selfdestruct_to_precompile_state_access_boundary( else: needs_new_account = True - operation_success = is_success and not needs_new_account + transfers_value = originator_balance > 0 + transfer_log_charged = ( + fork.gas_costs().TRANSFER_LOG_COST > 0 and transfers_value + ) + operation_success = ( + is_success and not needs_new_account and not transfer_log_charged + ) alice, caller, victim, tx = setup_selfdestruct_test( pre,