Skip to content

evm/vm: EIP-8037 follow-ups (parametric CPSB, 7702 auth refund, SD storage refund, system call split)#4287

Open
gabrocheleau wants to merge 16 commits intofeat/eip-8037from
eip-8037-followup
Open

evm/vm: EIP-8037 follow-ups (parametric CPSB, 7702 auth refund, SD storage refund, system call split)#4287
gabrocheleau wants to merge 16 commits intofeat/eip-8037from
eip-8037-followup

Conversation

@gabrocheleau
Copy link
Copy Markdown
Contributor

Summary

Follow-up work on top of #4286, targeting feat/eip-8037. Four spec items from the EIP-8037 queue:

  • Parametric CPSBcost_per_state_byte is now derived from the block gas limit per the latest spec rather than the constant 1174:

    raw     = ceil(gasLimit * 2_628_000 / (2 * 107_374_182_400))
    shifted = raw + 9578
    shift   = max(bitLen(shifted) - 5, 0)
    rounded = (shifted >> shift) << shift
    cpsb    = rounded <= 9578 ? 1 : rounded - 9578
    

    Returns 1174 at the spec's 96M reference gas limit (matches the prior constant), scales up beyond, floors at 1 for very low limits (covers the cpsb_underflow_boundary fixture). New helper activeCostPerStateByte(common, blockGasLimit?) plumbed through every state-gas charge site (SSTORE, CALL value-to-non-existent, CREATE/CREATE2 frame-exit, intrinsic+7702 in runTx).

  • EIP-7702 existing-authority state-gas refund — for each authorization that targets an account that already exists, refund stateBytesPerNewAccount × CPSB directly into the reservoir before execution begins. No 20% cap (replaces the legacy regular-gas refund, which was already disabled under 8037 since perEmptyAccountCost = 0).

  • SELFDESTRUCT deferred refund extended to per-account storage state-gascreatedAccountStateGas now accumulates each SSTORE 0→nonzero's state-gas charge per address (additive); nonzero→0 clears within the same tx decrement it so cleared slots aren't double-refunded. The existing refund path in runTx reads the full per-address total, so this drops in transparently.

  • System call gas splitSYSTEM_CALL_GAS_LIMIT = 30_000_000 + STATE_BYTES_PER_STORAGE_SET × CPSB(blockGasLimit) × 16. requests.ts now splits the regular 30M (passed as gas_left to runCall) from the reservoir portion (set on vm.evm.stateGasReservoir before the call, restored after via snapshot/restore so an in-flight tx-level reservoir isn't clobbered). accumulateRequests/accumulateWithdrawalsRequest/accumulateConsolidationsRequest now take an optional blockGasLimit threaded through from runBlock and buildBlock.

Item not changed: 20% refund cap formula. Reviewed against the EIP-8037 spec (tx_gas_refund = min(tx_gas_used_before_refund // 5, refund_counter)); the existing results.totalGasSpent / maxRefundQuotient is computed pre-refund and already equals intrinsic_total + executionGasUsed + reservoirDelta, which is tx.gas - gas_left - state_gas_reservoir algebraically. No change needed.

Test plan

  • state_gas_reservoir: 16/36 (no regression)
  • state_gas_pricing: 4/74 (no regression — *_scales_with_cpsb and pricing_changes_with_block_gas_limit still fail despite correct CPSB derivation, suggesting additional accounting work needed beyond items 1–5)
  • Full Amsterdam dev suite (v570_mixed_with_other_eips): 1143/1961 — identical pass count vs feat/eip-8037 parent (verified by checking out parent and re-running). No regressions introduced.

Caveat

In commit b88749674 (per-account storage tracking), the createdAccountStateGas map is not yet snapshotted in the journal-revert path. A SSTORE that gets reverted leaves a stale entry that could affect a later SELFDESTRUCT refund in the same tx. In practice this only fires if the same address goes through a successful CREATE → reverted SSTORE → SELFDESTRUCT sequence; worth wiring into _stateGasSnapshots in a follow-up if any fixture probes it.

Replaces the constant `costPerStateByte` (1174) with a derivation from
the block gas limit per the latest spec:

    raw     = ceil(gasLimit * 2_628_000 / (2 * 107_374_182_400))
    shifted = raw + 9578
    shift   = max(bitLen(shifted) - 5, 0)
    rounded = (shifted >> shift) << shift
    cpsb    = rounded <= 9578 ? 1 : rounded - 9578

At the spec's reference 96M gas limit this returns 1174, matching the
prior constant. Higher limits scale up; very low limits floor at 1
(handles the underflow boundary the fixtures probe).

The new helper activeCostPerStateByte(common, blockGasLimit?) is
plumbed through every state-gas charge site:
  - SSTORE state-gas (opcodes/functions.ts)
  - CALL value-to-non-existent state-gas (evm.ts)
  - CREATE/CREATE2 frame-exit state-gas (evm.ts)
  - intrinsic state-gas + 7702 authorization state-gas (runTx.ts)
For each EIP-7702 authorization that targets an authority account
that already exists, refund stateBytesPerNewAccount * costPerStateByte
directly to the state-gas reservoir before execution begins. This is
the EIP-8037 replacement for the legacy regular-gas refund (which is
already disabled under 8037 since perEmptyAccountCost = 0).

processAuthorizationList now returns both the legacy gasRefund and the
new existingAuthStateGasRefund; runTx adds the latter to
stateGasReservoirInitial so it expands the reservoir for the rest of
the tx (no 20% cap).
Previously the deferred SELFDESTRUCT refund only covered the account
+ code-deposit state-gas charged at CREATE time. The spec also
refunds new-storage-slot state-gas for slots set on a same-tx-created
account that gets selfdestructed.

Tracks the cumulative state-gas charged per address in
createdAccountStateGas:
  - CREATE/CREATE2 frame-exit accumulates account+code state-gas
    (additive instead of overwriting, in case of repeat keys)
  - SSTORE 0->nonzero adds stateBytesPerStorageSet * costPerStateByte
  - SSTORE nonzero->0 (clearing a same-tx-created slot) decrements
    the counter, mirroring the reservoir refill, so a later
    SELFDESTRUCT doesn't double-refund the cleared slot

The refund path in runTx already reads the per-address total, so this
change extends the refund to include storage without further wiring.
Per spec, SYSTEM_CALL_GAS_LIMIT becomes:
  30_000_000 + STATE_BYTES_PER_STORAGE_SET * CPSB * SYSTEM_MAX_SSTORES_PER_CALL

with the regular 30M passed as gas_left and the
STATE_BYTES_PER_STORAGE_SET * CPSB * 16 portion placed in the reservoir.
CPSB scales with the current block's gas limit.

requests.ts gains computeSystemCallGas + setup/teardown helpers that
snapshot the reservoir state around the runCall, so the system call's
state-gas accounting is isolated from any concurrent tx-level reservoir.
accumulateRequests / accumulateWithdrawalsRequest /
accumulateConsolidationsRequest now take an optional blockGasLimit
threaded through from runBlock and buildBlock.
block parameter is Block | undefined throughout runTx; the new
activeCostPerStateByte and processAuthorizationList call sites need
to accept that.
@codecov
Copy link
Copy Markdown

codecov Bot commented May 7, 2026

Codecov Report

❌ Patch coverage is 21.42857% with 55 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.08%. Comparing base (571b864) to head (0e078aa).

Additional details and impacted files

Impacted file tree graph

Flag Coverage Δ
block 87.33% <ø> (ø)
blockchain 88.82% <ø> (ø)
common 93.44% <ø> (ø)
evm 60.12% <21.42%> (-0.45%) ⬇️
mpt 89.64% <ø> (ø)
statemanager 78.04% <ø> (ø)
static 91.35% <ø> (ø)
tx 87.81% <ø> (ø)
util 80.83% <ø> (ø)
vm ?

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

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 7, 2026

📦 Bundle Size Analysis

Package Size (min+gzip) Δ
binarytree 17.5 KB ⚪ ±0%
block 43.8 KB 🔴 +0.0 KB (+0.09%)
blockchain 69.9 KB 🔴 +0.0 KB (+0.06%)
common 25.4 KB 🔴 +0.0 KB (+0.07%)
devp2p 17.7 KB ⚪ ±0%
e2store 87.3 KB 🔴 +0.0 KB (+0.04%)
ethash 61.8 KB 🔴 +0.0 KB (+0.07%)
evm 63.7 KB 🔴 +1.2 KB (+1.89%)
genesis 272.2 KB ⚪ ±0%
mpt 21.9 KB ⚪ ±0%
rlp 1.7 KB ⚪ ±0%
statemanager 41.3 KB 🔴 +0.0 KB (+0.04%)
testdata 43.8 KB ⚪ ±0%
tx 20.9 KB 🔴 +0.0 KB (+0.16%)
util 13.2 KB ⚪ ±0%
vm 156.7 KB 🔴 +1.9 KB (+1.21%)
wallet 15.0 KB ⚪ ±0%

Values are minified+gzipped bundles of each package entry. Workspace deps are bundled; external deps are excluded.

Generated by bundle-size workflow

The legacy EIP-2200 path uses sstoreInitEIP2200Gas (20000) for
SSTORE 0->nonzero, not sstoreSetGas. Under EIP-8037 this regular
portion drops to 2900, with the storage-set state portion (32 *
costPerStateByte) metered separately. Overriding only sstoreSetGas
left the EIP-2200 SSTORE handler still charging the legacy 20000.

+25 tests pass on the full Amsterdam dev suite (1143 -> 1168).
Under EIP-8037, the regular-gas refund for clearing a slot that was
created in the same tx (sstoreInitRefundEIP2200Gas) is replaced by
the state-gas reservoir refill of stateBytesPerStorageSet * CPSB.
Setting the legacy refund to 0 prevents double-refunding (the
fixture sstore_refund_scales_with_cpsb expects only the state-gas
refill, no regular-gas refund).

+10 tests pass (sstore_refund_scales_with_cpsb full group).
The 2D block accumulator was computing
  txRegular = max(txRegularGas, blockGasSpent)
which inflated block_regular_gas_used because blockGasSpent is the
combined regular+state total (totalGasSpent = intrinsic + execution +
reservoirDelta), not the calldata floor. When state-gas spilled into
gas_left, txRegularGas was correct but blockGasSpent included the
spilled state-gas, so the max picked up the wrong value and
gasUsed = max(blockReg, blockState) returned the combined sum.

Per spec section 10:
  block_regular_gas_used += max(tx_regular_gas, calldata_floor)

Apply the EIP-7623 calldata_floor against tx_regular_gas in runTx
(where floorCost is in scope), and accumulate txRegularGas directly
in runBlock without re-max'ing against blockGasSpent.

+289 tests pass on full Amsterdam dev suite (1168 -> 1457).
+143 tests in the EIP-8037 group (80 -> 223).
When CALL targets a non-existent EOA (no code), _executeCall takes
an early-exit path that returned before reaching the state-gas
charge block. The non-existent target is exactly the case the spec
wants to charge stateBytesPerNewAccount * CPSB for.

Move the state-gas charge into the early-exit branch (mirrors the
post-execution branch) so empty-code CALL frames that transfer
value also charge the new-account state gas.

+14 tests pass in the EIP-8037 group (223 -> 237).
The 7702 existing-authority refund was being added to
stateGasReservoirInitial, which is used as the snapshot for the
spec formula tx_gas_used = tx.gas - gas_left - reservoir_end. That
overstated tx_gas_used by the refund amount, since the refund
returns previously-paid intrinsic_state_gas to the reservoir
without representing 'gas paid' from a tx-cost perspective.

Apply the auth refund directly to vm.evm.stateGasReservoir after
snapshotting reservoirInitial, so:
  reservoirDelta = reservoirInitial - reservoir_end
                 = (state-gas consumed) - (auth refund)
giving the correct tx_gas_used.

+29 tests pass in the EIP-8037 group (237 -> 266).
The state-gas charge at frame-exit was OOG'ing when spill exceeded
the inner frame's gasLimit. Per spec, state-gas is metered tx-wide
not per-frame: the charge first deducts from state_gas_reservoir,
then from gas_left (the tx-level gas pool). The current frame's
budget bounds regular gas only.

Drop the inner OOG check; let executionGasUsed exceed message.gasLimit
so the spill bubbles to the caller's useGas() and consumes parent
gas. If the tx as a whole runs out, OOG raises at the caller frame.

Applies to both the post-execution and the empty-code early-exit
state-gas charge paths in _executeCall.

+2 tests in eip8037 group (266 -> 268).
When SELFDESTRUCT transfers value to a non-existent (or empty)
beneficiary, the value transfer creates a new account at the
beneficiary. Charge stateBytesPerNewAccount * CPSB on this path
(mirrors the CALL value-to-non-existent state-gas charge).

+15 tests in eip8037 group (268 -> 283).
When a creation tx (depth=0) fails (revert/exception/OOG), the
spec says no state-gas should be charged for the new account.
The intrinsic state portion (stateBytesPerNewAccount * CPSB) was
paid up-front in runTx but never refunded if the tx failed.

Add a refund path on the failure branch of _executeCreate at
depth=0 that returns the intrinsic to the reservoir. Mirrors the
existing 7702 auth refund pattern (refund to reservoir, decrement
executionStateGasUsed). The L * CPSB code-deposit portion isn't
needed in this path since we only charge stateGasCreate on success.
Per spec, all state-gas charged on a reverted frame is refunded.
The reservoir + executionStateGasUsed counters were already
snapshotted; the per-address createdAccountStateGas map was not.
A reverted SSTORE that contributed to the per-address tracker
would otherwise leak into the deferred SELFDESTRUCT refund at
end-of-tx.

Snapshot a clone of the map at frame entry; restore on revert.
Cost: O(n) per checkpoint where n = same-tx-created accounts so
far (typically small).
The intrinsic state-gas refund on top-level CREATE tx failure
needs to happen OUTSIDE the frame snapshot/revert (since the
refund applied inside _executeCreate gets unwound by the same
journal snapshot that handles the failure).

This ended up being non-trivial: the runTx-level refund must
narrow scope to the right failure modes (e.g. only TX-OOG, not
when the EVM internally rolled back state), otherwise tests that
were correctly NOT refunding before now over-refund. Backing it
out for now; tracked as P2.
@gabrocheleau
Copy link
Copy Markdown
Contributor Author

Audit follow-up: spec review + 5 distinct bugs found

After a careful walkthrough of EIP-8037 vs. our implementation (with debug-instrumented runs against failing fixtures), found and fixed 5 distinct bugs beyond the original queue. Test progression:

Full Amsterdam dev (1961 tests) EIP-8037 group (374 tests)
Parent baseline 1143 55
After PR (this branch, current HEAD) 1529 283
Δ +386 +228

New fixes (commits since the previous round)

  1. 3cbd381sstoreInitEIP2200Gas: 2900 under 8037. The 8037 overlay had set sstoreSetGas: 2900 but the EIP-2200 path uses sstoreInitEIP2200Gas (still 20000) for create-slot regular cost. Mismatched param name.

  2. 350a5b2sstoreInitRefundEIP2200Gas: 0 under 8037. Legacy regular-gas refund for "create-then-clear-in-same-tx" is replaced by the state-gas reservoir refill; without zeroing the legacy refund, fixtures double-refunded (+10 tests).

  3. c40a47a — Block 2D accounting bug. The accumulator had txRegular = max(txRegularGas, blockGasSpent); blockGasSpent is the combined regular+state total, so max inflated block_regular_gas_used and gas_used = max(blockReg, blockState) returned the combined sum instead of the per-dimension max. Apply EIP-7623 calldata floor against txRegularGas directly in runTx, accumulate without re-max'ing (+289 tests).

  4. 941e7af — CALL to non-existent EOA takes the empty-code early-exit path, which returned before the state-gas charge block. Charge new-account state-gas on this path too (+14 tests in eip8037 group).

  5. d77dbe5 — 7702 existing-authority refund was being added to stateGasReservoirInitial, which is the snapshot used by tx_gas_used = tx.gas - gas_left - reservoir_end. Including the refund there overstated tx_gas_used by the refund amount (since the refund returns previously-paid intrinsic). Apply auth refund directly to evm.stateGasReservoir after snapshotting initial (+29 tests).

  6. 95625e0 — State-gas spill at frame-exit was OOG'ing on inner frame's gasLimit. Per spec section 4, state-gas first deducts from reservoir then from gas_left (the tx-level pool). The current frame's budget bounds regular gas only; spill bubbles up to caller's useGas() and consumes parent gas. Drop the inner OOG check (+~50 tests).

  7. 0a1046b — SELFDESTRUCT to non-existent beneficiary should charge stateBytesPerNewAccount × CPSB (mirrors CALL value-to-non-existent). Was missing entirely (+15 tests).

  8. 9f55cb6 — Snapshot/revert createdAccountStateGas map alongside _stateGasSnapshots. Without this, a reverted SSTORE leaks into the deferred SELFDESTRUCT refund.

Spec ambiguities found

  1. CPSB constant vs parametric — mainline eips.ethereum.org/EIPS/eip-8037 page lists CPSB as constant 1174, but tests-bal@v5.7.0 fixtures, Erigon, and Besu treat it as parametric (block-gas-limit-derived per the formula in 5827fb9ab).
  2. EIP-8038 dependency — spec defers regular-gas refund value updates (sstoreClearRefundEIP2200Gas, sstoreInitRefundEIP2200Gas) to a separate EIP-8038 that hasn't published. Reverse-engineered behavior from fixtures so far.
  3. Creation-tx top-level revert refund — spec says "no state-gas is charged for the new account" on revert, but doesn't address whether the intrinsic state-gas portion (paid up-front in runTx) refunds. Tried implementing in-frame (commit ee3daaf03) but the journal-revert undoes it; tried at runTx level but it over-refunds for cases where state was rolled back internally. Backed out (3d0eebe99); needs a more careful scoping pass.
  4. System call block accounting exclusion — spec says system calls don't contribute to block_regular_gas_used / block_state_gas_used. Need to audit our runBlock accumulator to confirm.

Remaining clusters (P2/P3, see todo plan in branch)

  • test_call_value_to_self_destructed_burns_value — gas matches expected; receiptTrie mismatch suggests EIP-7708 SD-log differences.
  • test_nested_create_fail_parent_revert_state_gas, test_inner_create_succeeds_code_deposit_state_gas — likely related to top-level CREATE revert refund (item 3 above).
  • test_create_collision_burned_gas_counted_in_block_regular, test_create_oog_reservoir_inflation_detection, test_failed_create_header_gas_used — all touch the CREATE-failure refund path.

Replace CPSB / cpsb identifiers with the verbose costPerStateByte
form throughout the EIP-8037 helpers, so cspell's TS dictionary
no longer flags the abbreviation. Pure rename; no behavior change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants