From b9c2095a1b6bf6c644b4f2e1291b5b92cc9db1b9 Mon Sep 17 00:00:00 2001 From: danceratopz Date: Thu, 25 Jun 2026 10:38:19 +0200 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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, }