Skip to content

EVM multi-dimensional gas: architecture analysis and migration assessment #19865

Description

@mh0lt

Summary

Ethereum's Amsterdam fork introduces multi-dimensional gas (EIP-8037) alongside block gas accounting changes (EIP-7778) and block access lists (EIP-7928). Our current implementation on the eip-8037 branch threads a new MdGas{Regular, State} type through 46 files, duplicating critical save/restore and spill-reversal logic at 6 call/create sites and 2 call/create lifecycle functions. This duplication is the root cause of contradicting edge case fixes — the commit history shows a recurring cycle of "fix X breaks Y" as the duplicated blocks diverge.

14 Amsterdam test fixtures are currently skipped, several showing large gas discrepancies (e.g., 56K gas mismatch), indicating structural accounting errors rather than off-by-one issues.

The risk analysis identifies extracting shared helpers (Phase 1) as a high-value, near-zero-risk first step that can be done immediately. It eliminates the most dangerous code duplication — the 6× save/restore pattern in instruction handlers and the 2× spill-reversal block in call()/create() — without introducing new abstractions or changing any APIs. This alone would reduce the surface area where contradicting fixes can occur and may resolve some of the skipped tests by unifying logic that has silently diverged between the two copies.

Further phases (centralized GasLedger, typed gas functions, formalized two-phase gas for BAL) offer increasing structural improvement but carry higher merge conflict risk with the actively-developed eip-8037 branch and should be sequenced after the current batch of gas fixes stabilizes.


Background

This document was produced to inform architectural decisions around multi-dimensional gas in Erigon. It combines:

  1. A detailed analysis of how three gas dimensions (regular, state, blob) flow through the EVM, including EIP-7778 refund accounting and EIP-7928's stateful/stateless gas split for block access lists
  2. An outline design for a centralized "GasLedger" that addresses the structural problems identified in the analysis
  3. A risk and sizing assessment for migrating to that design

The outline design is presented for analysis purposes — it illustrates one approach to centralizing gas lifecycle management and provides a concrete basis for sizing and risk evaluation. Alternative approaches are possible, including lighter-weight refactors that address the most critical duplication without introducing the full ledger abstraction. The phased migration strategy is specifically designed to allow stopping at any phase boundary if the cost/benefit shifts.


Part I: Architecture Analysis

1. The Three Gas Dimensions

Dimension EIP Introduced Scope Pricing Pool Limit
Regular (original) Genesis EVM execution, memory, computation EIP-1559 base fee + tip block.gasLimit
Blob EIP-4844 Cancun Data availability (transaction-level) Independent exponential blob base fee maxBlobGasPerBlock
State EIP-8037 Amsterdam Storage/account creation (EVM-level) Dynamic CostPerStateByte per block Shares block.gasLimit via max(regular, state)

Additionally, EIP-7778 (Amsterdam) splits regular gas into two accounting views — receipt gas (with refunds) and block gas (without refunds) — applied retrospectively at the end of transaction execution.

2. EIP Specifications

2.1 EIP-7778: Block Gas Accounting Without Refunds

Problem: Gas refunds reduce both user costs and block gas accounting, allowing blocks to exceed their intended computational limits. Block 20,878,522 showed 28.5 MGas net usage but 32.51 MGas gross, exceeding the block limit.

Solution: Split gas accounting into two views of the same execution:

  • User gas (ReceiptGasUsed): refunds still apply — gas_spent = max(tx_gas_used - gas_refund, calldata_floor_gas_cost)
  • Block gas (BlockGasUsed): refunds excluded — block.gas_used += max(tx_gas_used, calldata_floor_gas_cost)

Key property: This is a retrospective divergence — during execution there's only one gas counter. The split happens purely at the accounting stage after execution completes. This is why 7778 was relatively clean to implement.

Implementation in state_transition.go:639-667:

gasUsed := st.gasUsed()              // raw gas consumed
st.blockGasUsed = gasUsed            // start with raw

stateRefund := st.state.GetRefund()
effectiveRefund = min(gasUsed/refundQuotient, stateRefund)
gasUsed = gasUsed - effectiveRefund   // user gets refund

