core/vm: fix EIP-8037 CALL state gas ordering + code-store OOG underflow (#33972)

## Summary

Two EIP-8037 state gas accounting fixes:

1. **CALL state gas ordering** — Charge new-account state gas (112 ×
cpsb = 131,488) **before** the 63/64 child gas allocation, not after
2. **Code-store OOG underflow** — Do not inflate `TotalStateGasCharged`
when `UseGas` fails for code deposit, preventing uint64 underflow in
`blockGasUsed()`

## Bug 1: CALL state gas ordering (`operations_acl.go`)

In `makeCallVariantGasCall`, the EIP-8037 state gas for new account
creation was returned in the `GasCosts` struct and charged by the
interpreter's `UseGas`/`Sub` **after** `callGas()` had already computed
the 63/64 child gas allocation using the full (pre-state-gas)
`contract.Gas.RegularGas`.

When the state gas reservoir is empty (common case — reservoir only has
gas when `tx.gasLimit` exceeds `TX_MAX_GAS_LIMIT - intrinsic`), state
gas spills to regular gas. The spill amount (131,488) far exceeds the
1/64 retained gas (~15,600 at 1M gas), causing an underflow/OOG on CALLs
that should succeed.

**Fix:** Charge state gas directly before `callGas()` so the 63/64
calculation uses the reduced regular gas, then return `StateGas: 0` to
avoid double-charging. This matches nethermind's implementation
(`EvmInstructions.Call.cs:187-213`) and besu's approach.

**Spec basis:** EIP-8037 says "State gas charges deduct from
`state_gas_reservoir` first; when the reservoir is exhausted, from
`gas_left`." The 63/64 rule applies to `gas_left`, so state gas
spillover into `gas_left` must happen before the 63/64 computation.

## Bug 2: Code-store OOG underflow (`evm.go`)

In `initNewContract`, when `UseGas` fails for code deposit (code-store
OOG on valid code), the upstream code at `evm.go:666-672` added
`createDataGas.StateGas` to `TotalStateGasCharged` without actually
consuming any gas. For a 14KB init code output, this adds ~17.3M phantom
state gas to TSC.

This inflated TSC propagates through `blockGasUsed()`:
```
execRegularUsed := totalExecUsed - execStateUsed  // uint64 underflow when TSC > totalExecUsed
```
The underflow produces a massive `txRegular` value, causing
`ReturnGasAmsterdam` to reject the block with `gas limit reached`.

**Fix:** Remove the TSC inflation on `UseGas` failure (lines 666-672).
Also remove `ErrCodeStoreOutOfGas` from the `isCodeValidation` condition
(line 621-623), so code-store OOG follows the normal exceptional halt
path: all regular gas consumed, state gas reverted via
`RevertStateGas()`.

**Spec basis:** EIP-8037 §"Contract deployment cost calculation"
explicitly lists code-store OOG as a failure path:
> **Failure paths** (REVERT, OOG/invalid during initcode, **OOG during
code deposit**, or `L > MAX_CODE_SIZE`): Do NOT charge `GAS_CODE_DEPOSIT
* L` or `HASH_COST(L)`

And §"Transaction-level gas accounting":
> On child **exceptional halt**, all state gas consumed by the child,
both from the reservoir and any that spilled into `gas_left`, is
restored to the parent's reservoir.

## Test plan

- [x] geth+besu: 161 blocks (5+ epochs) with heavy spamoor load (eoatx
50 + evm-fuzz 25 + tx-fuzz 15), zero errors
- [x] Without Bug 2 fix: geth+besu chain-split at block ~30 under load —
`blockGasUsed` uint64 underflow
- [x] geth+nethermind: 86+ blocks no-load, 162 blocks eoatx load
(nethermind has separate BAL validation bug under fuzz)
- [x] geth+nimbus: 150+ blocks evm-fuzz with nimbus CALL fix applied —
gasUsed matches
- [x] Verified cross-client: nethermind and besu both charge CALL state
gas before 63/64

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Stefan 2026-03-09 11:07:36 +01:00 committed by GitHub
parent 51018a536f
commit 6489aab697
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 27 additions and 11 deletions

View file

@ -783,11 +783,13 @@ func (st *stateTransition) blockGasUsed(intrinsicGas, execGasStart vm.GasCosts)
execStateUsed := st.gasRemaining.TotalStateGasCharged
// Exclude state gas that was charged from regular gas but then reverted.
// This gas was consumed from the regular pool but was for state operations
// that didn't persist, so it shouldn't count in the regular dimension.
// that didn't persist, so it shouldn't count in either dimension for block
// accounting (invisible to the block). This matches nethermind's approach.
execRegularUsed := totalExecUsed - execStateUsed - st.gasRemaining.RevertedStateGasSpill
txRegular := intrinsicGas.RegularGas + execRegularUsed
txState := intrinsicGas.StateGas + execStateUsed - st.stateGasRefund
return txRegular, txState
}

