Skip to content

Implement EIP-8037: "State Creation Gas Cost Increase" - #1672

Open
chfast wants to merge 9 commits into
masterfrom
amsterdam/8037
Open

Implement EIP-8037: "State Creation Gas Cost Increase"#1672
chfast wants to merge 9 commits into
masterfrom
amsterdam/8037

Conversation

@chfast

@chfast chfast commented Aug 23, 2026

Copy link
Copy Markdown
Member

Implement EIP-8037: "State Creation Gas Cost Increase"

Introduce the Amsterdam two-dimensional gas model: state-creation costs move
out of regular gas into a separate state-gas dimension, priced at
COST_PER_STATE_BYTE (1530) per byte of new state.

  • evmc: add state_gas to the message and state_gas_left/state_gas_spilled
    to the result, threading a per-frame state-gas reservoir through the VM.
  • StateGas (state_gas.hpp): a (reservoir-left, spilled) pair. Charges draw
    from the reservoir first and spill into regular gas_left; refunds refill in
    LIFO order; a frame's net use derives as initial - left + spilled. Frames
    roll their state gas back on revert/halt (make_execution_result).
  • Charges at state-creation sites: new account by CREATE/CREATE2 (at the
    deployment-address access), by value-CALL — including the depth-0
    value-transfer charge the EIP-2780 decomposition later builds on — and by
    SELFDESTRUCT to a new beneficiary (NEW_ACCOUNT = 120 bytes); SSTORE
    0->non-zero slot allocation (64 bytes, with the 0->Y->0 LIFO refill; the
    regular set cost drops to its 2900 component); code deposit per byte.
    Failed creations refund the charge. Opcode CREATE and the create
    transaction keep the legacy 32000 execution cost here: EIP-8037 defers its
    execution component to EIP-8038's CREATE_ACCESS, and EIP-8038 states that
    the flat GAS_CREATE is what CREATE_ACCESS replaces, so the reprice lands
    with it.
  • Transaction processing: execution gas splits into a regular budget (capped
    by TX_MAX_GAS_LIMIT - intrinsic) and the state-gas reservoir. Amsterdam
    lifts the Osaka per-tx gas cap; validation instead caps the regular
    intrinsic and applies the per-dimension block-inclusion rules against the
    new block state-gas budget.
  • Block accounting: per-tx receipts carry regular/state components;
    block gas_used = max(sum_regular, sum_state) (EIP-7778 2D formula).
  • System calls get a separate 16-SSTORE state-gas reservoir so the state
    dimension cannot OOG them, and a failed block-start system call now rolls
    back its partial state changes instead of asserting.

The intrinsic cost otherwise keeps the pre-Amsterdam formula; the EIP-2780
resource decomposition lands separately.

The EIP-7702 per-authorization state charges (AUTH_BASE and the authority's
NEW_ACCOUNT) are not part of this commit: they are only expressible through
the top-frame charging model that the EIP-2780 intrinsic decomposition
introduces, so they land with it. The intrinsic keeps the pre-Amsterdam
formula here.

Includes the state-gas unit tests and the GAS_ALLOWANCE_EXCEEDED /
BlockException.GAS_USED_OVERFLOW acceptance, which exists because the
per-dimension inclusion checks keep an over-block-gas transaction a
transaction-level rule.


The follow-up commits are separable and each build and pass ctest on their own:

  • evmc: Bump the ABI version for the state gas fields
  • test: Forward the depth-0 state charge through the interpreter exit
  • Set the state gas on the remaining result producers
  • Declare the state gas in the cold tail of ExecutionState
  • Reuse account_exists for the create aliveness check
  • Fold the SSTORE state gas into the cost table
  • test: Pin the depth-0 state charge reaching a precompile

@codspeed-hq

codspeed-hq Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 129 untouched benchmarks


Comparing amsterdam/8037 (78114ae) with master (c8d6705)

Open in CodSpeed

@codecov

codecov Bot commented Aug 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.03401% with 44 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.48%. Comparing base (c8d6705) to head (78114ae).