if rules.IsAmsterdam {
    // EIP-7778: block accounting ignores refunds
    st.blockGasUsed = max(intrinsicGasResult.FloorGasCost, st.blockGasUsed)
} else {
    st.blockGasUsed = gasUsed         // pre-Amsterdam: block = user view
}

st.gp.AddGas(st.initialGas - st.blockGasUsed)  // return unused to pool

Fee calculations use ReceiptGasUsed (with refunds) — so the validator gets paid based on the refunded amount, while the block limit is enforced on the non-refunded amount.

2.2 EIP-7928: Block-Level Access Lists (BAL)

Core change: Two-phase gas validation:

  1. Pre-state validation — gas costs calculable without state access (memory expansion, base cost, warm/cold designation)
  2. Post-state validation — costs requiring state access (account existence, EIP-7702 delegation resolution)

Critical rule: "Pre-state validation MUST pass before any state access occurs. If pre-state validation fails, the target resource is never accessed" and therefore excluded from the BAL.

BAL recording: Only addresses and storage slots actually touched are recorded. EIP-2930 access list entries are NOT automatically included.

Block constraint: bal_items ≤ block_gas_limit ÷ 2000

2.3 EIP-8037: State Creation Gas Cost Increase (Multi-Dimensional Gas)

Problem: State creation costs are too low relative to their long-term impact. As block limits increase, state growth accelerates unsustainably.

Solution: Introduce a second gas dimension tracked during EVM execution:

  • Regular gas: computation, memory, parallelizable operations
  • State gas: persistent state creation (SSTORE new slot, CREATE, code deposit)

Dynamic pricing via cost_per_state_byte calculated per block from gas limit, targeting 100 GiB/year state growth at 50% utilization. Currently hardcoded to 1174 for bal-devnet-3.

Key properties:

  • State gas can spill into regular gas when the state reservoir is exhausted
  • Block validity: max(regular_gas_used, state_gas_used) ≤ gas_limit
  • Transaction gas limit can exceed MaxTxnGasLimit — the excess becomes the state gas reservoir

3. Current Implementation Architecture

3.1 Blob Gas (Clean, Independent)

Blob gas is the cleanest model — genuinely independent of the EVM:

BlobTx.BlobVersionedHashes → GetBlobGas() = count × 131,072
    ↓
preCheck(): validate maxFeePerBlobGas ≥ blobBaseFee
    ↓
buyGas(): GasPool.SubBlobGas() — independent pool
    ↓
Receipt: BlobGasUsed per-tx
Header: BlobGasUsed total, ExcessBlobGas for next block pricing

Why it works well:

  • Never enters the EVM interpreter loop — the EVM is unaware of blob gas
  • Has its own independent pool (GasPool.blobGas)
  • Has its own pricing mechanism (exponential FakeExponential)
  • Has its own balance check (maxFeePerBlobGas × blobGas)
  • Block validity: simple blobGasUsed ≤ maxBlobGasPerBlock

3.2 EIP-7778 Implementation (Retrospective Split)

The ExecutionResult struct stores both accounting views:

type ExecutionResult struct {
    ReceiptGasUsed uint64  // with refunds (user pays)
    BlockGasUsed   uint64  // without refunds (block limit)
}

On the eip-8037 branch, BlockGasUsed further splits into BlockRegularGasUsed and BlockStateGasUsed, with the block-level value being max(regular, state).

3.3 State Gas Implementation (Entangled with Regular)

On the eip-8037 branch, state gas is threaded through the entire EVM:

New typeexecution/vm/evmtypes/mdgas/md_gas.go:

type MdGas struct {
    Regular uint64
    State   uint64
}

With spill logic: MinusStateGas(sg) — if state gas insufficient, overflow charged from regular gas.

Key changes:

  • CallContext gains stateGas uint64 alongside existing gas uint64
  • Run() signature changes from gas uint64 to gas MdGas
  • All Call/Create signatures take and return MdGas
  • Three new EVM tracking fields: stateGasConsumed, regularGasConsumed, revertedSpillGas
  • Every gasFunc returns MdGas instead of uint64
  • Refund counter changes from uint64 to MdGas

4. The Stateful vs Stateless Gas Split

4.1 Current Two-Phase Pattern

The codebase already separates gas calculation into two phases for CALL variants, introduced for EIP-7702 delegation resolution:

