erigontech / erigontech/erigon

EVM multi-dimensional gas: architecture analysis and migration assessment

Open
#19,865 2 comments 0 reactions 1 assignee Claimed by @yperbasis View on GitHub
Glamsterdam
Dominant language
Go
Stars
3.6k
Forks
1.5k
Avg merge
1d 16h
Merged PRs (30d)
455

Description

## 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`:

```go
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:

```go
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 type** — `execution/vm/evmtypes/mdgas/md_gas.go`:

```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:

```go
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:

```go
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:

```go
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**:

```go
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**:

```go
// 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 centralized** — `blockUsed` 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:

```go
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:

```go
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:

```go
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 |

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.