Files with missing lines Patch % Lines
test/state/host.cpp 75.64% 14 Missing and 5 partials ⚠️
lib/evmone/instructions_calls.cpp 77.41% 3 Missing and 4 partials ⚠️
test/state/state.cpp 88.67% 3 Missing and 3 partials ⚠️
lib/evmone/instructions_storage.cpp 20.00% 2 Missing and 2 partials ⚠️
lib/evmone/instructions.hpp 80.00% 1 Missing and 1 partial ⚠️
lib/evmone/state_gas.hpp 87.50% 1 Missing and 1 partial ⚠️
test/state/system_contracts.cpp 60.00% 1 Missing and 1 partial ⚠️
test/utils/t8n.cpp 50.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1672      +/-   ##
==========================================
- Coverage   97.72%   97.48%   -0.25%     
==========================================
  Files         171      173       +2     
  Lines       15666    15888     +222     
  Branches     3625     3665      +40     
==========================================
+ Hits        15310    15488     +178     
- Misses        269      295      +26     
- Partials       87      105      +18     
Flag Coverage Δ
eest-develop 87.18% <53.07%> (-1.42%) ⬇️
eest-develop-gmp 26.56% <37.07%> (+0.04%) ⬆️
eest-legacy 17.24% <35.71%> (+0.15%) ⬆️
eest-libsecp256k1 28.79% <37.07%> (+0.01%) ⬆️
eest-stable 87.18% <53.07%> (-1.42%) ⬇️
evmone-unittests 93.25% <82.99%> (-0.19%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
core 95.43% <80.18%> (-0.68%) ⬇️
tooling 91.89% <90.00%> (-0.08%) ⬇️
tests 99.80% <100.00%> (+<0.01%) ⬆️
Files with missing lines Coverage Δ
lib/evmone/execution_state.hpp 90.90% <100.00%> (+2.53%) ⬆️
test/state/state.hpp 100.00% <ø> (ø)
test/state/transaction.hpp 100.00% <ø> (ø)
test/statetest/statetest_runner.cpp 92.59% <100.00%> (+0.13%) ⬆️
test/unittests/state_transition.cpp 98.86% <100.00%> (+0.04%) ⬆️
test/unittests/state_transition_create_test.cpp 100.00% <100.00%> (ø)
test/unittests/state_transition_eip8037_test.cpp 100.00% <100.00%> (ø)
test/unittests/state_tx_test.cpp 98.85% <100.00%> (+<0.01%) ⬆️
test/utils/block_transition.cpp 100.00% <100.00%> (ø)
test/utils/error_matching.cpp 95.83% <ø> (ø)
... and 10 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

chfast added 9 commits August 24, 2026 01:37
Introduce the Amsterdam two-dimensional gas model: state-creation costs move
out of regular gas into a separate state-gas dimension, priced at
COST_PER_STATE_BYTE (1530) per byte of new state.

- evmc: add `state_gas` to the message and `state_gas_left`/`state_gas_spilled`
  to the result, threading a per-frame state-gas reservoir through the VM.
- StateGas (state_gas.hpp): a (reservoir-left, spilled) pair. Charges draw
  from the reservoir first and spill into regular gas_left; refunds refill in
  LIFO order; a frame's net use derives as `initial - left + spilled`. Frames
  roll their state gas back on revert/halt (make_execution_result).
- Charges at state-creation sites: new account by CREATE/CREATE2 (at the
  deployment-address access), by value-CALL — including the depth-0
  value-transfer charge the EIP-2780 decomposition later builds on — and by
  SELFDESTRUCT to a new beneficiary (NEW_ACCOUNT = 120 bytes); SSTORE
  0->non-zero slot allocation (64 bytes, with the 0->Y->0 LIFO refill; the
  regular set cost drops to its 2900 component); code deposit per byte.
  Failed creations refund the charge. Opcode CREATE and the create
  transaction keep the legacy 32000 execution cost here: EIP-8037 defers its
  execution component to EIP-8038's CREATE_ACCESS, and EIP-8038 states that
  the flat GAS_CREATE is what CREATE_ACCESS replaces, so the reprice lands
  with it.
- Transaction processing: execution gas splits into a regular budget (capped
  by TX_MAX_GAS_LIMIT - intrinsic) and the state-gas reservoir. Amsterdam
  lifts the Osaka per-tx gas cap; validation instead caps the regular
  intrinsic and applies the per-dimension block-inclusion rules against the
  new block state-gas budget.
- Block accounting: per-tx receipts carry regular/state components;
  block gas_used = max(sum_regular, sum_state) (EIP-7778 2D formula).
- System calls get a separate 16-SSTORE state-gas reservoir so the state
  dimension cannot OOG them, and a failed block-start system call now rolls
  back its partial state changes instead of asserting.

The intrinsic cost otherwise keeps the pre-Amsterdam formula; the EIP-2780
resource decomposition lands separately.

The EIP-7702 per-authorization state charges (AUTH_BASE and the authority's
NEW_ACCOUNT) are not part of this commit: they are only expressible through
the top-frame charging model that the EIP-2780 intrinsic decomposition
introduces, so they land with it. The intrinsic keeps the pre-Amsterdam
formula here.

Includes the state-gas unit tests and the GAS_ALLOWANCE_EXCEEDED /
BlockException.GAS_USED_OVERFLOW acceptance, which exists because the
per-dimension inclusion checks keep an over-block-gas transaction a
transaction-level rule.
EIP-8037 appends `state_gas` to evmc_message and `state_gas_left` /
`state_gas_spilled` to evmc_result. Both structs change layout, and
evmc_result is returned by value, so a host built against ABI 18 allocates
the smaller struct while a VM built against this header writes the larger
one. evmc_is_abi_compatible() compares only this number, so without the
bump such a pair loads and reads past the end of the peer's structs
straight into gas accounting.
Host::execute_message debits the depth-0 NEW_ACCOUNT charge from a local
StateGas and from msg.gas, but left msg.state_gas at the entry reservoir.
The precompile and empty-code exits commit the charged pools; the exit into
the interpreter did not, so the reservoir portion of the charge stayed
spendable and the caller's net-used derivation missed it.

Not reachable today: a charged recipient is not alive, so it has no code and
the empty-code exit returns first. The charge does not depend on that, and
the deferred EIP-7702 authorization charges land in the same block.
state_gas_left defaults to 0, which the protocol reads as "the callee
consumed the caller's whole reservoir", so every evmc_result producer has to
set it. Two did not: MockedHost::call, which backs the unit-test fixture and
the baseline-vs-advanced fuzzer, and the Frontier code-deposit exit, which
sits beside a path that already sets it.

Both are correct today only because their callers are pre-Amsterdam and hand
out an empty reservoir. A delta-encoded field would make the zero default
right by construction; echoing the reservoir is the smaller fix.
Inserting StateGas between gas_refund and memory shifted every later member
by 16 bytes, pushing `status` from offset 120 to 136 and `host` from 40 to
56. On x86-64 that moves `status` past the disp8 window, so each of the ~195
accesses in a dispatch loop grows 3 bytes, and the host pointers land on a
second cache line.

Moving the field next to the stack space, which the class already documents
as last "to make other fields' offsets of reasonable values", recovers 1728
bytes of baseline_execution .text.
create_impl spelled the EIP-161 aliveness rule out of three host callbacks
where account_exists computes the same predicate: under the Amsterdam gate
its pre-Spurious-Dragon arm is unreachable, leaving `acc != nullptr &&
!is_empty()`. That made four spellings of one rule, and this was the only
one also assuming get_code_size agrees with code_hash.

Three indirect host calls become one; each is a state lookup that, for the
usual non-existent deployment address, misses the modified set and queries
the uncached view.
sstore re-tested the revision and the storage status twice to decide the
EIP-8037 slot-allocation charge and its refill, on values it had already
used to index sstore_costs. Put the amount in that table as a third column,
signed: positive charges, negative refills, zero before Amsterdam.

The body reduces to two sign tests on a value already in a register, and the
rule lives next to the other SSTORE costs rather than beside them. int32_t
because 64 * COST_PER_STATE_BYTE is 97'920.
A value transfer to a zero-balance precompile at depth 0 creates a state
account and pays NEW_ACCOUNT_STATE_GAS. With a below-cap gas limit the
reservoir is empty, so the charge spills entirely into regular gas and the
precompile must execute on what is left.

No fixture in either EEST release covers this, so the interaction between the
depth-0 charge and the precompile dispatch was unguarded.
The interpreter exit carried the depth-0 charge's spill out of every frame
regardless of outcome, which commits the charge on a frame failure. EELS
refills it there: it is charged after commit_state_gas, so restore_state_gas
returns it.

The path is unreachable — a charge implies a non-alive recipient, which has
empty code and returns at the exit above — so assert that rather than encode a
guess. The EIP-7702 authorization charges EIP-2780 will add next to this one
must survive a failure, the opposite of this charge, so carrying both out
uniformly would be wrong for one of them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant