From 3e38118442cac8ae262a565778ee9c1807dc70e3 Mon Sep 17 00:00:00 2001 From: Guruprasad Kamath Date: Thu, 25 Jun 2026 09:37:16 +0200 Subject: [PATCH 1/4] fix(amsterdam): charge NEW_ACCOUNT for value transfer to empty precompile EIP-2780 charges the NEW_ACCOUNT state cost when a transaction transfers value to a recipient that is empty per EIP-161. The top-frame charge previously carved out precompile recipients, but neither EIP-2780 nor EIP-161 authorizes that exemption: - EIP-2780 does not mention precompiles; its rule keys solely on "empty per EIP-161 and tx.value > 0". - EIP-161 defines empty structurally (no code, zero nonce, zero balance) with no precompile exception, so an unfunded precompile is empty and is created by the value transfer like any other account. Remove the `recipient_is_precompile` carve-out from the top-frame charge so an empty precompile receiving value pays NEW_ACCOUNT, drop the matching special-case from the testing framework's `transaction_top_frame_state_gas`, and rewrite `test_value_move_to_precompiles` to assert the charge fires for the not-funded precompile while a pre-funded (alive) precompile remains exempt by virtue of being non-empty. --- .../forks/forks/eips/amsterdam/eip_2780.py | 7 +-- .../forks/amsterdam/vm/interpreter.py | 11 ++--- .../test_value_moving_transactions.py | 44 ++++++++++++------- 3 files changed, 31 insertions(+), 31 deletions(-) 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_value_moving_transactions.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_value_moving_transactions.py index 77f5fc28de6..25313bdf356 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,24 @@ 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 gas varies per precompile and input; pick a + # buffer large enough to cover the most expensive precompile in the + # matrix on top of the intrinsic and the (spilled) state charge. precompile_execution_budget = 100_000 - tx_gas_limit = intrinsic_gas + precompile_execution_budget + tx_gas_limit = ( + intrinsic_gas + top_frame_state_gas + precompile_execution_budget + ) gas_price = 1_000_000_000 tx = Transaction( From b9c2095a1b6bf6c644b4f2e1291b5b92cc9db1b9 Mon Sep 17 00:00:00 2001 From: danceratopz Date: Thu, 25 Jun 2026 10:38:19 +0200 Subject: [PATCH 2/4] feat(tests): add empty precompile top-frame charge test Add a gas-boundary regression test for value transfers to an unfunded precompile. The transaction is one gas short of covering the top-frame NEW_ACCOUNT state charge, so implementations that incorrectly carve out precompile recipients reach the identity precompile and fail the expected post-state instead of silently filling. --- .../test_top_frame_charges.py | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) 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", From 1a56b1f05ede5a49b2c5da4b2f2f0d93f455466a Mon Sep 17 00:00:00 2001 From: danceratopz Date: Thu, 25 Jun 2026 10:39:14 +0200 Subject: [PATCH 3/4] refactor(tests): remove explicit `Trancsaction(gas_limit=...)` cf #2969 --- .../test_value_moving_transactions.py | 25 ------------------- 1 file changed, 25 deletions(-) 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 25313bdf356..bf6f5b136ce 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 @@ -322,30 +322,6 @@ def test_value_move_to_precompiles( tx_data = _precompile_calldata(precompile) - intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( - calldata=tx_data, - sends_value=bool(value), - 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 on top of the intrinsic and the (spilled) state charge. - precompile_execution_budget = 100_000 - tx_gas_limit = ( - intrinsic_gas + top_frame_state_gas + precompile_execution_budget - ) gas_price = 1_000_000_000 tx = Transaction( @@ -353,7 +329,6 @@ def test_value_move_to_precompiles( to=precompile, value=value, data=tx_data, - gas_limit=tx_gas_limit, gas_price=gas_price, ) From 57886b62faff63580ad9a08269ac9ca0ee90ad91 Mon Sep 17 00:00:00 2001 From: danceratopz Date: Thu, 25 Jun 2026 10:41:27 +0200 Subject: [PATCH 4/4] chore(tests): guard empty precompile gas assertion Pin the sender balance for one value-moving transaction to an unfunded precompile so the source test fails if the NEW_ACCOUNT top-frame state charge is accidentally skipped. Without this check the broad precompile matrix can still fill because recipient balance and sender nonce are unchanged by the missing charge. Use precompile 0x04 specifically because identity accepts the empty calldata already used by this test and has deterministic execution gas. The NEW_ACCOUNT rule is independent of which precompile executes, so this avoids duplicating every precompile's gas model while still catching the silent-fill regression. --- .../test_value_moving_transactions.py | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) 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 bf6f5b136ce..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 @@ -322,6 +322,23 @@ def test_value_move_to_precompiles( tx_data = _precompile_calldata(precompile) + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + calldata=tx_data, + sends_value=bool(value), + 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, + ) + gas_price = 1_000_000_000 tx = Transaction( @@ -332,16 +349,36 @@ def test_value_move_to_precompiles( 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, }