Phase Function Computes State Access?
Stateless statelessGasCall() Memory expansion, value transfer, base cost No
Stateful statefulGasCall() Account creation gas (Empty() check) Yes
Call gas calcCallGas() 63/64 rule allocation No

Composed in makeCallVariantGasCallEIP7702() (operations_acl.go:290):

1. Cold/warm access check (access list lookup — no state read)
2. statelessCalculator() — memory, value transfer (no state read)
3. Pre-state OOG check — ABORT HERE if can't afford stateless cost
4. statefulCalculator() — Empty() check (STATE READ)
5. Delegation resolution — GetDelegatedDesignation() (STATE READ)
6. calcCallGas() — 63/64 rule (no state read)

4.2 Opcode Classification by Gas State Dependency

Fully stateless gas (never needs state to compute cost):

  • Arithmetic, stack, memory, flow control
  • LOG0-4, KECCAK256, RETURNDATACOPY, CALLDATACOPY, CODECOPY
  • MCOPY, PUSH, DUP, SWAP
  • SLOAD — gas depends only on warm/cold (access list), not state values
  • BALANCE, EXTCODESIZE, EXTCODEHASH, EXTCODECOPY — same, warm/cold only

Two-phase gas (can split stateless/stateful):

  • CALL: stateless = memory + value transfer; stateful = Empty() for account creation
  • CALLCODE, DELEGATECALL, STATICCALL: stateless only (no account creation cost)
  • SELFDESTRUCT: stateless = base cost; stateful = Empty() + GetBalance() for creation gas
  • CREATE/CREATE2: stateless = memory + initcode words; stateful = collision check (pass/fail, not cost-varying)

Inherently stateful gas (cost depends on state values):

  • SSTORE: gas depends on current value, original value, new value — cannot split

SSTORE is the only opcode whose gas cost is truly state-dependent. Everything else can determine gas from the access list (warm/cold) plus stack arguments.

4.3 BAL Recording Implications

Even if gas is stateless, BAL recording depends on whether the operation actually proceeds:

  1. CALL to cold address → add to access list (gas) → Empty() check (state) → if OOG on cold gas, don't record in BAL
  2. CALL to warm address → no gas for access → proceed to Empty()do record in BAL
  3. SSTORE with insufficient gas for sentry check → abort → don't record in BAL
  4. SSTORE with sufficient gas → read state for cost determination → do record in BAL

This is why MarkReadsInternal() and the revertable flag exist — to distinguish "I read state for gas calculation purposes" from "I actually performed the operation."

4.4 EIP-8037 Complication for BAL

With state gas, the CALL statefulGasCall now returns MdGas{State: 112 × cpsb} when creating a new account. The spill mechanism means that even though the cost is classified as "state gas," the deduction may come from regular gas. If the spill causes OOG on regular gas, the operation fails — but the state read (Empty()) already happened.

This is correct for BAL purposes (the access is recorded) and satisfies EIP-7928's pre-state validation requirement (the stateless phase passed before the state read). But it creates an asymmetry where the payment fails due to cross-dimension spill after the state access.

5. Structural Problems

5.1 State Gas Lives in Two Places with Different Semantics

CallContext has gas uint64 and stateGas uint64 as separate fields, but MdGas is the struct passed across call boundaries. Every call/create entry point must manually save parentStateGas, zero scope.stateGas, then conditionally restore on error. This save/restore pattern is duplicated 6 times (opCall, opCallCode, opDelegateCall, opStaticCall, opCreate, opCreate2) with identical logic:

parentStateGas := scope.stateGas
scope.stateGas = 0
// ... call ...
if err != nil {
    scope.stateGas = parentStateGas
} else {
    scope.stateGas = returnGas.State
}

5.2 Spill Logic is Duplicated and Error-Prone

The "state gas overflows into regular gas" spill happens in useMdGas() (the generic helper), but the reversal of spill on revert is duplicated in both evm.call() and evm.create() with nearly identical ~20-line blocks:

childStateConsumed := evm.stateGasConsumed - savedStateGasConsumed

if depth > 0 {
    evm.stateGasConsumed = savedStateGasConsumed
}

reservoirUsed := initialChildState - gas.State
if childStateConsumed > reservoirUsed {
    spill := childStateConsumed - reservoirUsed
    gas.Regular += spill
    if depth == 0 && err == ErrExecutionReverted {
        evm.revertedSpillGas += spill
    }
}