View file

@ -619,8 +619,7 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasCosts, value *
// State gas charges must persist for block 2D gas accounting even
// though the state is reverted. Only zero regular gas as penalty.
isCodeValidation := evm.chainRules.IsAmsterdam &&
(errors.Is(err, ErrMaxCodeSizeExceeded) || errors.Is(err, ErrInvalidCode) ||
(err == ErrCodeStoreOutOfGas && contract.Gas.TotalStateGasCharged > savedTotalStateGas))
(errors.Is(err, ErrMaxCodeSizeExceeded) || errors.Is(err, ErrInvalidCode))
if !isRevert {
if isCodeValidation {
contract.Gas.RegularGas = 0
@ -663,13 +662,6 @@ func (evm *EVM) initNewContract(contract *Contract, address common.Address) ([]b
}
}
if !contract.UseGas(createDataGas, evm.Config.Tracer, tracing.GasChangeCallCodeStorage) {
if evm.chainRules.IsAmsterdam && codeErr == nil {
// Valid code that merely ran out of gas: track state gas for
// block accounting (EIP-8037).
contract.Gas.TotalStateGasCharged += createDataGas.StateGas
contract.Gas.RegularGas = 0
contract.Gas.StateGas = 0
}
return ret, ErrCodeStoreOutOfGas
}
} else {

View file

@ -295,6 +295,21 @@ func makeCallVariantGasCall(oldCalculatorStateful, oldCalculatorStateless gasFun
}
}
// EIP-8037: Charge state gas for new account creation BEFORE the 63/64
// child gas allocation. State gas that spills from an empty reservoir to
// regular gas must reduce the gas available for callGasTemp, otherwise
// the Underflow check in UseGas will fail when the spillover exceeds the
// tiny 1/64 remainder after child gas allocation.
var stateGasCharged uint64
if evm.chainRules.IsAmsterdam && oldStateful.StateGas > 0 {
stateGasCharged = oldStateful.StateGas
stateGasCost := GasCosts{StateGas: stateGasCharged}
if contract.Gas.Underflow(stateGasCost) {
return GasCosts{}, ErrOutOfGas
}
contract.Gas.Sub(stateGasCost)
}
evm.callGasTemp, err = callGas(evm.chainRules.IsEIP150, contract.Gas.RegularGas, eip150BaseGas.RegularGas, stack.Back(0))
if err != nil {
return GasCosts{}, err
@ -331,6 +346,13 @@ func makeCallVariantGasCall(oldCalculatorStateful, oldCalculatorStateless gasFun
return GasCosts{}, ErrGasUintOverflow
}
return GasCosts{RegularGas: totalCost, StateGas: oldStateful.StateGas}, nil
// If state gas was already charged directly (Amsterdam), don't include
// it in the returned cost — it would be double-charged by the
// interpreter's UseGas/Sub which increments TotalStateGasCharged again.
returnedStateGas := oldStateful.StateGas
if stateGasCharged > 0 {
returnedStateGas = 0
}
return GasCosts{RegularGas: totalCost, StateGas: returnedStateGas}, nil
}
}