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 3746a921b42..8ef87da63b0 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 @@ -146,16 +146,11 @@ def transaction_top_frame_state_gas( """ Return the state gas charged at the top-level transaction frame. Charges ``NEW_ACCOUNT`` when value is transferred to an - empty recipient; zero otherwise. Precompile recipients are - exempt: they are protocol-inherent rather than created by a - value transfer, so they never trigger ``NEW_ACCOUNT`` even - when state-empty. + empty recipient; zero otherwise. """ gas_costs = cls.gas_costs() if contract_creation: return 0 - if recipient_type == RecipientType.PRECOMPILE: - return 0 if sends_value and recipient_type == RecipientType.EMPTY_ACCOUNT: return gas_costs.NEW_ACCOUNT return 0 diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index e3c5f646eae..4809d9bd215 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -312,16 +312,11 @@ def process_message(message: Message) -> Evm: # opcode runs. Reads pre-value-transfer state so the # EIP-161-empty check sees the recipient as it was at the start # of the frame. Gated to non-create top-level frames; creates - # pay the equivalent NEW_ACCOUNT state gas. Precompile - # recipients are exempt from the NEW_ACCOUNT charge: they are - # protocol-inherent rather than created by a value transfer. + # pay the equivalent NEW_ACCOUNT state gas. if message.depth == Uint(0) and message.target != Bytes0(b""): recipient = message.current_target - recipient_is_precompile = recipient in PRE_COMPILED_CONTRACTS - if ( - message.value > U256(0) - and not recipient_is_precompile - and not is_account_alive(tx_state, recipient) + if message.value > U256(0) and not is_account_alive( + tx_state, recipient ): charge_state_gas(evm, StateGasCosts.NEW_ACCOUNT) recipient_code = get_code( diff --git a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_top_frame_charges.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_top_frame_charges.py index dd791c5e78d..c56c06439b0 100644 --- a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_top_frame_charges.py +++ b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_top_frame_charges.py @@ -19,6 +19,7 @@ import pytest from execution_testing import ( Account, + Address, Alloc, Fork, Op, @@ -104,6 +105,60 @@ def test_top_frame_state_charge( state_test(pre=pre, tx=tx, post=post) +def test_top_frame_state_charge_empty_precompile( + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, +) -> None: + """ + An empty precompile recipient is still empty per EIP-161, so a + value-moving transaction to it must pay the top-frame + ``NEW_ACCOUNT`` state-gas charge. + + The gas limit is one short of covering that state charge. Without + the charge, the transaction would reach the identity precompile and + transfer value, which makes this a direct regression test for a + precompile carve-out. + """ + sender_initial_balance = 10**18 + sender = pre.fund_eoa(sender_initial_balance) + identity_precompile = Address(0x04) + + value = 1 + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + sends_value=True, + recipient_type=RecipientType.PRECOMPILE, + return_cost_deducted_prior_execution=True, + ) + top_frame_state_gas = fork.transaction_top_frame_state_gas( + sends_value=True, + recipient_type=RecipientType.EMPTY_ACCOUNT, + ) + assert top_frame_state_gas > 0, ( + "top-frame state gas must be non-zero for empty recipients" + ) + + gas_price = 1_000_000_000 + gas_limit = intrinsic_gas + top_frame_state_gas - 1 + tx = Transaction( + sender=sender, + to=identity_precompile, + value=value, + gas_limit=gas_limit, + gas_price=gas_price, + ) + + post = { + sender: Account( + nonce=1, + balance=sender_initial_balance - gas_limit * gas_price, + ), + identity_precompile: None, + } + + state_test(pre=pre, tx=tx, post=post) + + @pytest.mark.parametrize("outcome", ["oog", "success", "evm_reverts"]) @pytest.mark.parametrize( "value", diff --git a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_value_moving_transactions.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_value_moving_transactions.py index 77f5fc28de6..ace321454ab 100644 --- a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_value_moving_transactions.py +++ b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_value_moving_transactions.py @@ -300,18 +300,17 @@ def test_value_move_to_precompiles( intrinsic time as any other non-self target -- access lists do not warm transaction-level accounts. A value transfer to a precompile additionally pays the transfer-log and value-transfer - charges. The top-frame ``NEW_ACCOUNT`` state charge is suppressed - for precompile recipients: they are protocol-inherent rather than - created by a value transfer. - - The ``pre_funded`` parameter exercises both pre-tx states of the - precompile address: the ``empty`` variant proves the carve-out is - explicit -- without it, a value transfer to an empty precompile - would trip the ``NEW_ACCOUNT`` charge that ``is_account_alive`` - would otherwise demand. The gas limit is pinned to - ``intrinsic_gas`` plus a buffer that covers the precompile - execution body, so a miscalculated intrinsic or a missing - carve-out would trip the gas budget rather than pass silently. + charges. + + The top-frame ``NEW_ACCOUNT`` state charge keys solely on EIP-161 + emptiness; a precompile address is not special-cased. The + ``pre_funded`` parameter exercises both pre-tx states: + + - ``not_funded``: the precompile address is empty per EIP-161, so a + value transfer creates it and pays ``NEW_ACCOUNT`` -- exactly + like any other empty recipient. + - ``pre_funded``: the precompile already holds a balance and is + therefore alive, so no ``NEW_ACCOUNT`` charge applies. """ sender_initial_balance = 10**18 sender = pre.fund_eoa(sender_initial_balance) @@ -329,13 +328,17 @@ def test_value_move_to_precompiles( recipient_type=RecipientType.PRECOMPILE, return_cost_deducted_prior_execution=True, ) + # A value transfer to an empty (not pre-funded) precompile fires the + # top-frame ``NEW_ACCOUNT`` state charge, modelled via + # ``EMPTY_ACCOUNT``; a pre-funded precompile is alive and exempt. + state_recipient_type = ( + RecipientType.PRECOMPILE if pre_funded else RecipientType.EMPTY_ACCOUNT + ) + top_frame_state_gas = fork.transaction_top_frame_state_gas( + sends_value=bool(value), + recipient_type=state_recipient_type, + ) - # Precompile execution gas varies per precompile and input; pick - # a buffer large enough to cover the most expensive precompile in - # the matrix but still well under the ``NEW_ACCOUNT`` state - # charge, so a missing carve-out OOGs the ``empty`` variants. - precompile_execution_budget = 100_000 - tx_gas_limit = intrinsic_gas + precompile_execution_budget gas_price = 1_000_000_000 tx = Transaction( @@ -343,20 +346,39 @@ def test_value_move_to_precompiles( to=precompile, value=value, data=tx_data, - gas_limit=tx_gas_limit, gas_price=gas_price, ) - # Exact sender balance is not checked because precompile execution - # gas varies; verify value receipt and sender nonce instead. + # Exact sender balance is generally not checked because precompile + # execution gas varies across the matrix. For identity with empty + # calldata, the execution gas is deterministic, so pin the exact + # balance to make the empty-precompile ``NEW_ACCOUNT`` charge a + # source-level assertion. final_precompile_balance = pre_funded_amount + value expected_precompile: Account | None if final_precompile_balance > 0: expected_precompile = Account(balance=final_precompile_balance) else: expected_precompile = None + expected_sender = Account(nonce=1) + if precompile == Address(0x04): + gas_costs = fork.gas_costs() + precompile_execution_gas = ( + gas_costs.PRECOMPILE_IDENTITY_BASE + + gas_costs.PRECOMPILE_IDENTITY_PER_WORD + * ((len(tx_data) + 31) // 32) + ) + total_gas_cost = ( + intrinsic_gas + top_frame_state_gas + precompile_execution_gas + ) + expected_sender = Account( + nonce=1, + balance=( + sender_initial_balance - value - total_gas_cost * gas_price + ), + ) post = { - sender: Account(nonce=1), + sender: expected_sender, precompile: expected_precompile, }