evm/vm: EIP-8037 follow-ups (parametric CPSB, 7702 auth refund, SD storage refund, system call split)#4287
evm/vm: EIP-8037 follow-ups (parametric CPSB, 7702 auth refund, SD storage refund, system call split)#4287gabrocheleau wants to merge 16 commits intofeat/eip-8037from
Conversation
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 Report❌ Patch coverage is Additional details and impacted files
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
📦 Bundle Size Analysis
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.
Audit follow-up: spec review + 5 distinct bugs foundAfter 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:
New fixes (commits since the previous round)
Spec ambiguities found
Remaining clusters (P2/P3, see todo plan in branch)
|
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.
Summary
Follow-up work on top of #4286, targeting
feat/eip-8037. Four spec items from the EIP-8037 queue:Parametric CPSB —
cost_per_state_byteis now derived from the block gas limit per the latest spec rather than the constant 1174: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_boundaryfixture). New helperactiveCostPerStateByte(common, blockGasLimit?)plumbed through every state-gas charge site (SSTORE, CALL value-to-non-existent, CREATE/CREATE2 frame-exit, intrinsic+7702 inrunTx).EIP-7702 existing-authority state-gas refund — for each authorization that targets an account that already exists, refund
stateBytesPerNewAccount × CPSBdirectly into the reservoir before execution begins. No 20% cap (replaces the legacy regular-gas refund, which was already disabled under 8037 sinceperEmptyAccountCost = 0).SELFDESTRUCT deferred refund extended to per-account storage state-gas —
createdAccountStateGasnow accumulates eachSSTORE 0→nonzero's state-gas charge per address (additive);nonzero→0clears within the same tx decrement it so cleared slots aren't double-refunded. The existing refund path inrunTxreads the full per-address total, so this drops in transparently.System call gas split —
SYSTEM_CALL_GAS_LIMIT = 30_000_000 + STATE_BYTES_PER_STORAGE_SET × CPSB(blockGasLimit) × 16.requests.tsnow splits the regular 30M (passed asgas_lefttorunCall) from the reservoir portion (set onvm.evm.stateGasReservoirbefore the call, restored after via snapshot/restore so an in-flight tx-level reservoir isn't clobbered).accumulateRequests/accumulateWithdrawalsRequest/accumulateConsolidationsRequestnow take an optionalblockGasLimitthreaded through fromrunBlockandbuildBlock.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 existingresults.totalGasSpent / maxRefundQuotientis computed pre-refund and already equalsintrinsic_total + executionGasUsed + reservoirDelta, which istx.gas - gas_left - state_gas_reservoiralgebraically. No change needed.Test plan
state_gas_reservoir: 16/36 (no regression)state_gas_pricing: 4/74 (no regression —*_scales_with_cpsbandpricing_changes_with_block_gas_limitstill fail despite correct CPSB derivation, suggesting additional accounting work needed beyond items 1–5)v570_mixed_with_other_eips): 1143/1961 — identical pass count vsfeat/eip-8037parent (verified by checking out parent and re-running). No regressions introduced.Caveat
In commit
b88749674(per-account storage tracking), thecreatedAccountStateGasmap is not yet snapshotted in the journal-revert path. ASSTOREthat gets reverted leaves a stale entry that could affect a laterSELFDESTRUCTrefund in the same tx. In practice this only fires if the same address goes through a successfulCREATE → reverted SSTORE → SELFDESTRUCTsequence; worth wiring into_stateGasSnapshotsin a follow-up if any fixture probes it.