if err != ErrExecutionReverted {
    gas.State = initialChildState
    if depth == 0 {
        gas.Regular = 0
    }
}

Any bug in one copy but not the other creates edge cases. The commit history tells the story: "fix restorations on revert/halts", "fix spill and pass on state gas cost in call", "adjust regularGasConsumed tracking", "fix exceptional halt".

5.3 Block-Level Accounting is Scattered

regularGasConsumed is updated in 5+ places:

  • Constant gas in the interpreter loop
  • Dynamic regular gas in the interpreter loop
  • Precompile calls in evm.call()
  • Exceptional halt (remaining gas counted as consumed)
  • Stipend adjustments in opCall/opCallCode (regularGasConsumed -= CallStipend)

This makes it very hard to verify the invariant: regularGasConsumed + leftoverRegular == initialRegularGas.

5.4 The gasFunc Signature Change is All-or-Nothing

Every gasFunc now returns MdGas even for pre-Amsterdam operations that only ever use Regular. This means 30+ gas functions wrap their return in mdgas.MdGas{Regular: gas} boilerplate. The type signature doesn't distinguish between "this function can return state gas" and "this function never does."

5.5 Revert Semantics Differ by Depth

At depth 0, state gas is kept consumed (for block accounting) but spill is restored. At depth > 0, stateGasConsumed is rolled back. This depth-dependent behavior is the primary source of contradicting edge case fixes.

5.6 The Three-Way Refund Interaction

EIP-7778 says block gas ignores refunds. EIP-8037 has state refunds. The refund total is min(gasUsed/5, regularRefund + stateRefund) applied as a single scalar. But gasUsed now includes spilled state gas. The revertedSpillGas field patches this:

txnGasUsedB4Refunds = mdGasUsed.Total() + evm.RevertedSpillGas()

6. The Full Gas Flow

                    ┌─────────────────────────────────────────────────┐
                    │              TRANSACTION ENTRY                   │
                    │                                                  │
                    │  Tx.Gas → IntrinsicGas (regular + state)        │
                    │        → SplitIntoMdGas(regular, state)         │
                    │        → buyGas(GasPool.SubGas)                 │
                    │                                                  │
                    │  Tx.BlobGas → GasPool.SubBlobGas (independent)  │
                    │                                                  │
                    │  Tx balance must cover:                          │
                    │    gasLimit × maxFeePerGas                       │
                    │    + blobGas × maxFeePerBlobGas                  │
                    │    + value                                       │
                    └──────────┬──────────────────────────────────────┘
                               │
                    ┌──────────▼──────────────────────────────────────┐
                    │              EVM EXECUTION                       │
                    │                                                  │
                    │  Per-opcode:                                     │
                    │    constantGas ──────────► regular only          │
                    │    dynamicGas:                                   │
                    │      Phase 1 (stateless) ► regular gas          │
                    │      Phase 2 (stateful)  ► regular + state gas  │
                    │        └─ state can SPILL to regular            │
                    │        └─ BAL records state accesses            │
                    │                                                  │
                    │  SSTORE: inherently stateful (no phase split)   │
                    │    └─ state gas for new slot creation (8037)    │
                    │    └─ state refund for slot deletion (8037)     │
                    │                                                  │
                    │  CALL/CREATE: two-phase split                   │
                    │    └─ state gas for account creation (8037)     │
                    │    └─ 63/64 rule applies to regular only        │
                    │    └─ state gas passed whole to child           │
                    │    └─ on revert: undo spill, depth-dependent    │
                    │                                                  │
                    │  Blob gas: NOT in EVM (tx-level only)           │
                    └──────────┬──────────────────────────────────────┘
                               │
                    ┌──────────▼──────────────────────────────────────┐
                    │         END OF TRANSACTION ACCOUNTING            │
                    │                                                  │
                    │  EIP-7778: Retrospective refund split            │
                    │    ReceiptGasUsed = gasUsed - effectiveRefund    │
                    │    BlockGasUsed   = max(floor, gasUsed) ◄ no refund │
                    │                                                  │
                    │  EIP-8037: Dimension split                       │
                    │    BlockRegularGasUsed = regularGasConsumed      │
                    │    BlockStateGasUsed   = stateGasConsumed        │
                    │    BlockGasUsed = max(regular, state)            │
                    │                                                  │
                    │  EIP-4844: Independent                           │
                    │    BlobGasUsed = count × GasPerBlob              │
                    │                                                  │
                    │  Fee distribution (uses ReceiptGasUsed):         │
                    │    tip = receiptGasUsed × effectiveTip → coinbase│
                    │    burn = receiptGasUsed × baseFee → burn addr   │
                    │    blobFee = blobGas × blobBaseFee → burn addr   │
                    │    refund = gasRemaining × gasPrice → sender     │
                    │                                                  │
                    │  Block validity:                                 │
                    │    max(regularGasUsed, stateGasUsed) ≤ gasLimit  │
                    │    blobGasUsed ≤ maxBlobGasPerBlock              │
                    └─────────────────────────────────────────────────┘

