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.
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_usedwithout asserting receipt status) keep passing while the transaction they meant to exercise does nothing.set_delegation's own docstring insrc/ethereum/forks/amsterdam/vm/eoa_delegation.pystates the consequence:Evidence
tests/amsterdam/eip7976_increase_calldata_floor_cost/test_transaction_validity.py::test_transaction_validity_type_4and its Prague ancestortests/prague/eip7623_increase_calldata_cost/test_transaction_validity.py::test_transaction_validity_type_4size their gas limit astx_intrinsic_gas_cost_including_floor_data_cost + tx_gas_delta(seeconftest.py), with no top-frame term. Filled at Amsterdam, every type-4 transaction that gets included fails:Taking
single_authorization-no_access_list-exact_gas-floor_gas_greater_than_intrinsic_gasas the concrete case — 163 bytes of calldata, one authorization:The resulting fixture:
status = False,cumulativeGasUsed = 25432preandpostState— the delegation never lands and the authority's leaf is never createdpost={}and asserts nothing about statusNote how the failure hides: the transaction OOGs and therefore consumes its entire gas limit, which happens to equal the floor, so
gas_usedis 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_authorizationscomputes its threshold againstintrinsic + top_frame_gas + top_frame_state_gas, which pushes it to 851 bytes of calldata and a 69,464 gas limit. Its fixture hasstatus = Trueand 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:
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 returnintrinsic + top_frame_execution + top_frame_state.This is naturally correct across all forks:
transaction_top_frame_gas_calculatorandtransaction_top_frame_state_gasinbase_fork.pyalready default to0, so pre-Amsterdam the helper returns exactly the intrinsic cost and existing behaviour is unchanged.Alternative names, if
transaction_gas_to_reach_execution_calculatoris too long:transaction_execution_entry_cost_calculator, orminimum_transaction_gas_calculator.Scope of the migration
transaction_intrinsic_cost_calculatoris used at 354 call sites across 128 files undertests/:amsterdamported_staticbenchmarkpragueosakacancunshanghaiberlintangerine_whistlefrontier37 files already reference
transaction_top_frame_gas_calculatorortransaction_top_frame_state_gasand 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:
statusinexpected_receipt(or assert post-state that only a successful transaction can produce) wherever a test funds a transaction at exactly its computed costtest_calldata_floor_with_authorizationsdoesReproduction
Then inspect any generated fixture:
blocks[0].receipts[0].statusisFalse, and the authorization'ssignerappears in neitherprenorpostState.