Skip to content

Commit 2119b38

Browse files
authored
refactor(spec-specs): organize gas logic in amsterdam EIP-8037/8038/2780 (#3159)
* refactor(spec-specs, tools): bundle amsterdam frame gas into GasMeter Gather the flat gas fields on Evm into a GasMeter dataclass in vm/gas.py and replace the scattered hand-rolled gas arithmetic with named operations: - State-gas rollback becomes self-contained on the meter: a state_gas_baseline field records the reservoir level a rollback refills to (the frame's grant at entry), commit_state_gas folds everything consumed since the baseline into state_gas_committed and moves the baseline down (zeroing the spill, so post-commit refunds route to the reservoir), restore_state_gas refills to the baseline on revert or halt, and restore_state_gas_to_entry undoes the commit when the transaction rollback also reverts the applied delegations. Message.state_gas_reservoir is never mutated after construction. - Frame lifecycle helpers (refill/commit and credit_state_gas_refund) move from vm/__init__.py into vm/gas.py; incorporate_child_on_* stay as thin wrappers over the new absorb_child_gas_on_* meter operations. - Inline arithmetic in interpreter.py (burn-all-gas), system.py (reservoir handoffs) and fork.py (EIP-8037 split, refund + floor settlement) becomes forfeit_remaining_gas, withhold_create_gas, drain_state_gas_reservoir, restore_child_gas, allocate_execution_gas and settle_transaction_gas; tx_state_gas_used measures the top frame's net state gas at settlement. - t8n tracer protocols gain gas-layout accessors so both the flat and gas-meter Evm shapes trace through one code path. The meter carries no per-frame regular-gas counter: since #3005 the block's regular-gas dimension is derived from transaction totals (tx.gas - gas_left - state_gas_left, floor-bound), so a frame-level counter would only ever be charged, discounted across sub-calls, and forfeited on halts without being read. The identifiers avoid "frame" (restore_child_gas, tx_state_gas_used, withhold_create_gas) ahead of EIP-8141, which will give the term a protocol meaning. Behavior-preserving: verified with ruff, mypy, ethereum-spec-lint, vulture, spec-tools, json_loader tracing on Amsterdam and Osaka, and the EIP-8037/2780/7778 fill battery. * refactor(spec-specs): name the gas stages in amsterdam opcodes Give the two-dimensional gas choreography named stages so structurally corresponding code sits in structurally corresponding places: - CALL*/CREATE* wrappers price the opcode in labeled sections -- GAS (STATE-INDEPENDENT) computes and affordability-checks everything that needs no state access, STATE ACCESS (STATE-DEPENDENT GAS) performs the accesses and completes the pricing, STATE GAS holds the account-creation charge, and CHILD GRANT withholds the child's regular share and drains the reservoir in one block. - generic_call and generic_create reduce to the child-frame lifecycle (PREFLIGHT, DESTINATION ACCESS, DISPATCH, OUTCOME): grant withholding and all charging move to the wrappers, and the balance preflight folds into generic_call via GenericCall.insufficient_balance, unifying the abort-without-spawn paths. - The same stage labels apply to sstore and selfdestruct, the two other opcodes with state-gas stages; single-stage opcodes keep the bare GAS marker. - Frame-settlement comments on the process_message handlers state the postcondition the parent-side absorb relies on. Behavior-preserving: statement order of every charge, check, and trace event is unchanged; verified with the EIP-8037/2780/7778 fill battery and the full static suite. * refactor(spec-specs): unify amsterdam child-frame incorporation Discard a failed frame's refunds in restore_state_gas, alongside the state gas rollback, instead of ignoring them at the absorption sites. Every settled meter now states exactly what the frame gives back, so a single incorporate_child replaces the on-success/on-error pair (and the absorb_child_gas_on_* helpers): gas is absorbed unconditionally, while logs, self-destructs, and warmed access sets survive only on success. The top frame's refund read in process_message_call becomes unconditional for the same reason. * chore(tooling): document gas handling rules in implement-eip skill Capture the two-dimensional gas philosophy from the Amsterdam gas refactors so future sessions do not re-derive it: gas movements are named GasMeter helpers in vm/gas.py, opcodes price in labeled stages with all charging before the operation, generic_call/generic_create run only the child-frame lifecycle, and gas changes are verified by preserving charge/check/trace order against the fill tests. * fix static test fails
1 parent a45205c commit 2119b38

11 files changed

Lines changed: 872 additions & 394 deletions

File tree

.claude/commands/implement-eip.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,22 @@ Each fork lives at `src/ethereum/forks/<fork_name>/`. Explore the latest fork di
2828
## Adding a New Opcode
2929

3030
1. Add to `Ops` enum in `vm/instructions/__init__.py` with hex value
31-
2. Implement function in appropriate `vm/instructions/<category>.py` — follows pattern: STACK → GAS (`charge_gas`) → OPERATION → PROGRAM COUNTER
31+
2. Implement function in appropriate `vm/instructions/<category>.py` — follows pattern: STACK → GAS (`charge_gas`) → OPERATION → PROGRAM COUNTER. Opcodes that touch state use the staged gas labels — see "Gas Handling" below.
3232
3. Register in `op_implementation` dict in `vm/instructions/__init__.py`
3333
4. Add gas constant in `vm/gas.py` if needed
3434

35+
## Gas Handling
36+
37+
Recent forks meter two gas dimensions: regular gas and state gas (for durable state growth). Key rules:
38+
39+
1. Gas constants and calculations go in `vm/gas.py`; a frame's mutable gas state lives on `Evm.gas_meter`.
40+
2. Extend the named helper vocabulary (`charge_*`, `credit_*`, `restore_*`, `withhold_*`, ...) instead of doing gas arithmetic by hand at call sites; encode each helper's invariant as an assert.
41+
3. State gas is charged by the frame whose opcode causes the creation, before the child's regular-gas share is withheld; the whole reservoir passes to the child.
42+
4. A failing frame settles its own meter before returning, so parents incorporate children unconditionally.
43+
5. Opcodes that touch state use labeled stages, with all charging before the operation: `GAS (STATE-INDEPENDENT)``STATE ACCESS (STATE-DEPENDENT GAS)``STATE GAS``CHILD GRANT``OPERATION`. Simple opcodes keep the bare `GAS` marker. `generic_call`/`generic_create` contain no pricing; they run the child lifecycle: `PREFLIGHT``DESTINATION ACCESS``CHILD GRANT``DISPATCH``OUTCOME`.
44+
6. Avoid "frame" in gas identifiers (a future EIP claims the term); when a name diverges from the spec's variable name, cross-reference the spec name in the docstring.
45+
7. A gas change is behavior-preserving only if the relative order of every charge, check, and trace event is unchanged; verify with the gas-related fill tests under `tests/<fork>/`.
46+
3547
## Adding a New Precompile
3648

3749
1. Define address constant in `vm/precompiled_contracts/__init__.py` using `hex_to_address("0x...")`

src/ethereum/forks/amsterdam/fork.py

Lines changed: 19 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,12 @@
103103
from .vm.gas import (
104104
GasCosts,
105105
StateGasCosts,
106+
allocate_execution_gas,
106107
calculate_blob_gas_price,
107108
calculate_data_fee,
108109
calculate_excess_blob_gas,
109110
calculate_total_blob_gas,
111+
settle_transaction_gas,
110112
)
111113
from .vm.interpreter import MessageCallOutput, process_message_call
112114

@@ -1032,8 +1034,6 @@ def process_transaction(
10321034
sender = recover_sender(tx)
10331035
intrinsic = validate_transaction(tx, sender)
10341036

1035-
intrinsic_gas = Uint(intrinsic.regular)
1036-
10371037
(
10381038
effective_gas_price,
10391039
blob_versioned_hashes,
@@ -1055,12 +1055,9 @@ def process_transaction(
10551055

10561056
effective_gas_fee = tx.gas * effective_gas_price
10571057

1058-
# Split execution gas into gas_left (capped by remaining regular gas
1059-
# budget) and state_gas_reservoir.
1060-
execution_gas = tx.gas - intrinsic_gas
1061-
regular_gas_budget = TX_MAX_GAS_LIMIT - intrinsic.regular
1062-
gas = min(regular_gas_budget, execution_gas)
1063-
state_gas_reservoir = Uint(execution_gas - gas)
1058+
# Split execution gas into a regular grant (capped by the remaining
1059+
# regular-gas budget) and a state gas reservoir.
1060+
allocation = allocate_execution_gas(tx.gas, intrinsic)
10641061

10651062
increment_nonce(tx_state, sender)
10661063

@@ -1087,8 +1084,8 @@ def process_transaction(
10871084
recipient=tx.to,
10881085
value=tx.value,
10891086
gas_price=effective_gas_price,
1090-
gas=gas,
1091-
state_gas_reservoir=state_gas_reservoir,
1087+
gas=allocation.regular_gas,
1088+
state_gas_reservoir=allocation.state_gas_reservoir,
10921089
access_list_addresses=access_list_addresses,
10931090
access_list_storage_keys=access_list_storage_keys,
10941091
state=tx_state,
@@ -1102,43 +1099,32 @@ def process_transaction(
11021099

11031100
tx_output = process_message_call(message)
11041101

1105-
tx_gas_used_before_refund = (
1106-
tx.gas - tx_output.gas_left - tx_output.state_gas_left
1107-
)
1108-
tx_gas_refund = min(
1109-
tx_gas_used_before_refund // Uint(5), Uint(tx_output.refund_counter)
1102+
settlement = settle_transaction_gas(
1103+
tx.gas,
1104+
intrinsic,
1105+
tx_output.gas_left,
1106+
tx_output.state_gas_left,
1107+
tx_output.refund_counter,
1108+
tx_output.state_gas_used,
11101109
)
1111-
tx_gas_used_after_refund = tx_gas_used_before_refund - tx_gas_refund
11121110

1113-
# Transactions with less execution_gas_used than the floor pay at the
1114-
# floor cost.
1115-
tx_gas_used = max(tx_gas_used_after_refund, intrinsic.calldata_floor)
1116-
1117-
tx_gas_left = tx.gas - tx_gas_used
1118-
gas_refund_amount = tx_gas_left * effective_gas_price
1111+
gas_refund_amount = settlement.gas_left * effective_gas_price
11191112

11201113
# For non-1559 transactions effective_gas_price == tx.gas_price
11211114
priority_fee_per_gas = effective_gas_price - block_env.base_fee_per_gas
1122-
transaction_fee = tx_gas_used * priority_fee_per_gas
1115+
transaction_fee = settlement.gas_used * priority_fee_per_gas
11231116

11241117
# refund gas
11251118
create_ether(tx_state, sender, U256(gas_refund_amount))
11261119

11271120
# transfer miner fees
11281121
create_ether(tx_state, block_env.coinbase, U256(transaction_fee))
11291122

1130-
tx_state_gas = tx_output.state_gas_used
1131-
# The calldata floor binds the regular-gas dimension: subtract state gas
1132-
# first so the floor is not discounted by a transaction's state spending.
1133-
tx_regular_gas = max(
1134-
tx_gas_used_before_refund - Uint(max(0, tx_state_gas)),
1135-
intrinsic.calldata_floor,
1136-
)
1137-
block_output.block_gas_used += tx_regular_gas
1138-
block_output.block_state_gas_used += Uint(max(0, tx_state_gas))
1123+
block_output.block_gas_used += settlement.regular_gas_used
1124+
block_output.block_state_gas_used += settlement.state_gas_used
11391125
block_output.blob_gas_used += tx_blob_gas_used
11401126

1141-
block_output.cumulative_gas_used += tx_gas_used
1127+
block_output.cumulative_gas_used += settlement.gas_used
11421128
receipt = make_receipt(
11431129
tx, tx_output.error, block_output.cumulative_gas_used, tx_output.logs
11441130
)

src/ethereum/forks/amsterdam/vm/__init__.py

Lines changed: 41 additions & 147 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@
2626

2727
from ..block_access_lists import BlockAccessList, BlockAccessListBuilder
2828
from ..blocks import Log, Receipt, Withdrawal
29-
from ..fork_types import Authorization, StateGas, VersionedHash
29+
from ..fork_types import Authorization, VersionedHash
3030
from ..state_tracker import BlockState, TransactionState
3131
from ..transactions import LegacyTransaction
32+
from .gas import GasMeter
3233

3334
__all__ = ("Environment", "Evm", "Message")
3435
TRANSFER_TOPIC = keccak256(b"Transfer(address,address,uint256)")
@@ -170,11 +171,9 @@ class Evm:
170171
stack: List[U256]
171172
memory: bytearray
172173
code: Bytes
173-
gas_left: Uint
174-
state_gas_left: Uint
174+
gas_meter: GasMeter
175175
valid_jump_destinations: Set[Uint]
176176
logs: Tuple[Log, ...]
177-
refund_counter: int
178177
running: bool
179178
message: Message
180179
output: Bytes
@@ -183,43 +182,21 @@ class Evm:
183182
error: Optional[EthereumException]
184183
accessed_addresses: Set[Address]
185184
accessed_storage_keys: Set[Tuple[Address, Bytes32]]
186-
regular_gas_used: Uint = Uint(0)
187-
state_gas_spilled: Uint = Uint(0)
188-
committed_state_gas: int = 0
189-
"""
190-
State gas locked in by [`commit_frame_state_gas`] because the state
191-
it paid for outlives a later failure in the frame.
192-
193-
[`commit_frame_state_gas`]: ref:ethereum.forks.amsterdam.vm.commit_frame_state_gas
194-
""" # noqa: E501
195185

196186

197-
def credit_state_gas_refund(evm: Evm, amount: StateGas) -> None:
187+
def incorporate_child(evm: Evm, child_evm: Evm) -> None:
198188
"""
199-
Credit a state gas refund to the local frame, in LIFO order.
200-
201-
State-gas charges draw from the reservoir first and from `gas_left`
202-
last, so refills credit the pool charged last first: `gas_left` up
203-
to `state_gas_spilled`, then the reservoir. This restores the
204-
exact pools the charge drew from, so the two never drift.
205-
206-
Parameters
207-
----------
208-
evm :
209-
The frame crediting the refund.
210-
amount :
211-
The refund amount to credit.
212-
213-
"""
214-
from_gas_left = min(amount, evm.state_gas_spilled)
215-
evm.gas_left += from_gas_left
216-
evm.state_gas_spilled -= from_gas_left
217-
evm.state_gas_left += amount - from_gas_left
218-
219-
220-
def incorporate_child_on_success(evm: Evm, child_evm: Evm) -> None:
221-
"""
222-
Incorporate the state of a successful `child_evm` into the parent `evm`.
189+
Incorporate the state of a returning `child_evm` into the parent
190+
`evm`.
191+
192+
Gas flows back to the parent regardless of the child's fate. A
193+
failed child settles its own meter before returning -- its state
194+
gas rolled back to the baseline, its [spill] refilled, and its
195+
refunds discarded -- so absorbing the meter unconditionally
196+
reclaims exactly the gas the child gives back. Everything else the
197+
child accumulated -- logs, scheduled self-destructs, refunds, and
198+
warmed access sets -- survives only on success, dying with a
199+
failed child's reverted state.
223200
224201
Parameters
225202
----------
@@ -228,117 +205,34 @@ def incorporate_child_on_success(evm: Evm, child_evm: Evm) -> None:
228205
child_evm :
229206
The child evm to incorporate.
230207
231-
"""
232-
evm.gas_left += child_evm.gas_left
233-
evm.state_gas_left += child_evm.state_gas_left
234-
evm.state_gas_spilled += child_evm.state_gas_spilled
235-
evm.logs += child_evm.logs
236-
evm.refund_counter += child_evm.refund_counter
237-
evm.accounts_to_delete.update(child_evm.accounts_to_delete)
238-
evm.accessed_addresses.update(child_evm.accessed_addresses)
239-
evm.accessed_storage_keys.update(child_evm.accessed_storage_keys)
240-
evm.regular_gas_used += child_evm.regular_gas_used
241-
242-
243-
def refill_frame_state_gas(evm: Evm) -> None:
244-
"""
245-
Roll back the frame's state gas in LIFO order on revert or halt.
246-
247-
The frame's state changes are undone, so the state gas it consumed
248-
is credited back to `gas_left` first and then to the reservoir,
249-
restoring the pools the charges drew from.
250-
251-
Parameters
252-
----------
253-
evm :
254-
The frame whose state gas is rolled back.
255-
256-
"""
257-
evm.gas_left += evm.state_gas_spilled
258-
evm.state_gas_left = evm.message.state_gas_reservoir
259-
evm.state_gas_spilled = Uint(0)
260-
261-
262-
def frame_state_gas_used(evm: Evm) -> int:
263-
"""
264-
Return the net state gas consumed by a finished frame, including
265-
any state gas committed as non-refillable earlier in the frame.
266-
267-
Equal to the reservoir drawn down ([`state_gas_reservoir`][sgr] at
268-
the last commit -- or frame entry, absent one -- minus the
269-
reservoir now) plus [`state_gas_spilled`][sgs] plus
270-
[`committed_state_gas`][csg]. May be negative when refunds exceed
271-
charges.
272-
273-
Parameters
274-
----------
275-
evm :
276-
The finished frame.
277-
278-
[sgr]: ref:ethereum.forks.amsterdam.vm.Message.state_gas_reservoir
279-
[sgs]: ref:ethereum.forks.amsterdam.vm.Evm.state_gas_spilled
280-
[csg]: ref:ethereum.forks.amsterdam.vm.Evm.committed_state_gas
281-
282-
"""
283-
return (
284-
int(evm.message.state_gas_reservoir)
285-
- int(evm.state_gas_left)
286-
+ int(evm.state_gas_spilled)
287-
+ evm.committed_state_gas
288-
)
289-
290-
291-
def commit_frame_state_gas(evm: Evm) -> None:
292-
"""
293-
Mark the state gas consumed so far as non-refillable and reset the
294-
refill baseline.
295-
296-
The state this gas paid for (the delegations applied by
297-
[`set_delegation`][sd]) outlives a later failure of the dispatched
298-
code, so a subsequent [`refill_frame_state_gas`][refill] must not
299-
credit it back. The consumption so far is folded into
300-
[`committed_state_gas`][csg] and the reservoir baseline moves down
301-
to the current level, so only charges made after this commit are
302-
refillable.
303-
304-
Parameters
305-
----------
306-
evm :
307-
The frame whose state gas consumption is committed.
308-
309-
[sd]: ref:ethereum.forks.amsterdam.vm.eoa_delegation.set_delegation
310-
[refill]: ref:ethereum.forks.amsterdam.vm.refill_frame_state_gas
311-
[csg]: ref:ethereum.forks.amsterdam.vm.Evm.committed_state_gas
312-
313-
"""
314-
evm.committed_state_gas = frame_state_gas_used(evm)
315-
evm.message.state_gas_reservoir = evm.state_gas_left
316-
evm.state_gas_spilled = Uint(0)
317-
318-
319-
def incorporate_child_on_error(
320-
evm: Evm,
321-
child_evm: Evm,
322-
) -> None:
323-
"""
324-
Incorporate the state of an unsuccessful `child_evm` into the parent `evm`.
325-
326-
The child rolls back its own state gas via `refill_frame_state_gas`
327-
before returning (on both reverts and exceptional halts), so its
328-
`gas_left` and reservoir already reflect the LIFO refill. The parent
329-
therefore only reabsorbs the child's `gas_left` and reservoir.
330-
331-
Parameters
332-
----------
333-
evm :
334-
The parent `EVM`.
335-
child_evm :
336-
The child evm to incorporate.
208+
[spill]: ref:ethereum.forks.amsterdam.vm.gas.GasMeter.state_gas_spilled
337209
338210
"""
339-
evm.gas_left += child_evm.gas_left
340-
evm.state_gas_left += child_evm.state_gas_left
341-
evm.regular_gas_used += child_evm.regular_gas_used
211+
child_meter = child_evm.gas_meter
212+
# Only the top frame commits state gas; a child never carries any.
213+
assert child_meter.state_gas_committed_spill == Uint(0)
214+
215+
if child_evm.error:
216+
# A failed child arrives settled: rolled back to its baseline,
217+
# spill refilled, refunds discarded.
218+
assert child_meter.state_gas_spilled == Uint(0)
219+
assert child_meter.refund_counter == 0
220+
assert child_meter.state_gas_left == child_meter.state_gas_baseline
221+
222+
# Gas returns to the parent regardless of the child's fate.
223+
# Note that upon failure, the child already arrives settled.
224+
gas_meter = evm.gas_meter
225+
gas_meter.gas_left += child_meter.gas_left
226+
gas_meter.state_gas_left += child_meter.state_gas_left
227+
gas_meter.state_gas_spilled += child_meter.state_gas_spilled
228+
gas_meter.refund_counter += child_meter.refund_counter
229+
230+
# Everything else survives only on success.
231+
if not child_evm.error:
232+
evm.logs += child_evm.logs
233+
evm.accounts_to_delete.update(child_evm.accounts_to_delete)
234+
evm.accessed_addresses.update(child_evm.accessed_addresses)
235+
evm.accessed_storage_keys.update(child_evm.accessed_storage_keys)
342236

343237

344238
def emit_transfer_log(

0 commit comments

Comments
 (0)