7. Where Edge Cases Breed

The edge cases that are proving "time consuming" and "contradictory" cluster at three interaction points:

7.1 Spill + Revert: State gas borrows from regular, then execution reverts. Must undo the borrow. Different rules at depth 0 vs depth N:

  • Depth > 0: stateGasConsumed restored to parent's snapshot. Spill returned to gas.Regular.
  • Depth 0 (top-level REVERT): stateGasConsumed kept for block accounting. Spill tracked in revertedSpillGas.
  • Depth 0 (exceptional halt): regular gas zeroed. State gas reservoir preserved.

7.2 Refund + State Gas + Block Accounting: EIP-7778 says block gas ignores refunds. EIP-8037 has state refunds. The combined formula requires revertedSpillGas to be correct or mdGasUsed.Total() is short.

7.3 Pre-State Validation + State Gas OOG: The stateless phase passes, the stateful phase determines a state gas cost, but payment fails due to spill exhaustion after the state read has already occurred.

7.4 Composition: A reverted CALL that spilled state gas to regular gas, inside a transaction that gets a state refund, checked against a block gas limit using max(regular, state). Getting all the counters right across every combination is what causes the fix-contradiction cycle.


Part II: Outline Design

Note: This design is presented for analysis purposes — to provide a concrete basis for sizing the migration and evaluating risks. Alternative approaches are possible, including lighter-weight refactors that address critical duplication without the full ledger abstraction.

8. Gas Ledger Pattern

Instead of threading MdGas through every function and manually tracking counters on the EVM, introduce a centralized GasLedger:

type GasDimension uint8
const (
    Regular GasDimension = iota
    State
    Blob
    NumDimensions
)

type GasVector [NumDimensions]uint64

type SpillRule struct {
    From GasDimension  // when this runs out...
    To   GasDimension  // ...borrow from this
}

type GasLedger struct {
    frames []GasFrame

    // Block-level accumulators (never rolled back by inner frames)
    blockUsed GasVector

    // Spill rules (currently only State→Regular)
    spills []SpillRule

    // Per-dimension refund counters
    refunds GasVector
}

type GasFrame struct {
    available GasVector
    initial   GasVector  // snapshot at frame entry (for spill reversal)
    depth     int
    spillLog  []spillEntry  // records each spill as it happens
}

type spillEntry struct {
    from, to GasDimension
    amount   uint64
}

Key operations:

// PushFrame — called at CALL/CREATE boundaries
func (l *GasLedger) PushFrame(gas MdGas, depth int)

// PopFrame — handles revert/halt/success in ONE place
// Replaces the duplicated logic in call() and create()
func (l *GasLedger) PopFrame(err error) MdGas

// Use — charge gas in a specific dimension with spill
func (l *GasLedger) Use(dim GasDimension, amount uint64) bool

// Refund — add to dimension-specific refund counter
func (l *GasLedger) Refund(dim GasDimension, amount uint64)

Benefits:

  1. Revert/halt/success logic lives in ONE place (PopFrame) instead of being duplicated in call(), create(), and every opCall* instruction. The depth-0 vs depth-N distinction is handled once.

  2. Spill log enables clean reversal — instead of computing childStateConsumed - reservoirUsed to figure out how much spilled, the frame records each spill as it happens. On revert, replay in reverse.

  3. Block accounting is centralizedblockUsed is updated inside Use(), not scattered across 5+ locations in the interpreter loop.

  4. New dimensions are configuration — if a future EIP adds compute gas or bandwidth gas, it's a new entry in GasDimension and possibly a new SpillRule, not threading a new uint64 through 46 files.

