Skip to content

bug(tests): Intrinsic gas no longer guarantees a transaction reaches the top frame #3301

Description

@marioevz

Summary (AI assisted)

Before Amsterdam, funding a transaction with its intrinsic gas cost guaranteed it would reach the start of EVM execution. EIP-2780 breaks that invariant: costs that depend on the pre-transaction state — an authority's account creation, a net-new delegation indicator, the first write to an account leaf — moved out of the intrinsic cost and are now charged at the top frame, after the intrinsic deduction but before execution begins. A transaction funded with only its intrinsic cost now runs out of gas in the top frame.

This is silent. The transaction is still valid and still included, so tests that only assert validity (or assert gas_used without asserting receipt status) keep passing while the transaction they meant to exercise does nothing.

set_delegation's own docstring in src/ethereum/forks/amsterdam/vm/eoa_delegation.py states the consequence:

These costs depend on the authority's current state and so cannot be charged in the intrinsic cost. Insufficient gas raises an OutOfGasError; the caller rolls back the authorizations applied so far and halts the top frame.

Evidence

tests/amsterdam/eip7976_increase_calldata_floor_cost/test_transaction_validity.py::test_transaction_validity_type_4 and its Prague ancestor tests/prague/eip7623_increase_calldata_cost/test_transaction_validity.py::test_transaction_validity_type_4 size their gas limit as tx_intrinsic_gas_cost_including_floor_data_cost + tx_gas_delta (see conftest.py), with no top-frame term. Filled at Amsterdam, every type-4 transaction that gets included fails:

eip7976 type_4 at Amsterdam: success=0  FAILED(status=False)=56  no-receipt(invalid, expected)=28
eip7623 type_4 at Amsterdam: success=0  FAILED(status=False)=56  no-receipt(invalid, expected)=28

Taking single_authorization-no_access_list-exact_gas-floor_gas_greater_than_intrinsic_gas as the concrete case — 163 bytes of calldata, one authorization:

intrinsic              = 23468
floor (= gas limit)    = 25432
top_frame_execution    =  8000   (ACCOUNT_WRITE)
top_frame_state        = 35190   (AUTH_BASE)
intrinsic + top_frame  = 66658

shortfall              = 41226

The resulting fixture:

  • receipt status = False, cumulativeGasUsed = 25432
  • the authority is absent from both pre and postState — the delegation never lands and the authority's leaf is never created
  • the test passes regardless, because it declares post={} and asserts nothing about status

Note how the failure hides: the transaction OOGs and therefore consumes its entire gas limit, which happens to equal the floor, so gas_used is exactly what a successful floor-bound transaction would have consumed. Nothing distinguishes the two without a status assertion.

For contrast, tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_calldata_floor.py::test_calldata_floor_with_authorizations computes its threshold against intrinsic + top_frame_gas + top_frame_state_gas, which pushes it to 851 bytes of calldata and a 69,464 gas limit. Its fixture has status = True and the authority ends up delegated (nonce 1, code 0xef0100…). That test is the only one in the calldata-floor family that actually exercises an authorization under a binding floor.

Proposed fix

Add a fork-level helper that returns the gas a transaction needs in order to be guaranteed to reach the start of execution, so tests have one thing to call instead of remembering to sum three:

@classmethod
def transaction_gas_to_reach_execution_calculator(cls) -> ...:
    """
    Return a callable giving the gas a transaction must be funded with to
    be guaranteed to reach the start of EVM execution: the intrinsic cost
    plus any gas charged at the top frame before execution begins.
    """

It should accept the union of the parameters the three existing functions take (calldata, contract_creation, access_list, authorization_list_or_count / authorizations, sends_value, recipient_type, delegation_warm) and return intrinsic + top_frame_execution + top_frame_state.

This is naturally correct across all forks: transaction_top_frame_gas_calculator and transaction_top_frame_state_gas in base_fork.py already default to 0, so pre-Amsterdam the helper returns exactly the intrinsic cost and existing behaviour is unchanged.

Alternative names, if transaction_gas_to_reach_execution_calculator is too long: transaction_execution_entry_cost_calculator, or minimum_transaction_gas_calculator.

Scope of the migration

transaction_intrinsic_cost_calculator is used at 354 call sites across 128 files under tests/:

directory files
amsterdam 51
ported_static 34
benchmark 20
prague 9
osaka 4
cancun 4
shanghai 2
berlin 2
tangerine_whistle 1
frontier 1

37 files already reference transaction_top_frame_gas_calculator or transaction_top_frame_state_gas and are presumably fine.

This is an upper bound, not a work list. The top-frame charge is only non-zero for specific shapes — authorizations, value transfer to an empty account, contract creation — so a call site that funds a plain value-free call to an existing contract needs no change. The affected subset needs triage. Amsterdam tests written before EIP-2780 landed are as suspect as pre-Amsterdam ones, since they run under the current schedule.

Two things would make the remaining cases self-reporting rather than silent:

  • pin status in expected_receipt (or assert post-state that only a successful transaction can produce) wherever a test funds a transaction at exactly its computed cost
  • for suites that binary-search a threshold against the intrinsic cost, search against the top-frame-inclusive total instead, as test_calldata_floor_with_authorizations does

Reproduction

uv run fill tests/amsterdam/eip7976_increase_calldata_floor_cost/test_transaction_validity.py \
  -k "type_4 and floor_gas_greater and exact_gas" --fork Amsterdam --output /tmp/fx

Then inspect any generated fixture: blocks[0].receipts[0].status is False, and the authorization's signer appears in neither pre nor postState.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions