owned this note
owned this note
Published
Linked with GitHub
# EIP-8037: Framework Rollout Plan (Per-Block cpsb)
Per-block cpsb is staying. This is the framework-first rollout — no more per-test gas bumps.
All changes share a common foundation: threading `block_gas_limit: int | None = None` through the gas calculation chain. This is backward compatible (`None` = current hardcoded default).
---
## Foundation: `block_gas_limit` Parameter + Amsterdam cpsb Plumbing
### Method Signatures to Update
| File | Methods |
|------|---------|
| `base_fork.py` | `gas_costs()`, `transaction_intrinsic_cost_calculator()`, `transaction_intrinsic_state_gas()`, `opcode_gas_calculator()`, `opcode_refund_calculator()` |
| `vm/bases.py` | `opcode_gas_calculator()` and `opcode_refund_calculator()` protocol definitions |
| `forks.py` | All overrides (~40), plus `opcode_gas_map()` (10 overrides), `opcode_refund_map()` (uses `cls.gas_costs()`) |
| `bytecode.py` | `Bytecode.gas_cost()`, `Bytecode.refund()` |
| **Total** | **~12 methods, ~40 overrides** |
### Amsterdam's 5 Internal `cost_per_state_byte()` Call Sites
All 5 currently call `cls.cost_per_state_byte()` with the hardcoded default (120M). All 5 must use the passed `block_gas_limit` instead.
**Confirmed in PR #2235**: Each method calls `cpsb = cls.cost_per_state_byte()` with no parameter. The default comes from `_DEFAULT_BLOCK_GAS_LIMIT = 120_000_000` hardcoded in the Amsterdam class (cannot import from `test_types.block_types` due to circular dependency — see comment in PR #2235).
| Line | Method | How it uses cpsb |
|------|--------|-----------------|
| 3499 | `gas_costs()` | Computes G_STORAGE_SET, G_CREATE, G_NEW_ACCOUNT, G_AUTHORIZATION |
| 3543 | `transaction_intrinsic_state_gas()` | Computes intrinsic state gas for creation + auth |
| 3568 | `_calculate_sstore_gas()` | SSTORE cost (0→nonzero includes 32 * cpsb) |
| 3602 | `_calculate_sstore_refund()` | SSTORE refund (includes state_gas_storage_set) |
| 3655 | `_calculate_return_gas()` | Code deposit cost (code_size * cpsb) |
**Critical**: The 3 private `_calculate_*` methods receive `gas_costs` as a parameter but **also independently call `cls.cost_per_state_byte()` with the hardcoded default**. They must receive cpsb as a parameter instead.
### The Full Call Hierarchy
```
cost_per_state_byte(gas_limit=...) ← already parameterized
├── gas_costs(block_gas_limit=...) ← NEEDS PARAM (line 3499)
│ ├── transaction_intrinsic_cost_calculator() ← NEEDS PARAM (Prague line 2864)
│ ├── opcode_gas_map() ← NEEDS PARAM (Amsterdam line 3410)
│ │ └── opcode_gas_calculator() ← NEEDS PARAM
│ │ └── Bytecode.gas_cost(fork) ← NEEDS PARAM
│ └── opcode_refund_map() ← NEEDS PARAM (calls cls.gas_costs())
│ └── opcode_refund_calculator() ← NEEDS PARAM
│ └── Bytecode.refund(fork) ← NEEDS PARAM
├── transaction_intrinsic_state_gas() ← NEEDS PARAM (line 3543)
├── _calculate_sstore_gas() ← NEEDS PARAM (line 3568, via opcode_gas_map)
├── _calculate_sstore_refund() ← NEEDS PARAM (line 3602, via opcode_refund_map)
└── _calculate_return_gas() ← NEEDS PARAM (line 3655, via opcode_gas_map)
```
## 1. Non-Static Tests
### Problem
PR [#2235](https://github.com/ethereum/execution-specs/pull/2235) introduced ~45 gas limit bumps and ~25 fork-conditional patterns across 57 test files. Common patterns from the PR:
```python
# Fork-conditional (most common — ~25 instances)
gas_limit = 500_000 if fork >= Amsterdam else 100_000
gas_limit = 5_000_000 if fork >= Amsterdam else 500_000
# Subcall gas (selfdestruct tests)
subcall_gas = 500_000 if fork >= Amsterdam else 100000
# Sender balance bumps
sender = pre.fund_eoa(amount=500_000_000 if extra_gas else 4_000_000)
```
These are fragile — the "right" number depends on the block gas limit, which can change. The PR also bumped the generator default buffer from 500K to 1M (`generators.py:615`).
### Solution: `auto_gas` Metadata + Block Share Strategy
Instead of computing gas analytically, assign gas from the block's budget. Non-static tests check post-state via **Python assertions** (storage slots, code, nonces) — not hardcoded expected values — so "enough gas" is sufficient.
### Why Not a Sentinel in `gas_limit`
`gas_limit` is typed `HexNumber` (`test_types/transaction_types.py:313`). Pydantic validation coerces values through `HexNumber` reconstruction (`base_types.py:121`), so an `AutoGas(int)` sentinel would be stripped — `isinstance(tx.gas_limit, AutoGas)` fails after model validation.
### `AutoGasConfig` Model Field
Use explicit metadata on `Transaction` instead:
```python
class AutoGasConfig(BaseModel):
"""Configuration for automatic gas resolution."""
min_reservoir: int = Field(0, ge=0) # minimum state_gas_reservoir (Amsterdam+)
# In Transaction (test_types/transaction_types.py):
class Transaction(TransactionGeneric[HexNumber], ...):
auto_gas: AutoGasConfig | None = Field(None, exclude=True)
# exclude=True: never serialized to t8n input
```
**`exclude=True` footgun**: `copy()` is implemented via `model_dump()` (`base_types/pydantic.py:31`). Excluded fields won't survive this path — pipeline helpers like the gas optimization path in `state.py:142` use `.copy(...)`. Fix: override `copy()` / `model_copy()` on Transaction to preserve `auto_gas`, or use a serialization-only exclusion (`json_schema_extra` or custom `model_dump` override) rather than field-level `exclude=True`.
Test authors opt in explicitly:
```python
# Most tests — just need "enough gas"
tx = Transaction(auto_gas=AutoGasConfig(), ...)
# Tests that need a specific reservoir size
tx = Transaction(auto_gas=AutoGasConfig(min_reservoir=20_000_000), ...)
# Tests with explicit gas — no auto_gas field, gas_limit used as-is
tx = Transaction(gas_limit=100_000, ...)
```
`gas_limit` keeps its default of `21_000`. The resolver overwrites it only when `auto_gas is not None`. No sentinel in `gas_limit`, no Pydantic coercion issues.
### Resolution Strategy: `block_share`
Resolve `auto_gas` before signing in **both** flows:
- **`state.py`** (line 351) — state test flow
- **`blockchain.py`** (line 603) — blockchain test flow
**Single-tx block**: auto tx gets the full block gas limit.
**Multi-tx block**: split remaining block gas across auto txs in order, after subtracting explicit tx gas.
### Fork Cap API
New mode/value split to eliminate Osaka/Amsterdam ambiguity. **Must accept `block_number`/`timestamp`** — the existing `transaction_gas_limit_cap()` is block/time-aware for transition fork support (`test_tx_gas_limit_transition_fork.py:57`):
```python
# In base_fork.py
@classmethod
def tx_gas_cap_mode(
cls, *, block_number: int = 0, timestamp: int = 0
) -> str:
"""Return tx gas cap semantics: 'none' | 'hard_total' | 'regular_only'."""
return "none"
@classmethod
def tx_gas_cap_value(
cls, *, block_number: int = 0, timestamp: int = 0
) -> int | None:
"""Return the tx gas cap value, or None if no cap."""
return None
```
**Fork mapping:**
| Fork range | `tx_gas_cap_mode` | `tx_gas_cap_value` | Semantics |
|------------|-------------------|-------------------|-----------|
| Pre-Osaka (Genesis–Prague) | `"none"` | `None` | No cap. Equal shares, no reservoir. |
| Osaka–BPO* | `"hard_total"` | `16_777_216` | `gas_limit` field clamped to cap (EIP-7825). No reservoir concept. `min_reservoir` incompatible. |
| Amsterdam+ | `"regular_only"` | `16_777_216` | Cap applies to **regular gas only** (EIP-8037). Total `gas_limit` can exceed cap — excess flows to `state_gas_reservoir`. `min_reservoir` enforced when set. |
Osaka and Amsterdam share the same numeric cap (`2^24`) but the semantics differ fundamentally. At Osaka the cap is a hard ceiling on the entire `gas_limit` field. At Amsterdam the same cap only constrains the regular gas portion — the total `gas_limit` can be much larger because surplus feeds the state gas reservoir. This is why the mode/value split exists: the value alone is ambiguous without knowing whether it's a hard total or a regular-only cap.
**Consistency guards** (enforced at fork class load or first call):
```python
mode = cls.tx_gas_cap_mode()
value = cls.tx_gas_cap_value()
if mode in ("hard_total", "regular_only") and value is None:
raise ForkConfigError(f"tx_gas_cap_mode={mode} requires tx_gas_cap_value != None")
if mode == "none" and value is not None:
raise ForkConfigError(f"tx_gas_cap_mode='none' but tx_gas_cap_value={value}")
```
**Compatibility shim**: Keep existing `transaction_gas_limit_cap()` (`forks.py:1143`, `forks.py:3102`) mapped from the new API. Used widely — `benchmark.py:371`, `benchmark.py:454`, `pre_alloc.py:367`, `generators.py:793`, and test files. Returns `cap_value` for both `hard_total` and `regular_only` (consumers expect `int` when a cap exists), `None` only for `"none"`:
```python
# In base_fork.py — backward-compatible shim
@classmethod
def transaction_gas_limit_cap(
cls, *, block_number: int = 0, timestamp: int = 0
) -> int | None:
"""Compatibility: returns cap value when any cap exists, None otherwise."""
mode = cls.tx_gas_cap_mode(
block_number=block_number, timestamp=timestamp
)
if mode in ("hard_total", "regular_only"):
return cls.tx_gas_cap_value(
block_number=block_number, timestamp=timestamp
)
return None
```
### Dependency: Foundation Must Land First
The resolver calls `transaction_intrinsic_cost_calculator(block_gas_limit=...)` and `transaction_intrinsic_state_gas(block_gas_limit=...)`. The current base signature at `forks.py:816` does not have the `block_gas_limit` param yet. The Foundation section (Section 0) must land first — it threads `block_gas_limit` through all gas calculation entry points and unblocks this resolver.
### Resolver
Resolution keys off `tx.auto_gas` metadata (not `gas_limit` value), so repeated generation runs are idempotent. Uses a **two-pass allocator**: pass 1 reserves per-tx minimums, pass 2 distributes remaining budget. All assignments are computed into a plan first, then applied atomically — no partial mutation on failure.
```python
def resolve_auto_gas(fork, env, txs):
"""Resolve auto_gas metadata to concrete gas limits before signing."""
auto_indices = [i for i, tx in enumerate(txs) if tx.auto_gas is not None]
if not auto_indices:
return
# Block context for transition-fork-aware lookups
bn, ts = env.number, env.timestamp
# --- Guards ---
for i in auto_indices:
tx = txs[i]
# Re-signable: secret_key must be set
# (test_types/transaction_types.py:394, test_types/transaction_types.py:549)
if tx.secret_key is None:
raise AutoGasBudgetError(
"auto_gas requires a re-signable tx (secret_key must be set). "
"Pre-signed txs need explicit gas_limit."
)
# Intentional-failure txs
if tx.error is not None:
raise AutoGasBudgetError(
f"auto_gas cannot be used with tx.error={tx.error}. "
f"Intentional failure tests need explicit gas_limit."
)
# Receipt gas assertions
if tx.expected_receipt and tx.expected_receipt.gas_used is not None:
raise AutoGasBudgetError(
"auto_gas cannot be used with expected_receipt.gas_used. "
"Gas-exact tests need explicit gas_limit."
)
if tx.expected_receipt and tx.expected_receipt.cumulative_gas_used is not None:
raise AutoGasBudgetError(
"auto_gas cannot be used with expected_receipt.cumulative_gas_used. "
"Gas-exact tests need explicit gas_limit."
)
cap_mode = fork.tx_gas_cap_mode(block_number=bn, timestamp=ts)
cap_value = fork.tx_gas_cap_value(block_number=bn, timestamp=ts)
explicit_sum = sum(
txs[i].gas_limit for i in range(len(txs)) if i not in auto_indices
)
# --- Pre-loop sanity checks ---
# Policy: reject if explicit txs alone exceed block gas limit.
# Stricter than protocol validity (protocol checks cumulative gas_used,
# not declared gas_limit sum), but ensures the allocator has budget to
# work with. Intentional — makes auto_gas deterministic and avoids
# silently assigning zero-gas shares.
if explicit_sum > env.gas_limit:
raise AutoGasBudgetError(
f"Explicit tx gas ({explicit_sum}) already exceeds "
f"block gas limit ({env.gas_limit})."
)
if cap_mode == "hard_total":
for i in auto_indices:
if txs[i].auto_gas.min_reservoir:
raise AutoGasBudgetError(
f"min_reservoir={txs[i].auto_gas.min_reservoir} requested "
f"but fork {fork} has a hard tx gas cap. "
f"Use explicit gas_limit."
)
# --- Pass 1: compute per-tx minimum gas ---
minimums = {}
for i in auto_indices:
tx = txs[i]
intrinsic = fork.transaction_intrinsic_cost_calculator(
block_gas_limit=env.gas_limit,
block_number=bn, timestamp=ts,
)(
calldata=tx.data,
contract_creation=tx.to is None,
access_list=tx.access_list,
authorization_list_or_count=tx.authorization_list,
)
intrinsic_state = fork.transaction_intrinsic_state_gas(
block_gas_limit=env.gas_limit,
block_number=bn, timestamp=ts,
contract_creation=tx.to is None,
authorization_count=len(tx.authorization_list or []),
)
intrinsic_total = intrinsic + intrinsic_state
if cap_mode == "regular_only" and tx.auto_gas.min_reservoir:
required_exec_for_res = (
max(0, cap_value - intrinsic) + tx.auto_gas.min_reservoir
)
floor = intrinsic_total + required_exec_for_res
else:
floor = intrinsic_total
minimums[i] = floor
reserved = sum(minimums.values())
remaining = env.gas_limit - explicit_sum
if reserved > remaining:
raise AutoGasBudgetError(
f"Minimum gas for auto txs ({reserved}) exceeds available "
f"budget ({remaining}). Use explicit gas_limit."
)
# --- Pass 2: distribute remaining budget above minimums ---
surplus = remaining - reserved
n = len(auto_indices)
plan = {} # index -> gas_limit
for j, i in enumerate(auto_indices):
# Last tx gets exact surplus remainder
if j == n - 1:
bonus = surplus
else:
bonus = surplus // (n - j)
surplus -= bonus
share = minimums[i] + bonus
if cap_mode == "hard_total":
share = min(share, cap_value)
plan[i] = share
# --- Apply atomically ---
for i, gas_limit in plan.items():
txs[i].gas_limit = gas_limit
```
Usage in the pre-signing window:
```python
# blockchain.py (line 603)
env = block.set_environment(previous_env) # gas limit KNOWN
env = env.set_fork_requirements(self.fork)
resolve_auto_gas(self.fork, env, block.txs) # resolve auto_gas
txs = [tx.with_signature_and_sender() for tx in block.txs] # signing HERE
```
### Resolver Behavior by Fork
**Pre-Osaka (`"none"`):** Auto txs split the full block gas limit. A single auto tx gets the entire block budget (e.g., 120M). No cap constraints.
**Osaka–BPO* (`"hard_total"`):** Auto txs are hard-clamped to `cap_value` (16M). Even with a 120M block, each auto tx gets at most 16M. This is correct — at Osaka there is no state gas reservoir, so 16M is the genuine protocol maximum. Tests needing >16M gas cannot use `auto_gas` at Osaka, but this is unlikely since the state gas cost increases from EIP-8037 don't exist yet.
**Amsterdam+ (`"regular_only"`):** Auto txs can receive the full block share (potentially >>16M). The 16M cap constrains regular gas only — excess flows to state gas reservoir. This is where `min_reservoir` becomes meaningful, ensuring heavy-state tests (e.g., `test_all_opcodes.py` needing 50M) have sufficient reservoir budget.
### Explicit Gas Always Wins
If `tx.auto_gas is None` (no metadata), `gas_limit` is never modified. This preserves OOG tests, threshold tests, and gas-semantics tests. Resolution is keyed off metadata, not the `gas_limit` value.
### Default-Auto Gating
The current `gas_limit` default is `21_000` (`test_types/transaction_types.py:313`). Globally flipping unspecified gas limits to auto would change legacy behavior (balances, optimization paths).
**`auto_gas` must be explicitly set per-tx.** No implicit "unspecified gas => auto" behavior. If a framework-wide default is desired later, gate it behind Amsterdam+ fork check or a framework flag — not as a global default.
### Eligibility Criteria
`auto_gas` is appropriate for tests that are:
- **Re-signable** — `secret_key` is set (not pre-signed with v/r/s)
- **No `tx.error` set** — not an intentional failure/OOG test
- **No `expected_receipt.gas_used`** — not asserting exact gas consumption
- **No `expected_receipt.cumulative_gas_used`** — not asserting cumulative gas (`specs/helpers.py:315`)
- **No sender balance assertions** — gas spending changes the sender's final balance
- **Post-state-only assertions** — storage/code/nonce checks, not exact gas or balance
- **Non-OOG semantics** — the test expects the transaction to succeed
- **No gas-sensitive execution** — no GAS opcode / gas-dependent branching where huge gas changes behavior
- **Not in gas optimization / benchmark mode** — these need exact gas control. **Enforcement**: the resolver must be gated out of the `BenchmarkTestFiller` flow. `BenchmarkTestFiller.generate_blockchain_test()` constructs its own `BlockchainTest` with pre-computed transactions — it does not go through the standard `blockchain.py` signing window where `resolve_auto_gas` runs. But if a benchmark test manually constructs a `BlockchainTest` with `auto_gas` txs, the resolver would fire. Add an explicit op-mode check: skip resolution when `BaseTest.op_mode == OpMode.BENCHMARK`
Works for both single-tx and multi-tx blocks (two-pass allocator handles splitting).
### Tests That Still Need Explicit Gas
**Computed explicit gas** — derive gas from `fork.gas_costs(block_gas_limit=...)` so it auto-adjusts with cpsb:
```python
gas_costs = fork.gas_costs(block_gas_limit=env.gas_limit)
gas_limit = num_sstores * gas_costs.G_STORAGE_SET + base_overhead
```
**Hardcoded explicit gas** — for OOG tests, exact-gas tests, gas-sensitive branching. These remain fork-conditional where needed.
### Scope
Of the ~45 gas limit bumps:
- **Eligible for `auto_gas`**: majority — single-tx state tests with storage/nonce assertions
- **`auto_gas` + `min_reservoir`**: heavy state tests needing reservoir (e.g., `test_all_opcodes.py`)
- **Computed explicit**: tests with exact gas math or many state operations
- **Hardcoded explicit**: OOG tests, gas-sensitive tests, exact-balance tests
---
## 2. Benchmark Tests
### Problem
Benchmarks manually compute gas costs for iteration counts and assert **exact** gas matching (zero tolerance):
```python
gas_costs = fork.gas_costs() # step 1: get costs
loop_cost = gas_costs.G_CREATE + gas_costs.G_SELF_DESTRUCT + ... # step 2: per-iteration cost
num_contracts = (attack_gas_limit - base_costs) // loop_cost # step 3: iterations
expected_benchmark_gas_used = iterations * loop_cost + base_costs # step 4: expected gas
# step 5: create transactions
# step 6: assert gas_used == expected_benchmark_gas_used (exact match)
```
If step 1 returns wrong costs (hardcoded default doesn't match actual block gas limit), every subsequent step is wrong. `auto_gas` doesn't help here — benchmarks need **exact** gas, not "enough".
### Solution
**`block_gas_limit` and `gas_benchmark_value` are separate knobs.** They must not be conflated:
- **`block_gas_limit`** → controls cpsb / gas schedule (what gas constants are). Pass `Environment().gas_limit` (the block's actual gas limit).
- **`gas_benchmark_value`** → controls workload budget (how much gas the benchmark uses). This is the attack/iteration budget.
Pass the block's gas limit as `block_gas_limit` to all entry points so they return correct gas constants:
```python
# Use the block's actual gas limit for gas schedule, NOT gas_benchmark_value
block_gas_limit = Environment().gas_limit # or env.gas_limit
# ~18 call sites
gas_costs = fork.gas_costs(block_gas_limit=block_gas_limit)
# ~18 call sites
intrinsic_calc = fork.transaction_intrinsic_cost_calculator(block_gas_limit=block_gas_limit)
# ~2 call sites
loop_gas = loop.gas_cost(fork, block_gas_limit=block_gas_limit)
# ~2 call sites
loop_refund = loop.refund(fork, block_gas_limit=block_gas_limit)
```
`gas_benchmark_value` remains the workload budget — how many iterations, how many contracts to create, etc. The gas *schedule* (G_CREATE, G_STORAGE_SET, cpsb) comes from `block_gas_limit`.
Once entry points return correct values, ALL downstream math self-corrects:
- `loop_cost` → correct (uses correct G_CREATE, G_STORAGE_SET, etc.)
- `num_contracts` / `iterations` → correct (derived from loop_cost)
- `expected_benchmark_gas_used` → correct (derived from iterations)
- setup tx counts → correct (derived from gas_costs.G_CREATE)
- intrinsic gas → correct (uses correct state gas)
- SSTORE/RETURN opcode gas → correct (uses correct cpsb)
Keep exact assertions. Compute expectations with the same gas context.
### Amsterdam Cap Semantics: Don't Over-Split
**8 call sites** currently treat `transaction_gas_limit_cap()` as a hard total cap:
- `conftest.py:93` — `tx_gas_limit` fixture
- `benchmark.py:371,454` — `generate_blocks_from_code_generator()`, `split_transaction()` path
- `benchmark.py:473` — `generate_fixed_opcode_count_transactions()` (fixed opcode count mode)
- `generators.py:793,846` — `tx_iterations_by_gas_limit()`, `tx_iterations_by_total_iteration_count()`
- `test_tx_context.py:65`
At Amsterdam with `regular_only`, total `gas_limit` can exceed 16M — excess flows to state gas reservoir. Treating 16M as a hard ceiling over-splits transactions, adding unwanted intrinsic overhead per extra tx and breaking exact gas assertions (warm/cold patterns change, receipt `gas_used` sums diverge).
**Fix**: These sites must use the new cap API **with block context** (for transition fork correctness). The behavior depends on cap mode:
- **`"hard_total"`** (Osaka): split at `cap_value` as today — total `gas_limit` cannot exceed 16M.
- **`"regular_only"`** (Amsterdam): per-tx **regular gas** is still capped at 16M, but total `gas_limit` can exceed it (excess → reservoir). Regular-gas-heavy benchmarks (no state ops) still need splitting at `cap_value` for the regular portion. State-heavy benchmarks can assign total `gas_limit` above the cap. The split decision depends on whether the workload is regular-gas-bound or state-gas-bound.
- **`"none"`** (pre-Osaka): no splitting needed.
```python
# conftest.py — updated tx_gas_limit fixture
@pytest.fixture
def tx_gas_limit(fork: Fork, gas_benchmark_value: int, env: Environment) -> int:
mode = fork.tx_gas_cap_mode(
block_number=env.number, timestamp=env.timestamp
)
cap = fork.tx_gas_cap_value(
block_number=env.number, timestamp=env.timestamp
)
if mode == "hard_total":
return min(cap, gas_benchmark_value)
# "regular_only": total gas_limit can exceed cap, but regular gas
# per tx is still capped. Return full budget — let split_transaction /
# generators handle per-tx regular-gas capping when needed.
return gas_benchmark_value
```
Similarly, `generators.py:793` (`tx_iterations_by_gas_limit`), `generators.py:846` (`tx_iterations_by_total_iteration_count`), and `benchmark.py:473` (`generate_fixed_opcode_count_transactions`) should use `tx_gas_cap_mode()` with block context instead of the scalar `transaction_gas_limit_cap()`. For `hard_total`, split as today. For `regular_only`, only split when the workload is regular-gas-bound — state-heavy workloads can use the full budget unsplit.
**Shared classifier**: To avoid inconsistent `regular_only` decisions across 8+ call sites, add a shared helper:
```python
def benchmark_tx_gas_limit(
fork: Fork, gas_budget: int, *, block_number: int = 0, timestamp: int = 0,
) -> int:
"""Return the effective per-tx gas limit for benchmark splitting."""
mode = fork.tx_gas_cap_mode(block_number=block_number, timestamp=timestamp)
cap = fork.tx_gas_cap_value(block_number=block_number, timestamp=timestamp)
if mode == "hard_total":
return min(cap, gas_budget)
# "regular_only" and "none": full budget (splitting handled downstream)
return gas_budget
```
All 8 cap-mode call sites use this instead of inline logic.
### IteratingBytecode Internal Chain Needs `block_gas_limit`
The plan's "pass `block_gas_limit` to entry points" is necessary but not sufficient. The `IteratingBytecode` gas calculation chain has internal calls that also need context:
**Direct offenders** (call gas APIs without context):
| Method | File:Line | What it calls without context |
|--------|-----------|------------------------------|
| `iterating_subcall_gas_cost()` | `generators.py:588` | `Bytecode.gas_cost(fork=fork)` — no `block_gas_limit` |
| `gas_cost_by_iteration_count()` | `generators.py:609,611,619,621` | `Bytecode.gas_cost(fork=fork)` for setup/iterating/cleanup |
| `tx_gas_cost_by_iteration_count()` | `generators.py:660` | `fork.transaction_intrinsic_cost_calculator()` — no `block_gas_limit` |
| `FixedIterationsBytecode.gas_cost()` | `generators.py:1079` | Discards `block_number`/`timestamp`, never had `block_gas_limit` |
**Forwarding methods** (must accept and pass `block_gas_limit` to reach the above):
| Method | File:Line | Calls |
|--------|-----------|-------|
| `iterating_subcall_reserve()` | `generators.py:590` | `iterating_subcall_gas_cost()` |
| `tx_gas_limit_by_iteration_count()` | `generators.py:683` | `tx_gas_cost_by_iteration_count()` + `iterating_subcall_reserve()` |
| `_binary_search_iterations()` | `generators.py:705` | `tx_gas_limit_by_iteration_count()` |
| `tx_iterations_by_gas_limit()` | `generators.py:773` | `_binary_search_iterations()` |
| `tx_iterations_by_total_iteration_count()` | `generators.py:831` | `_binary_search_iterations()` |
| `transactions_by_gas_limit()` | `generators.py:876` | `tx_iterations_by_gas_limit()` |
| `transactions_by_total_iteration_count()` | `generators.py:944` | `tx_iterations_by_total_iteration_count()` |
**Fix**: Thread `block_gas_limit` through the full `IteratingBytecode` method chain. Every method that computes gas must accept and forward `block_gas_limit`:
```python
def gas_cost_by_iteration_count(
self, *, fork: Type[ForkOpcodeInterface],
iteration_count: int, block_gas_limit: int | None = None,
) -> int:
...
loop_gas_cost = self.iterating.gas_cost(
fork=fork, block_gas_limit=block_gas_limit
)
...
```
This is part of the Foundation work — the `Bytecode.gas_cost()` signature already accepts `block_number`/`timestamp`, adding `block_gas_limit` follows the same pattern.
### Fixed Opcode Count Mode
Benchmarks have two modes: **gas-based** (default) and **fixed opcode count** (`benchmark.py:357`). In fixed opcode count mode:
- `skip_gas_used_validation = True` — no exact gas assertions, measuring by operation count not gas usage
- `generate_fixed_opcode_count_transactions()` (`benchmark.py:465`) creates a single tx per block — no splitting
- Still uses `transaction_gas_limit_cap() or gas_benchmark_value` at line 473 to set `gas_limit`
Since gas validation is skipped, cpsb changes don't break assertions. But the `gas_limit` still needs to be large enough for the tx to execute all target opcodes. With Amsterdam `regular_only`, capping at 16M may starve state-heavy fixed-count benchmarks. Same fix applies: use `tx_gas_cap_mode()` and only hard-cap for `hard_total`.
### Stateful Benchmarks (bloatnet)
Stateful benchmarks under `tests/benchmark/stateful/bloatnet/` use **two distinct patterns**, both affected:
**Pattern A — `BlockchainTestFiller` with manual gas math** (`test_single_opcode.py:127-181`, `test_multi_opcode.py:75-112`):
```python
gas_costs = fork.gas_costs() # NO block_gas_limit
intrinsic_calc = fork.transaction_intrinsic_cost_calculator() # NO block_gas_limit
intrinsic_gas = intrinsic_calc(calldata=b"", contract_creation=False)
# Manual iteration count
iterations = (tx_gas_limit - intrinsic_gas - overhead) // per_op_cost
# Manual splitting
num_txs = max(1, math.ceil(gas_benchmark_value / tx_gas_limit))
```
These tests compute gas from scratch — they don't use `BenchmarkTestFiller` or `IteratingBytecode`. Call sites lacking `block_gas_limit`:
- `test_single_opcode.py`: lines 133/136 (SSTORE), 275/278 (SLOAD)
- `test_multi_opcode.py`: lines 79/82 (BALANCE+EXTCODESIZE), 260/264 (EXTCODECOPY+CREATE2), 449/452 (SLOAD+SSTORE mixed), 642/645 (SLOAD+SSTORE mixed ratio)
Fix: pass `block_gas_limit` at each call site.
The manual splitting (`num_txs = max(1, math.ceil(gas_benchmark_value / tx_gas_limit))`) uses `tx_gas_limit` from the fixture, which goes through `conftest.py:93` — already covered by the cap-mode fix above.
**Pattern B — `BenchmarkTestFiller` with `IteratingBytecode`** (`test_single_opcode.py:744-808`, `test_single_opcode.py:879-943`, `test_deep_branch.py:325-373`):
```python
iterations = loop.tx_iterations_by_gas_limit(fork=fork, tx_gas_limit=tx_gas_limit)
# ... IteratingBytecode generators handle splitting (transactions_by_gas_limit) ...
expected_benchmark_gas_used = iterations * per_iter_cost + overhead
```
Splitting is done by `IteratingBytecode` generators (`generators.py:876` `transactions_by_gas_limit()`, `generators.py:944` `transactions_by_total_iteration_count()`), not by `BenchmarkTestFiller` itself. When `blocks` is provided to the filler, it does not split (`benchmark.py:366-367`). These use the standard `IteratingBytecode` chain — already covered by the "IteratingBytecode Internal Chain" fix above. The `expected_benchmark_gas_used` assertions at lines 803 and 938 self-correct once the chain receives correct `block_gas_limit`.
**Pattern C — `BlockchainTestFiller` with gas-based loop** (`test_extcodesize_bytecode_sizes.py:174-220`):
These tests use a gas-based loop exit strategy (contract checks remaining gas internally) rather than computing iteration counts. No explicit `gas_costs()` or `intrinsic_cost_calculator()` calls — the contract self-regulates. However, they still compute `num_txs` from `tx_gas_limit` and need cap-mode-aware splitting. Not covered by `split_transaction` or generator logic — needs explicit handling.
**Stateful-specific concern**: Stateful tests often exercise ERC20 contracts where SSTORE and SLOAD dominate. These are the operations most affected by cpsb — G_STORAGE_SET goes from 20K (old) to 40K+ at 120M block gas limit. The iteration count drops ~2x, which is correct behavior (benchmarking state throughput at that gas limit), but the exact gas assertions must use the matching constants.
### EIP-8037 cpsb-Affected Gas Constants
The `cost_per_state_byte` (cpsb) is derived from the block gas limit via a quantized formula (per EIP-8037 spec; Amsterdam `cost_per_state_byte()` already accepts `gas_limit` param — confirmed in PR #2235). These are the gas constants that become functions of cpsb — the ones benchmarks compute manually:
| Constant | Formula | @ 30M | @ 60M | @ 120M | Old (fixed) |
|----------|---------|-------|-------|--------|-------------|
| `G_STORAGE_SET` | `(G_STORAGE_UPDATE - G_COLD_SLOAD) + 32 * cpsb` | 7,700 | 24,084 | 40,468 | 20,000 |
| `G_CREATE` | `REGULAR_GAS_CREATE + 112 * cpsb` | 25,800 | 83,144 | 140,488 | 32,000 |
| `G_NEW_ACCOUNT` | `112 * cpsb` | 16,800 | 74,144 | 131,488 | 25,000 |
| `G_AUTHORIZATION` | `PER_AUTH_BASE_COST + (112+23) * cpsb` | varies | varies | varies | 25,000 |
| `G_CODE_DEPOSIT_BYTE` | `cpsb + GAS_KECCAK256_WORD * ceil(len/32) / len` | varies | varies | varies | 200 |
(Constants verified against PR #2181 `vm/gas.py`: `REGULAR_GAS_CREATE = 9000`, `PER_AUTH_BASE_COST = 7500`, `STATE_BYTES_PER_STORAGE_SET = 32`, `STATE_BYTES_PER_NEW_ACCOUNT = 112`, `STATE_BYTES_PER_AUTH_BASE = 23`)
**Impact on benchmark gas math**: Every benchmark that manually computes `per_op_cost` using these constants will get different values at different block gas limits. This is intentional — benchmarks measure throughput at a given network size — but the exact gas assertions **must** use constants from the same `block_gas_limit` as the execution.
**SSTORE is the dominant driver**: Most stateful benchmarks (ERC20, storage access patterns) revolve around SSTORE. At 120M, `G_STORAGE_SET` doubles vs the old fixed cost — iteration counts halve, tx splitting changes.
**CREATE dominates setup**: Setup blocks deploying contracts are hit hardest — `G_CREATE` goes from 32K to 140K at 120M (4.4x). More setup txs needed, larger fixtures, but this is overhead, not measured throughput.
### Dependencies
Section 2 depends on:
- **Foundation** (Section 0): `block_gas_limit` param on `gas_costs()`, `transaction_intrinsic_cost_calculator()`, `Bytecode.gas_cost()`, etc.
- **Fork Cap API** (defined in Section 1): `tx_gas_cap_mode()`/`tx_gas_cap_value()` and the backward-compatible `transaction_gas_limit_cap()` shim. The Fork Cap API could be split into Foundation as a standalone sub-PR since Section 2 needs it independently of the auto_gas resolver.
### Setup Blocks
Setup blocks also use `gas_costs` for computing how many ops fit per setup tx. Same fix:
```python
# test_selfdestruct_existing -- setup phase
gas_costs = fork.gas_costs(block_gas_limit=block_gas_limit)
estimated_gas_per_creation = gas_costs.G_CREATE + 3_000
max_creations_per_tx = int(tx_gas_limit // estimated_gas_per_creation)
num_setup_txs = math.ceil(num_contracts / max_creations_per_tx)
```
### Files to Modify
| File | Entry points used | Call sites |
|------|------------------|-----------|
| `tests/benchmark/conftest.py` | `transaction_gas_limit_cap()` → `tx_gas_cap_mode()`/`tx_gas_cap_value()` | 1 |
| `packages/.../specs/benchmark.py` | `transaction_gas_limit_cap()`, `split_transaction()` | 3 |
| `packages/.../tools/tools_code/generators.py` | Full `IteratingBytecode` chain (see table above), `transaction_gas_limit_cap()` | ~12 |
| `tests/benchmark/compute/instruction/test_tx_context.py` | `transaction_gas_limit_cap()` | 1 |
| `tests/benchmark/compute/instruction/test_storage.py` | `gas_costs()`, `intrinsic_cost_calculator()`, `Bytecode.gas_cost()`, `Bytecode.refund()` | ~6 |
| `tests/benchmark/compute/instruction/test_system.py` | `gas_costs()`, `intrinsic_cost_calculator()`, `memory_expansion_calc()` | ~8 |
| `tests/benchmark/compute/instruction/test_account_query.py` | `gas_costs()`, `intrinsic_cost_calculator()` | ~2 |
| `tests/benchmark/compute/instruction/test_call_context.py` | `intrinsic_cost_calculator()` | ~2 |
| `tests/benchmark/compute/instruction/test_keccak.py` | `gas_costs()`, `intrinsic_cost_calculator()`, `memory_expansion_calc()` | ~3 |
| `tests/benchmark/compute/scenario/test_transaction_types.py` | `gas_costs()`, `intrinsic_cost_calculator()` | ~8 |
| `tests/benchmark/compute/scenario/test_unchunkified_bytecode.py` | `gas_costs()`, `intrinsic_cost_calculator()` | ~2 |
| `tests/benchmark/compute/precompile/test_alt_bn128.py` | `gas_costs()`, `intrinsic_cost_calculator()`, `memory_expansion_calc()` | ~3 |
| `tests/benchmark/compute/precompile/test_sha256.py` | `intrinsic_cost_calculator()` | ~1 |
| `tests/benchmark/compute/precompile/test_identity.py` | `intrinsic_cost_calculator()` | ~1 |
| `tests/benchmark/compute/precompile/test_ripemd160.py` | `intrinsic_cost_calculator()` | ~1 |
| `tests/benchmark/compute/precompile/test_bls12_381.py` | `intrinsic_cost_calculator()` | ~1 |
| `tests/benchmark/compute/helpers.py` | `gas_costs()`, `memory_expansion_calc()` | ~2 |
| `tests/benchmark/stateful/bloatnet/test_single_opcode.py` | `gas_costs()`, `intrinsic_cost_calculator()`, `tx_iterations_by_gas_limit()`, `transactions_by_gas_limit()` | ~8 |
| `tests/benchmark/stateful/bloatnet/test_multi_opcode.py` | `gas_costs()`, `intrinsic_cost_calculator()` (4 test functions × 2 calls each) | ~8 |
| `tests/benchmark/stateful/bloatnet/depth_benchmarks/test_deep_branch.py` | `tx_iterations_by_gas_limit()`, `transactions_by_gas_limit()` | ~2 |
| `tests/benchmark/stateful/bloatnet/test_extcodesize_bytecode_sizes.py` | Manual `num_txs` from `tx_gas_limit` (gas-based loop, no explicit `gas_costs()`) | ~2 |
| Non-benchmark EIP test files (~24 files) | Various | ~24 |
| **Total** | | **~101** |
### Edge Cases
- Block gas limit is set via `--block-gas-limit` CLI flag (`filler.py:624`), flows to `EnvironmentDefaults.gas_limit` (`filler.py:743`), and is consistent across all blocks in a benchmark. **Two access paths**: `Environment().gas_limit` (creates new Environment from `EnvironmentDefaults` — used for pre-signing gas math in benchmarks) and `env.gas_limit` (from `Block.set_environment()` — used by the auto_gas resolver in Section 1). These are the same value for benchmarks because blocks don't override gas_limit, but they come from different code paths. CI multi-gas-limit runs just re-invoke fill/execute with different `--block-gas-limit` values.
- `GasCosts` is frozen → create new instances per block gas limit, cache by `(fork, block_gas_limit, block_number, timestamp)` tuple
- `Bytecode` internal gas cache (`bytecode.py:285,310`) must include `block_gas_limit` in key — see Foundation caching note
- `IteratingBytecode` internal chain (`generators.py:588-660`) must forward `block_gas_limit` to all `Bytecode.gas_cost()` and `fork.*_calculator()` calls
- `FixedIterationsBytecode.gas_cost()` (`generators.py:1079`) must not discard `block_gas_limit` — currently drops `block_number`/`timestamp` too
- `regular_only` cap at Amsterdam: per-tx regular gas is still capped at 16M, but total `gas_limit` can exceed it. Split for `hard_total` always; for `regular_only`, only split when the workload is regular-gas-bound (no/few state ops). State-heavy workloads can use full budget unsplit. Manual stateful tests computing `num_txs` from `tx_gas_limit` (e.g., `test_extcodesize_bytecode_sizes.py`) also need cap-mode-aware handling.
- Stateful bloatnet benchmarks use two patterns (manual `BlockchainTestFiller` gas math and `BenchmarkTestFiller` + `IteratingBytecode`) — both need `block_gas_limit` but through different code paths
- `G_STORAGE_SET` doubles at 120M vs old fixed cost — SSTORE-heavy benchmarks (ERC20, storage access) halve their iteration counts, which is correct but exact assertions must match
---
## 3. Static Tests
### Problem
**1,475 failures** across **698 filler files** in **47 test directories**. All post-state mismatches from `>=Cancun` expect sections. These are JSON/YAML filler-based tests — no Python fork conditionals.
### Why EIP-8037 Breaks Static Tests
EIP-8037 introduces a **dual-dimension gas model** that fundamentally changes how gas is charged. Static tests hardcode transaction gas limits in JSON — these were calibrated for pre-Amsterdam fixed gas costs but are now insufficient because gas costs scale dynamically with the block gas limit.
**The dual-dimension split.** Every gas constant that touches state is split into two components:
| Constant | Pre-Amsterdam (fixed) | Amsterdam: regular | Amsterdam: state | Source |
|----------|----------------------|-------------------|-----------------|--------|
| `G_STORAGE_SET` | 20,000 | 2,900 (`G_STORAGE_UPDATE - G_COLD_SLOAD`) | `STATE_BYTES_PER_STORAGE_SET` (32) × cpsb | `storage.py`, `vm/gas.py` |
| `G_CREATE` | 32,000 | `REGULAR_GAS_CREATE` (9,000) | `STATE_BYTES_PER_NEW_ACCOUNT` (112) × cpsb | `system.py`, `vm/gas.py` |
| `G_NEW_ACCOUNT` | 25,000 | 0 | `STATE_BYTES_PER_NEW_ACCOUNT` (112) × cpsb | `system.py` |
| `G_CODE_DEPOSIT` | 200/byte | `GAS_KECCAK256_WORD × ceil(len/32)` / len | cpsb/byte | `interpreter.py` |
| `PER_AUTH_BASE_COST` | 12,500 | `PER_AUTH_BASE_COST` (7,500) | (`STATE_BYTES_PER_NEW_ACCOUNT` + `STATE_BYTES_PER_AUTH_BASE`) × cpsb = (112 + 23) × cpsb | `eoa_delegation.py`, `vm/gas.py` |
Where `cpsb = cost_per_state_byte(block_gas_limit)` is computed per the EIP-8037 formula (verified against PR #2181 `vm/gas.py:state_gas_per_byte()` and PR #2235 `Amsterdam.cost_per_state_byte()`):
```python
TARGET = 100 * 1024**3 # TARGET_STATE_GROWTH_PER_YEAR
BLOCKS_PER_YEAR = 2_628_000
OFFSET = 9578 # COST_PER_STATE_BYTE_OFFSET
SIG_BITS = 5 # COST_PER_STATE_BYTE_SIGNIFICANT_BITS
raw = (gas_limit * BLOCKS_PER_YEAR + 2 * TARGET - 1) // (2 * TARGET) # ceiling division
shifted = raw + OFFSET
shift = max(shifted.bit_length() - SIG_BITS, 0)
cpsb = max(((shifted >> shift) << shift) - OFFSET, 1)
```
**The reservoir model.** Transaction gas is split into two budgets at execution start (verified against PR #2181 `fork.py:975-979`):
```python
# PR #2181 fork.py process_transaction()
intrinsic = validate_transaction(tx, block_env.block_gas_limit)
intrinsic_gas = intrinsic.regular + intrinsic.state # IntrinsicGasCost dataclass
execution_gas = tx.gas - intrinsic_gas
regular_gas_budget = TX_MAX_GAS_LIMIT - intrinsic.regular # 16,777,216 - regular intrinsic
gas = min(regular_gas_budget, execution_gas) # gas_left
state_gas_reservoir = Uint(execution_gas - gas) # tx gas ABOVE regular budget
```
State gas operations draw from the reservoir first, then overflow to `gas_left` (PR #2181 `charge_state_gas()`). The `GAS` opcode returns only `gas_left`. Block validation uses `max(block_gas_used, block_state_gas_used)` not the sum (PR #2181 `fork.py:271-274`).
**Why tests fail.** Consider a typical static test with `currentGasLimit: 10000000000` (10B block gas limit) and `gasLimit: 100000` (100K tx gas):
1. `cpsb(10B)` ≈ 121,494 (enormous — cpsb scales with block gas limit)
2. A single SSTORE costs: regular `2,900` + state `32 × 121,494 = 3,887,808`
3. The tx has 100K gas total — OOGs immediately on any state operation
Even at the framework default `DEFAULT_BLOCK_GAS_LIMIT = 120M`:
1. `cpsb(120M)` ≈ 1,174
2. `G_STORAGE_SET` total = `2,900 + 32 × 1,174 = 40,468` (vs old 20,000)
3. Tests with tight gas budgets OOG; tests with headroom get different final balances
The key insight: **cpsb is computed from the test's own `env.currentGasLimit` (block gas limit), not the tx gas limit.** Static tests often set block gas limits to arbitrarily large values (10B+), making cpsb and therefore state gas costs enormous. The hardcoded tx gas limits were never designed for this.
### Solution: Dynamic Gas Adjustment in the Filler Pipeline
The framework must dynamically adjust the transaction gas limit at Amsterdam+ to fund both the regular gas budget and the state gas reservoir. This happens in the filler pipeline (`state_static.py`), not in the JSON files.
#### Critical EIP-8037 gas model detail
**EIP-7825 caps regular gas, not total tx gas.** At Amsterdam, a transaction's total gas can (and should) exceed the 16,777,216 regular gas cap. The excess funds the `state_gas_reservoir`:
```
execution_gas = tx.gas_limit - intrinsic_gas
regular_gas_budget = TX_GAS_LIMIT_CAP - intrinsic_regular_gas # ≈ 16M
gas_left = min(regular_gas_budget, execution_gas)
state_gas_reservoir = execution_gas - gas_left # tx gas ABOVE 16M
```
Setting `tx.gas_limit = 16,777,216` (the regular cap) leaves `state_gas_reservoir = 0`, forcing all state gas to draw from `gas_left`. At high cpsb values, this is insufficient. The tx gas must be set **higher than the regular cap** to properly fund the reservoir.
#### Dependencies
**Section 3 requires the specs PR (#2181) to land first.** Specifically, the Amsterdam transaction validation must allow `tx.gas_limit > TX_GAS_LIMIT_CAP` (EIP-8037 caps regular gas, not total gas). PR #2181 already handles this — it removes `TransactionGasLimitExceededError` and the `tx.gas > TX_MAX_GAS_LIMIT` rejection, replacing it with `max(intrinsic.regular, calldata_floor) > TX_MAX_GAS_LIMIT`. Without this change, bumping tx gas to `env.gas_limit` (which can far exceed 16M) would be rejected by transaction validation.
#### Framework Change: `state_static.py`
**Injection point**: `test_state_vectors()` at line ~192, between tx creation and `state_test()` call. This is where `fork`, `env`, and `exception` are all available.
```python
tx = self.transaction.get_transaction(tags, d, g, v, exception)
# EIP-8037: state gas costs scale with block_gas_limit via cpsb.
# Static tests hardcode tx gas limits calibrated for pre-Amsterdam
# fixed costs. At Amsterdam+, we must fund both the regular gas
# budget AND the state gas reservoir. Set tx gas to the block gas
# limit (env.gas_limit) so the reservoir is properly funded.
# Guard on exception: do not adjust gas for tests that expect
# a specific failure — tx.copy() would also drop the tx.error field.
if fork >= Amsterdam and exception is None:
block_gas = int(env.gas_limit)
tx = tx.copy(gas_limit=block_gas)
```
**`exception is None` guard**: Two reasons:
1. Tests with `expectException` intentionally fail. Adjusting gas would mask the failure.
2. `tx.copy()` rebuilds via `model_dump()` which excludes `tx.error` (`transaction_types.py:326`). Calling copy on an exception tx would drop the error metadata. The guard prevents this.
**Why `env.gas_limit` (block gas limit)?** The reservoir model requires `tx.gas > TX_GAS_LIMIT_CAP` for the state reservoir to have any budget. Using the block gas limit:
1. Properly funds the state reservoir: `state_gas_reservoir = block_gas - intrinsic - regular_budget`
2. Is consistent with the cpsb derivation (cpsb is computed from this same block gas limit)
3. Is the maximum valid tx gas for the block
**Why not just the EIP-7825 cap (16M)?** At 16M, the reservoir is empty. A test with `currentGasLimit: 10B` would compute `cpsb(10B) ≈ 121,494`, making a single SSTORE cost `32 × 121,494 = 3,887,808` state gas — impossible with an empty reservoir and only 16M regular gas.
**Reservoir overflow provides a safety net.** Even when the reservoir is insufficient for a state gas charge, `charge_state_gas()` (PR #2181) overflows to `gas_left`:
```python
if evm.state_gas_left >= amount:
evm.state_gas_left -= amount
elif evm.state_gas_left + evm.gas_left >= amount:
remainder = amount - evm.state_gas_left
evm.state_gas_left = Uint(0)
evm.gas_left -= remainder
else:
raise OutOfGasError
```
This means tests can survive with less reservoir than the total state gas cost. The plan's approach of bumping to `env.gas_limit` is maximally conservative — it gives the maximum possible reservoir, ensuring even tests with enormous cpsb values have enough gas in the right dimension.
**Reservoir preserved on failure.** Per PR #11328: on exceptional halt, only `gas_left` is consumed (zeroed). The `state_gas_reservoir` is returned to the parent frame because state changes are reverted. This means if a test OOGs on regular gas, the reservoir is not wasted — it flows back correctly.
**Sender balance and affordability**: tx gas = `env.gas_limit` requires sender balance `≥ gas × gas_price`. For tests with huge `currentGasLimit` and limited sender balance (e.g., `randomStatetest121Filler.json` with `currentGasLimit = 0x7fffffffffffffff`, sender balance `1e18`, gasPrice `10`), the tx can't afford the gas. The tx will fail the intrinsic gas check and produce a reverted post-state. **This is the correct Amsterdam behavior** — if a test's block gas limit makes state gas costs unaffordable, the tx legitimately fails under the new gas model.
No sender balance boosting, no special handling. The same logic runs in both the capture script and the runtime, so results always reproduce.
#### Batch Capture Workflow
**Single-pass, consistent with runtime.** The capture script applies the same gas adjustment as the runtime (`tx.gas = env.gas_limit` when `exception is None`). No two-pass triage, no divergence between capture and runtime behavior.
For each `(d, g, v, fork=Amsterdam)` combination:
1. **Build the test**: Resolve tags, build env, build tx (with gas adjustment if applicable)
2. **Run through `t8n.evaluate()` directly** — not through `make_state_test_fixture()`, which asserts on post-state mismatch (`state.py:375-379`). Extract `transition_tool_output.alloc` and `result.receipts[0].status` (success/failure).
3. **Compare** the captured post-state against the existing matching expect section.
4. **If different** → new `>=Amsterdam` expect section needed.
5. **If identical** → test works as-is, no new section.
**Baseline section selection**: Must replicate the exact first-match semantics from `test_state_vectors()` (`state_static.py:172-202`): iterate expect sections in order, check `has_index(d, g, v)` AND `fork in expect.network`, use first match. Multi-section files (e.g., `push0Filler.yml` with different fork ranges) require this precision.
**Receipt status**: The capture records `result.receipts[0].status` (0 = failure, 1 = success) for diagnostic purposes. This distinguishes successful execution with different balances from OOG reverts — useful for reporting but not for the capture logic itself (we always capture whatever post-state results).
**Per-`(d,g,v)` granularity**: Mixed files with both exception and non-exception indexes (e.g., `lowGasLimitFiller.yml` with different data indexes) are handled individually. Exception vectors skip gas adjustment; non-exception vectors get it.
#### Capture Script: `capture_amsterdam_expects.py`
New CLI command alongside `modify_static_test_gas_limits.py`.
**Direct t8n capture**: The capture builds the `StateTest` manually and calls `t8n.evaluate()` directly, extracting `transition_tool_output.alloc` without the assertion at `state.py:375-379`.
**File format handling**: Uses the same JSON/YAML dual-parser as `modify_static_test_gas_limits.py` (`modify_static_test_gas_limits.py:73-87`), with `NoIntResolver` for YAML files.
**Fork-range handling**: Does not assume all failing tests use `>=Cancun`. Identifies the existing matching expect section per `(d,g,v)` using the same first-match + `has_index` logic as `test_state_vectors`. Some tests use `>=Prague`, `>=Osaka`, or compound ranges like `>=Cancun<Prague`.
**Structure-preserving file modification**:
- **JSON files**: Targeted string insertion — locate the `"expect": [` position, insert the new section as a serialized JSON object before the first existing entry. Preserves formatting, comment keys, and key ordering.
- **YAML files**: Use `ruamel.yaml` (round-trip mode) which preserves comments, key ordering, and formatting. Falls back to raw string insertion if `ruamel.yaml` is unavailable.
- This avoids the destructive churn of `json.dump()` or `yaml.dump()` re-serialization.
**Grouping**: For each file, captured post-states are grouped by result. If all `(d, g, v)` combos produce the same Amsterdam post-state, a single section with wildcard indexes (`-1`) is used. Otherwise, per-combination sections with specific indexes are created.
### Tests with Multiple Gas Limits / Data / Value
For tests with `gasLimit: ["21000", "50000"]` (multiple `g` indexes):
- Each `g` index gets the same gas adjustment (`env.gas_limit`)
- Post-states are captured per `(d, g, v)` combination
- If results differ across combinations, separate expect sections with specific `indexes` are created
### Skip Criteria
Gas adjustment is skipped for:
1. **`expectException` tests**: The `exception is None` guard in the injection point handles this at runtime and in capture. Per-`(d,g,v,fork)` granularity — not per-file, since mixed files exist.
2. **Tests where Amsterdam post-state matches existing expect**: No new section needed.
### Fork Comparison Semantics
Fork comparisons use the inheritance-based `__le__`/`__ge__` operators (`base_fork.py:215-238`), not chronological ordering. `>=Amsterdam` matches Amsterdam and its subclass forks, which is correct. However, BPO sibling forks (e.g., `BPO3` if it doesn't inherit from Amsterdam) would NOT match `>=Amsterdam`. The capture script must verify that the fork hierarchy is linear for the target test's fork range.
The `ForkConstraint.match()` (`expect_section.py:190-203`) and `ForkSet` parser (`expect_section.py:220-241`) already handle this correctly — they iterate `get_forks()` and check each constraint. The plan relies on this existing infrastructure.
### Existing Tooling Reuse
| Tool | Reuse |
|------|-------|
| `modify_static_test_gas_limits.py` | **CLI structure + file parsing** (click command, JSON/YAML dual-parser with `NoIntResolver`, `StaticTestFile`, dry-run, regex-based file modification pattern) |
| `t8n.evaluate()` | **Direct post-state capture** (called without `verify_post_alloc` assertion) |
| `StateTest.verify_modified_gas_limit()` | **Binary search** for tightening gas (optional second pass to find minimum sufficient gas) |
| `ForkSet` / `ForkConstraint` | **Already parses `>=Amsterdam`** — no changes needed |
### File Changes
| File | Change |
|------|--------|
| `state_static.py` | ~8 lines: gas adjustment in `test_state_vectors()` |
| `capture_amsterdam_expects.py` (new) | ~300 lines: CLI for single-pass capture + structure-preserving JSON/YAML modification |
| 698 `*Filler.json`/`.yml` files | Automated: new `>=Amsterdam` expect sections prepended |
| No changes to `general_transaction.py` | Gas adjustment happens downstream |
| No changes to `expect_section.py` | `ForkSet` already handles `>=Amsterdam` |
| No changes to `environment.py` | `env.gas_limit` already flows from JSON |
### Ongoing Maintenance
The `>=Amsterdam` expect sections are tied to each test's specific `env.currentGasLimit` (which determines cpsb). If a test's block gas limit changes, its expect section needs recapturing — the same batch script handles this.
If cpsb parameters change in the EIP (constants, formula), ALL 698 sections need recapturing. This is a one-command operation (`capture_amsterdam_expects.py --recapture`).
**This maintenance burden is inherent to per-block cpsb.** Every time gas costs change as a function of block gas limit, static test post-states must be re-derived. Compared to pinning cpsb at fork level: zero maintenance — all tests work as-is.
---
## CI Guardrails
### 1. Multi-Gas-Limit CI
**Goal**: Verify Amsterdam non-static tests pass at multiple block gas limits, since cpsb (and therefore gas costs) scale with `block_gas_limit`.
**Existing infrastructure**: The `--block-gas-limit` CLI flag already exists (`filler.py:624`). It sets `EnvironmentDefaults.gas_limit` at session start (`filler.py:742-743`). No framework changes needed to parameterize.
**Where to put it**: New tox environments in `tox.ini`, referenced from `test.yaml` jobs. NOT `feature.yaml` — that's consumed by release workflows (`release_fixture_feature.yaml`, `release_fixture_full.yaml`), not by PR CI (`test.yaml`). The `feature_only` flag makes entries opt-in/manual, not automatic.
**Implementation**: Add tox environments:
```ini
[testenv:fill-amsterdam-30m]
commands = fill --evm-bin={env:EVM_BIN:evmone-t8n} \
--fork=Amsterdam --block-gas-limit=30000000 \
-m "not slow and not benchmark" \
-n auto --maxprocesses 6
[testenv:fill-amsterdam-60m]
commands = fill --evm-bin={env:EVM_BIN:evmone-t8n} \
--fork=Amsterdam --block-gas-limit=60000000 \
-m "not slow and not benchmark" \
-n auto --maxprocesses 6
[testenv:fill-amsterdam-120m]
commands = fill --evm-bin={env:EVM_BIN:evmone-t8n} \
--fork=Amsterdam --block-gas-limit=120000000 \
-m "not slow and not benchmark" \
-n auto --maxprocesses 6
```
Pin 120M explicitly (do not rely on `DEFAULT_BLOCK_GAS_LIMIT` which is derived from `CURRENT_MAINNET_BLOCK_GAS_LIMIT * 2` and could drift).
Add to `test.yaml` as a matrix job:
```yaml
multi-gas-limit:
name: Amsterdam ${{ matrix.gas-limit }}
needs: static
runs-on: [self-hosted-ghr, size-xl-x64]
strategy:
fail-fast: false
matrix:
gas-limit: [30m, 60m, 120m]
steps:
# ... setup ...
- run: uvx tox -e fill-amsterdam-${{ matrix.gas-limit }}
```
**No `--fill-static-tests`**: Static tests are unaffected by `--block-gas-limit` because their gas comes from the JSON `currentGasLimit` field (`environment.py:19,65`), not from `EnvironmentDefaults.gas_limit`. Static tests are validated by their own `>=Amsterdam` expect sections.
**Execute mode**: `--block-gas-limit` only covers fill mode. The execute plugin exposes `--transaction-gas-limit` instead (`execute.py:94,191`). Multi-gas-limit validation for execute mode requires separate work (noted as a gap — matches existing TODO at `benchmark.yaml:88`).
**pypy3**: The multi-gas-limit tox envs use CPython. PyPy3 coverage is a nice-to-have but not critical for gas-limit validation — the gas math is identical across interpreters.
### 2. `gas_costs()` Enforcement: mypy Over Custom Lint
**Goal**: After Foundation lands, catch calls to `fork.gas_costs()` at Amsterdam+ that omit the `block_gas_limit` parameter.
**NOT via `ethereum-spec-lint`**: The existing linter only traverses hardfork modules (`lint/__init__.py:165,58`), not test files or the `execution_testing` package. Adding test-file traversal would require significant new infrastructure.
**Preferred approach: mypy enforcement.** After Foundation lands, make `block_gas_limit` a **required parameter** (no default value) for Amsterdam's `gas_costs()` override. Pre-Amsterdam forks keep the default. Then mypy (which already runs in the `static` tox env) catches any call that omits the parameter when targeting Amsterdam+.
```python
# Amsterdam's gas_costs() in forks.py — no default for block_gas_limit
@classmethod
def gas_costs(
cls,
*,
block_number: int = 0,
timestamp: int = 0,
block_gas_limit: int, # REQUIRED — no default
) -> GasCosts:
```
This is simpler, more robust, and requires zero custom lint infrastructure.
### 3. Benchmark Regression Gate
**Goal**: Benchmark exact gas assertions must pass at all block gas limit levels.
**Current state**: `benchmark.yaml` sanity checks use `benchmark-gas-values` tox env (`benchmark.yaml:61`), which runs with `--gas-benchmark-values 1` (`tox.ini:160`) and `--generate-pre-alloc-groups` (`tox.ini:161`) at the default block gas limit. Benchmarks currently target Osaka.
**Implementation**: When Amsterdam benchmarks are added (Section 2 of this plan), extend the benchmark sanity matrix in `benchmark.yaml`:
```yaml
# In benchmark.yaml sanity-checks matrix
- name: Benchmark Gas Values (Amsterdam 30M)
tox-env: benchmark-gas-values-amsterdam-30m
- name: Benchmark Gas Values (Amsterdam 60M)
tox-env: benchmark-gas-values-amsterdam-60m
- name: Benchmark Gas Values (Amsterdam 120M)
tox-env: benchmark-gas-values-amsterdam-120m
```
Each tox env runs:
```bash
fill --evm-bin={env:EVM_BIN:evmone-t8n} \
--gas-benchmark-values 1 \
--generate-pre-alloc-groups \
--fork Amsterdam \
--block-gas-limit=<value> \
-m "benchmark and not slow" \
-n auto --maxprocesses 10 --dist=loadgroup
```
(Matches existing `benchmark-gas-values` structure with `--generate-pre-alloc-groups` included.)
### 4. Capture Script Validation
**Goal**: After the batch capture script inserts `>=Amsterdam` expect sections, CI must verify the results are correct.
**Implementation**: The main `py3` job in `test.yaml` already fills with `--until Amsterdam`, which includes Amsterdam. After the capture script inserts `>=Amsterdam` expect sections, the normal CI fill validates them. The existing `bal` entry in `feature.yaml` provides additional coverage for release builds.
### 5. Known Gaps
| Gap | Status |
|-----|--------|
| **Execute mode multi-gas-limit** | Blocked on `--transaction-gas-limit` vs `--block-gas-limit` distinction. Matches existing TODO (`benchmark.yaml:88`). |
| **PyPy3 multi-gas-limit** | Not critical — gas math is interpreter-independent. Can add later if needed. |
| **Static tests at varied block gas limits** | By design, static tests use their own `currentGasLimit`. No CI parameterization needed. |
### CI File Changes
| File | Change |
|------|--------|
| `tox.ini` | Add `fill-amsterdam-30m`, `fill-amsterdam-60m`, `fill-amsterdam-120m` tox envs; add `benchmark-gas-values-amsterdam-*` tox envs |
| `.github/workflows/test.yaml` | Add `multi-gas-limit` matrix job |
| `.github/workflows/benchmark.yaml` | Add Amsterdam multi-gas-limit sanity matrix entries |
| `.github/configs/feature.yaml` | No changes needed for CI guardrails (keep `bal` for release builds only) |
| No changes to `ethereum-spec-lint` | Use mypy enforcement instead |
---
## Cross-Verification Against Source PRs
This plan was verified against the three source PRs and the EIP-8037 spec:
| Aspect | Verified Against | Result |
|--------|-----------------|--------|
| cpsb formula (constants, ceiling division, quantization) | PR #2181 `vm/gas.py`, PR #2235 `Amsterdam.cost_per_state_byte()` | Exact match |
| Split gas constants (regular/state per op) | PR #2181 `storage.py`, `system.py`, `interpreter.py`, `eoa_delegation.py` | Exact match |
| Reservoir model (`execution_gas`, `gas_left`, `state_gas_reservoir`) | PR #2181 `fork.py:975-979` | Exact match |
| TX_MAX_GAS_LIMIT semantics (caps regular, not total) | PR #2181 removes `TransactionGasLimitExceededError` | Confirmed |
| Block gas = `max(regular_gas_used, state_gas_used)` | PR #2181 `fork.py:271-274` | Exact match |
| 5 internal cpsb call sites | PR #2235 `Amsterdam.gas_costs()`, `_calculate_sstore_gas/refund()`, `_calculate_return_gas()`, `transaction_intrinsic_state_gas()` | All confirmed |
| Child frame reservoir (full, no 63/64 rule) | PR #2181 `system.py` | Confirmed |
| Exceptional halt (reservoir preserved) | PR #11328 clarification, PR #2181 `interpreter.py` | Confirmed |
| `charge_state_gas()` overflow to `gas_left` | PR #2181 `vm/gas.py` | Confirmed |
| EIP-7702 auth refund to reservoir + `intrinsic_state_gas` adjustment | PR #2181 `eoa_delegation.py`, PR #11328 | Confirmed |
| Code deposit: `cpsb * size` (state) + `keccak_word * ceil(len/32)` (regular) | PR #2181 `interpreter.py:235-249` | Exact match |
| Non-static test patterns (~45 bumps, ~25 conditionals, 57 files) | PR #2235 diff | All patterns confirmed |
**One stale reference found**: The slides (`EIP-8037-slides.md:198`) reference `fork.safe_gas_limit()` which was superseded by the simpler `env.gas_limit` approach in this plan. The slides should be updated.
---
## Summary
| Section | What |
|---------|------|
| **Foundation** | `block_gas_limit` param through gas + refund chain, caching by `(fork, block_gas_limit, block_number, timestamp)`. Aligns with spec-side `IntrinsicGasCost` dataclass and `validate_transaction(tx, gas_limit)` changes (PR #2181). |
| **Non-static** | `auto_gas` metadata (`AutoGasConfig`) on Transaction, resolved via two-pass block-share allocator before signing. Replaces ~45 fragile gas bumps and ~25 fork-conditional patterns from PR #2235. Eligible: re-signable, post-state-only, non-OOG, no gas/balance assertions. Heavy/semantic tests use computed explicit gas via `fork.gas_costs(block_gas_limit=...)` |
| **Benchmarks** | Explicit `block_gas_limit` wiring at ~101 call sites (separate from `gas_benchmark_value`) |
| **Static** | EIP-8037 dual-dimension gas breaks 698 static tests (cpsb scales state gas costs with block gas limit). Dynamic tx gas adjustment in `state_static.py` (bump to `env.gas_limit` to fund state reservoir — EIP-7825 caps regular gas, not total; confirmed by `TransactionGasLimitExceededError` removal in PR #2181). Reservoir overflow to `gas_left` provides safety net. Single-pass capture via direct `t8n.evaluate()`. Same logic in runtime and capture (no divergence). Structure-preserving JSON/YAML modification. Per-`(d,g,v)` exception guard. Depends on specs PR #2181 landing first. |
| **CI** | Multi-gas-limit tox envs in `test.yaml` matrix (30M/60M/120M pinned, non-static only). Benchmark regression gate with Amsterdam sanity matrix in `benchmark.yaml`. mypy enforcement for `gas_costs()` (required param, no default at Amsterdam). Capture validated by existing `py3` fill job. |
**Foundation is PR-sized** and unblocks everything else. Can be done first as a standalone patch.
**Compared to pinning cpsb at fork level: zero work.** All tests work as-is.