9. Typed Gas Returns from gasFunc

Split gasFunc into two types:

type gasFunc func(evm *EVM, ctx *CallContext, avail MdGas, memSize uint64) (MdGas, error)
type regularGasFunc func(evm *EVM, ctx *CallContext, avail uint64, memSize uint64) (uint64, error)

Only 5 functions ever return MdGas.State > 0:

Function Where State Gas Source
gasCreateEip3860 gas_table.go 112 × cpsb for contract creation
gasCreate2Eip3860 gas_table.go 112 × cpsb for contract creation
statefulGasCall gas_table.go 112 × cpsb for new account (empty + value)
makeGasSStoreFunc operations_acl.go 32 × cpsb for new storage slot
makeSelfdestructGasFn operations_acl.go 112 × cpsb for selfdestruct to empty

Everything else (17+ gas functions) is regular-only. The type split eliminates 44 MdGas{Regular: x} wrappers and 108 mdgas.MdGas{} zero-value returns.

10. Formalize Two-Phase Gas for BAL

Instead of ad-hoc function pairs (statelessGasCall/statefulGasCall), make the two phases a type-level contract:

type GasPhase1Result struct {
    RegularGas uint64      // known without state
    CanProceed bool        // false = abort before state access
}

type GasPhase2Result struct {
    RegularGas uint64      // additional regular gas from state check
    StateGas   uint64      // state creation gas (EIP-8037)
    BALAccess  []Address   // addresses to record in BAL
}

type TwoPhaseGasFunc struct {
    Stateless func(evm *EVM, ctx *CallContext, avail MdGas, memSize uint64) (GasPhase1Result, error)
    Stateful  func(evm *EVM, ctx *CallContext, avail MdGas, p1 GasPhase1Result) (GasPhase2Result, error)
}

This makes the EIP-7928 invariant explicit: Phase 1 determines affordability without state. Phase 2 is only called if Phase 1 succeeds. BAL recording is a return value, not a side effect.

11. Save/Restore Helper

The repeated pattern in instructions.go (6 copies) becomes a single helper:

func (evm *EVM) executeSubCall(scope *CallContext, stipend uint64,
    fn func(gas MdGas) ([]byte, MdGas, error)) ([]byte, error) {

    gas := MdGas{Regular: evm.CallGasTemp() + stipend, State: scope.stateGas}
    scope.stateGas = 0

    // Stipend doesn't count in block accounting
    evm.ledger.AdjustBlockRegular(-int64(stipend))

    ret, returnGas, err := fn(gas)

    if err != nil {
        scope.stateGas = /* restored by ledger.PopFrame */
    } else {
        scope.stateGas = returnGas.State
    }
    scope.refundGas(returnGas.Regular, ...)
    return ret, err
}

12. Blob Gas Stays at Transaction Level

Blob gas never enters a GasFrame because it never enters the EVM. The GasLedger handles it at PushTransaction/PopTransaction only, preserving its clean independence.


Part III: Size Impact and Risk Assessment

13. Scope of Current eip-8037 Implementation

Metric Count
Files changed (eip-8037 vs main) 46
Lines added/removed +1,162 / -898
MdGas{Regular: x} wrappers (no state gas, boilerplate) 44
mdgas.MdGas{} zero-value returns (error paths) 108
parentStateGas save/restore duplications 12 references across 6 call/create ops
childStateConsumed spill-reversal blocks 6 (3 in call(), 3 in create())
regularGasConsumed += update sites 13
stateGasConsumed += update sites 14
Skipped Amsterdam test fixtures 14 (gas mismatches, receipt hash mismatches)

The 14 skipped tests are the clearest signal — several show large gas discrepancies (e.g., gas used: 9897862, in header: 9841510), indicating structural accounting errors, not off-by-one issues.

14. Blast Radius Analysis

66 non-test files across the codebase import execution/vm:

Category Files Impact from Refactor
EVM core (execution/vm/) ~15 Heavy — all gas functions, interpreter, evm.go
State transition (execution/protocol/) 6 Heavy — state_transition.go, block_exec.go
Executors (execution/stagedsync/) 4 Medium — exec3_serial.go, exec3_parallel.go
Tracers (execution/tracing/) 12 Low — use uint64 projection, not MdGas directly
RPC (rpc/jsonrpc/) 16 Low — consume ExecutionResult, not internal gas
CLI/tools (cmd/) 4 Low — t8ntool, integration, state runner
Builder 2 Low — uses gas pool, not internal gas
Other (polygon, otterscan, etc.) 7 Minimal — use vm.Config or error types

Tracer compatibility: Tracers currently see uint64 only — state gas is projected away via startGas.Regular. All 12 tracer files, 16 RPC files, and external tracer consumers are unaffected by internal gas restructuring as long as this projection is preserved.

15. Migration Phases

Phase 1: Extract Helpers (Low Risk)

Scope: execution/vm/instructions.go, execution/vm/evm.go

What: Factor duplicated patterns into shared functions without changing any types or APIs.

1a — Save/Restore Helper: Replace 6 copies of the parentStateGas pattern with 1 helper called 6 times.

Metric Value
Files touched 1 (instructions.go)
Lines removed ~60 (duplicated blocks)
Lines added ~25 (helper + 6 one-line calls)
Risk Very low — pure refactor, no logic change
Test impact Existing tests validate; no new tests needed

1b — Spill Reversal Helper: Replace 2 copies of the ~20-line spill reversal block in call() and create() with a shared function.

Metric Value
Files touched 1 (evm.go)
Lines removed ~40 (duplicated blocks)
Lines added ~25 (shared function)
Risk Low — same logic, one location. Any test failure reveals pre-existing divergence between the two copies.

Phase 1 Total: 2 files, ~-50 net lines, low risk, no dependencies.

Phase 2: Introduce GasLedger (Medium Risk)

Scope: New gas_ledger.go, modifications to evm.go and interpreter.go

What: Create the GasLedger as an internal EVM component wrapping existing fields. Replace 27 scattered counter updates with ledger.Use() calls, and the frame save/restore with PushFrame/PopFrame.

Metric Value
Files touched 4
Net lines ~+60
Risk Medium — the frame lifecycle is where all edge cases live
Merge conflict potential High with concurrent gas work on eip-8037

Phase 3: Typed Gas Functions (Low-Medium Risk)

Scope: gas_table.go, operations_acl.go, jump_table.go

What: Split gasFunc into regularGasFunc (returns uint64) and gasFunc (returns MdGas). Eliminate boilerplate wrappers.

Metric Value
Files touched 4
Net lines ~-80
Risk Low — mechanical transformation, no behavioral change
Dependencies Can be done independently of Phase 2

Phase 4: Two-Phase Gas Formalization (Medium Risk)

Scope: operations_acl.go, gas_table.go

What: Replace ad-hoc stateless/stateful function pairs with formal TwoPhaseGasFunc type.

Metric Value
Files touched 3-4
Lines changed ~200
Risk Medium — changes gas calculation structure
Dependencies Phase 3; should only be pursued if BAL recording bugs persist

16. Size Summary

Phase Files Lines Added Lines Removed Net
1: Extract helpers 2 50 100 -50
2: GasLedger 4 197 137 +60
3: Typed gas functions 4 40 120 -80
4: Two-phase formalization 4 200 150 +50
Total ~8 ~487 ~477 ~+10

The refactor is roughly code-neutral in size. With the GasLedger, the eip-8037 diff would shrink by ~280 lines (wrappers, zero returns, duplicated logic), from +1,162 to approximately +880 additions.

17. Risk Register

ID Risk Severity Likelihood Mitigation
R1 Consensus divergence — different gas values than geth Critical Medium EEST fixtures must all pass; devnet comparison
R2 Tracer regression — incorrect gas in debug_trace* High Low Preserve uint64 projection; run tracer tests
R3 Merge conflicts — concurrent gas fixes on eip-8037 High High Land Phase 1 quickly; Phase 2 needs stable window
R4 Performance regression — method call overhead Medium Very low Pre-allocate frames; ~50µs/s overhead (negligible)
R5 Incomplete migration — two mental models Medium Medium Phase 1 is standalone valuable; Phase 2 is all-or-nothing
R6 Spill log correctness — wrong reversal order High Low Test all spill×revert×depth combinations; debug invariant
R7 Frame stack overflow — allocation pressure at depth 1024 Low Very low Pre-allocate to max call depth

18. Decision Matrix

Factor Phase 1 Phase 2 Phase 3 Phase 4
Value High — eliminates most dangerous duplication High — centralizes edge case logic Medium — code cleanliness Medium — BAL correctness
Risk Very low Medium Low Medium
Urgency Now — while edge cases are being fixed Before Amsterdam hardfork Any time When BAL bugs surface
Dependencies None Phase 1 None Phase 3
Merge conflict risk Low High Medium Medium
Revertable? Yes — expand inline Yes — but painful Yes — add wrappers back Yes — revert to ad-hoc

19. Recommended Sequence

  1. Phase 1 NOW — Extract helpers. Pure improvement with near-zero risk. Immediately reduces the surface area for contradicting edge case fixes. Can be reviewed and landed in a single PR.

  2. Phase 3 NEXT — Typed gas functions. Independent of Phase 2, low risk, reduces boilerplate. Good warmup for reviewing gas structure changes.

  3. Phase 2 AFTER STABILIZATION — GasLedger. The big win but needs a stable period on eip-8037. Ideally done when the current batch of skipped tests is resolved.

  4. Phase 4 IF NEEDED — Two-phase formalization. Only pursue if BAL recording side-effects continue to cause bugs after Phases 1-3.

20. What Could Go Wrong

Worst case — Consensus fork: A gas accounting bug causes erigon to compute different header.GasUsed than other clients. Detected on devnet immediately. Recovery: revert the PR.

Likely case — Merge conflict hell: Phase 2 lands, then a concurrent gas fix on eip-8037 conflicts. The fix author must understand the GasLedger. Recovery: document invariants, add debug assertions.

Best case — Skipped tests start passing: If the duplicated spill reversal in call() and create() has diverged (which the skip list suggests), centralizing it may fix multiple tests at once. The skip list entries with large gas mismatches (e.g., 56K gas difference) are likely candidates.


Appendix: Key Files Reference

Component File Purpose
MdGas type execution/vm/evmtypes/mdgas/md_gas.go Multi-dimensional gas struct
Interpreter loop execution/vm/interpreter.go Opcode dispatch, gas deduction
EVM call/create execution/vm/evm.go Call frame lifecycle, revert handling
Gas functions execution/vm/gas_table.go Per-opcode dynamic gas calculation
ACL gas functions execution/vm/operations_acl.go EIP-2929/7702/7928 gas with access lists
Instructions execution/vm/instructions.go Opcode implementations (CALL, CREATE, etc.)
Jump table execution/vm/jump_table.go gasFunc type definition, opcode→gas mapping
State transition execution/protocol/state_transition.go Transaction gas lifecycle, refund, 7778
Gas pool execution/protocol/gaspool.go Block-level gas + blob gas pools
Block execution execution/protocol/block_exec.go Block validation, gas limit checks
State processor execution/protocol/state_processor.go GasUsed{Receipt,Block,Blob} accumulation
Intrinsic gas execution/protocol/fixedgas/intrinsic_gas.go Transaction intrinsic gas (regular + state)
CostPerStateByte execution/protocol/misc/eip8037.go Dynamic state gas pricing
Blob gas pricing execution/protocol/misc/eip4844.go Blob base fee calculation
Serial executor execution/stagedsync/exec3_serial.go Serial block execution gas tracking
Parallel executor execution/stagedsync/exec3_parallel.go Parallel block execution gas tracking
Access list execution/state/access_list.go EIP-2929 warm/cold tracking
Intra-block state execution/state/intra_block_state.go Refund counter, BAL access marking
Versioned IO execution/state/versionedio.go BAL construction from versioned reads/writes
BAL types execution/types/block_access_list.go Block access list data structures
Execution result execution/vm/evmtypes/evmtypes.go ExecutionResult with dual gas fields
Transaction pool txnprovider/txpool/pool.go Tx validation with MdGas
Gas constants execution/protocol/params/protocol.go All gas cost parameters
Chain config execution/chain/chain_config.go Blob config, fork rules

Metadata

Metadata

Assignees

Labels

Glamsterdamhttps://eips.ethereum.org/EIPS/eip-7773

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions