Skip to content

Commit 40ce50b

Browse files
authored
feat(spec-specs, tests): EIP-8037 halt refunds spilled state gas (#2815)
1 parent e87390b commit 40ce50b

31 files changed

Lines changed: 650 additions & 194 deletions

File tree

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

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -236,19 +236,11 @@ def process_create_message(message: Message) -> Evm:
236236
restore_tx_state(tx_state, snapshot)
237237
evm.regular_gas_used += evm.gas_left
238238
evm.gas_left = Uint(0)
239-
# On halt, restore the state gas reservoir to what was
240-
# passed into this frame. State-gas charges in excess of
241-
# the original reservoir came from gas_left (spill) or
242-
# from a child revert refund; either way they get
243-
# re-classified as regular gas usage on halt.
244-
total_state = evm.state_gas_used + evm.state_gas_left
245-
reservoir = evm.message.state_gas_reservoir
246-
if total_state > reservoir:
247-
evm.regular_gas_used += total_state - reservoir
248-
evm.state_gas_left = reservoir
249-
evm.state_gas_used = Uint(0)
250-
evm.state_gas_refund = Uint(0)
251-
evm.state_gas_refund_pending = Uint(0)
239+
# State-gas counters preserved: parent's
240+
# incorporate_child_on_error (or tx-level error handler at
241+
# top) folds the full state-gas charge — including any
242+
# spilled portion — back into the reservoir, since no
243+
# state was actually grown.
252244
evm.output = b""
253245
evm.error = error
254246
else:
@@ -340,19 +332,11 @@ def process_message(message: Message) -> Evm:
340332
evm_trace(evm, OpException(error))
341333
evm.regular_gas_used += evm.gas_left
342334
evm.gas_left = Uint(0)
343-
# On halt, restore the state gas reservoir to what was passed
344-
# into this frame. State-gas charges in excess of the original
345-
# reservoir came from gas_left (spill) or from a child revert
346-
# refund; either way they get re-classified as regular gas
347-
# usage on halt.
348-
total_state = evm.state_gas_used + evm.state_gas_left
349-
reservoir = evm.message.state_gas_reservoir
350-
if total_state > reservoir:
351-
evm.regular_gas_used += total_state - reservoir
352-
evm.state_gas_left = reservoir
353-
evm.state_gas_used = Uint(0)
354-
evm.state_gas_refund = Uint(0)
355-
evm.state_gas_refund_pending = Uint(0)
335+
# State-gas counters preserved: parent's
336+
# incorporate_child_on_error (or tx-level error handler at
337+
# top) folds the full state-gas charge — including any
338+
# spilled portion — back into the reservoir, since no
339+
# state was actually grown.
356340
evm.output = b""
357341
evm.error = error
358342
except Revert as error:

tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_call.py

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1353,23 +1353,30 @@ def test_call_value_to_pre_existing_selfdestructed_account(
13531353
],
13541354
)
13551355
@pytest.mark.valid_from("EIP8037")
1356-
def test_top_level_halt_preserves_restored_reservoir(
1356+
def test_top_level_halt_refunds_total_state_gas(
13571357
blockchain_test: BlockchainTestFiller,
13581358
pre: Alloc,
13591359
fork: Fork,
13601360
child_termination: str,
13611361
reservoir_delta: int,
13621362
) -> None:
13631363
"""
1364-
Verify a top-level halt resets `state_gas_left` to the frame's
1365-
entry reservoir regardless of child failure mode or in-frame
1366-
drain/spill. The parent calls a child that either reverts (which
1367-
refunds the full `state_gas_used` back to the parent's reservoir)
1368-
or halts (which leaves only the reservoir-portion behind), then
1369-
the parent INVALIDs. In both child-failure paths and across all
1370-
three reservoir sizes (one short / exact / one over the child's
1371-
SSTORE cost), the top-level halt collapses to a final reservoir
1372-
equal to the original tx-level R0 — billed as `gas_limit_cap`.
1364+
Verify a top-level halt refunds the total state-gas consumed
1365+
(reservoir-portion + spilled-portion) regardless of child failure
1366+
mode. The parent calls a child that either reverts or halts, then
1367+
INVALIDs at the top level.
1368+
1369+
Per the updated EIP, both child failure modes propagate the full
1370+
`state_gas_used` back through `incorporate_child_on_error`, and
1371+
the top-level halt no longer overrides it. The tx-level error
1372+
handler then folds the residual into the reservoir, so
1373+
`state_gas_left_end = max(reservoir, child_charge)` and
1374+
`tx_gas_used = tx.gas - state_gas_left_end`:
1375+
1376+
- `reservoir < child_charge` (one_short): spill is refunded too,
1377+
`tx_gas_used = gas_limit_cap - (child_charge - reservoir)`.
1378+
- `reservoir >= child_charge`: no spill, `tx_gas_used =
1379+
gas_limit_cap`.
13731380
"""
13741381
gas_limit_cap = fork.transaction_gas_limit_cap()
13751382
assert gas_limit_cap is not None
@@ -1386,20 +1393,20 @@ def test_top_level_halt_preserves_restored_reservoir(
13861393
code=(Op.POP(Op.CALL(gas=500_000, address=child)) + Op.INVALID),
13871394
)
13881395

1396+
reservoir = sstore_state_gas + reservoir_delta
1397+
tx_gas = gas_limit_cap + reservoir
1398+
13891399
tx = Transaction(
13901400
to=parent,
1391-
gas_limit=gas_limit_cap + sstore_state_gas + reservoir_delta,
1401+
gas_limit=tx_gas,
13921402
sender=pre.fund_eoa(),
13931403
)
13941404

1395-
# Halt rule: every halted frame's (gas_left, state_gas_left) is
1396-
# reset to (0, message.state_gas_reservoir). Whatever the child
1397-
# did inside the call — drain, spill, or revert refund — is
1398-
# wiped when the parent's INVALID hits. The user's final
1399-
# reservoir is exactly the top-level R0 = sstore + delta, and
1400-
# `tx_gas_used = tx.gas - 0 - R0 = gas_limit_cap` for all six
1401-
# combinations.
1402-
expected_gas_used = gas_limit_cap
1405+
# Policy A halt: state_gas counters preserved through the child
1406+
# halt/revert, parent halt, and tx-level fold.
1407+
# state_gas_left_end = max(reservoir, sstore_state_gas).
1408+
state_gas_left_end = max(reservoir, sstore_state_gas)
1409+
expected_gas_used = tx_gas - state_gas_left_end
14031410

14041411
blockchain_test(
14051412
pre=pre,

tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_create.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
Storage,
2828
Transaction,
2929
TransactionException,
30+
TransactionReceipt,
3031
compute_create2_address,
3132
compute_create_address,
3233
)
@@ -596,6 +597,196 @@ def test_code_deposit_oog_preserves_parent_reservoir(
596597
state_test(pre=pre, post=post, tx=tx)
597598

598599

600+
@pytest.mark.parametrize(
601+
("with_reservoir", "failure_op"),
602+
[
603+
pytest.param(True, Op.REVERT(0, 0), id="with_reservoir-revert"),
604+
pytest.param(True, Op.INVALID, id="with_reservoir-halt"),
605+
pytest.param(False, Op.REVERT(0, 0), id="no_reservoir-revert"),
606+
pytest.param(False, Op.INVALID, id="no_reservoir-halt"),
607+
],
608+
)
609+
@pytest.mark.valid_from("EIP8037")
610+
def test_parent_state_gas_after_child_failure(
611+
state_test: StateTestFiller,
612+
pre: Alloc,
613+
fork: Fork,
614+
with_reservoir: bool,
615+
failure_op: Bytecode,
616+
) -> None:
617+
"""
618+
Test parent state-gas pools after CREATE child failure.
619+
620+
A factory invokes CREATE whose initcode performs an SSTORE
621+
(charging state gas) then either REVERTs or hits INVALID. The
622+
factory's own SSTORE after the failed CREATE acts as the
623+
discriminator that the parent's state-gas accounting (reservoir
624+
and gas_left) is in the expected state.
625+
626+
Four scenarios cover the gas-pool state space:
627+
628+
- `with_reservoir x revert`: child state gas (new account +
629+
initcode SSTORE) is fully refunded to the parent reservoir on
630+
REVERT.
631+
- `with_reservoir x halt`: HALT resets the child frame to
632+
`(0, R0_child)`; only the reservoir-portion entering the
633+
initcode is returned, any spilled gas stays burned.
634+
- `no_reservoir x revert`: child state gas refunded forms a
635+
fresh reservoir even though `R0_parent` started at 0.
636+
- `no_reservoir x halt`: no phantom reservoir may form; the
637+
factory's post-CREATE SSTORE must spill from gas_left.
638+
"""
639+
gas_limit_cap = fork.transaction_gas_limit_cap()
640+
assert gas_limit_cap is not None
641+
gas_costs = fork.gas_costs()
642+
intrinsic_cost = fork.transaction_intrinsic_cost_calculator()()
643+
sstore_state_gas = fork.sstore_state_gas()
644+
new_account_state_gas = gas_costs.NEW_ACCOUNT
645+
646+
initcode = Op.SSTORE(0, 1, original_value=0, new_value=1) + failure_op
647+
648+
factory_storage = Storage()
649+
factory_code = (
650+
Op.MSTORE(0, Op.PUSH32(bytes(initcode)))
651+
+ Op.SSTORE(
652+
factory_storage.store_next(0, "create_fails"),
653+
Op.CREATE(
654+
value=0,
655+
offset=32 - len(initcode),
656+
size=len(initcode),
657+
),
658+
original_value=0,
659+
new_value=0,
660+
)
661+
+ Op.SSTORE(
662+
factory_storage.store_next(1, "post_create"),
663+
1,
664+
original_value=0,
665+
new_value=1,
666+
)
667+
)
668+
factory = pre.deploy_contract(code=factory_code)
669+
670+
gas_limit = (
671+
gas_limit_cap + new_account_state_gas + sstore_state_gas * 2
672+
if with_reservoir
673+
else 5_000_000
674+
)
675+
676+
# `bytecode.gas_cost(fork)` accounts for opcode base costs and
677+
# state-gas charges, but does NOT track memory-expansion or CREATE
678+
# init-code word costs. Add those back to recover runtime regular
679+
# gas consumption.
680+
init_code_word_count = (len(initcode) + 31) // 32
681+
init_code_word_cost = gas_costs.CODE_INIT_PER_WORD * init_code_word_count
682+
mstore_memory_expansion = gas_costs.MEMORY_PER_WORD # 1 word
683+
gas_cost_helper_extras = init_code_word_cost + mstore_memory_expansion
684+
685+
# Factory bytecode shape costs, derived from fork.gas_costs():
686+
# pre-CREATE: PUSH32 + PUSH1 + MSTORE (with 1-word expansion)
687+
# + 3 PUSHes for CREATE inputs
688+
# post-CREATE: PUSH key + SSTORE (no-op) + 2 PUSHes + SSTORE
689+
# (zero-to-nonzero regular)
690+
factory_pre_create_regular = (
691+
gas_costs.VERY_LOW * 2
692+
+ gas_costs.OPCODE_MSTORE_BASE
693+
+ mstore_memory_expansion
694+
+ gas_costs.VERY_LOW * 3
695+
)
696+
factory_post_create_regular = (
697+
gas_costs.VERY_LOW
698+
+ gas_costs.COLD_STORAGE_ACCESS
699+
+ gas_costs.WARM_ACCESS
700+
+ gas_costs.VERY_LOW * 2
701+
+ gas_costs.COLD_STORAGE_WRITE
702+
)
703+
704+
factory_regular = (
705+
factory_code.gas_cost(fork)
706+
- new_account_state_gas
707+
- sstore_state_gas
708+
+ gas_cost_helper_extras
709+
)
710+
initcode_regular_revert = initcode.gas_cost(fork) - sstore_state_gas
711+
712+
if failure_op == Op.INVALID:
713+
# Simulate runtime gas accounting for HALT using fork helpers:
714+
# 1. Initial regular pool capped by transaction_gas_limit_cap;
715+
# remainder forms the state reservoir.
716+
# 2. CREATE op charges new_account state gas (from reservoir
717+
# first, spilled to gas_left otherwise).
718+
# 3. 63/64 retention rule: parent retains gas_left // 64.
719+
# 4. INVALID burns all forwarded regular gas in the child.
720+
# Per the updated EIP, child halt preserves its state-gas
721+
# counters and `incorporate_child_on_error` refunds the
722+
# full child charge — including any spilled portion — to
723+
# the parent's reservoir.
724+
# 5. CREATE failure refunds new_account state gas to the
725+
# parent's state pool (account creation rolled back).
726+
# 6. Factory's post-CREATE SSTORE charges sstore_state_gas
727+
# (state pool first, spilled to gas_left otherwise).
728+
execution_gas = gas_limit - intrinsic_cost
729+
regular_budget = gas_limit_cap - intrinsic_cost
730+
sim_gas_left = min(regular_budget, execution_gas)
731+
sim_state_gas_left = execution_gas - sim_gas_left
732+
733+
sim_gas_left -= factory_pre_create_regular
734+
sim_gas_left -= gas_costs.OPCODE_CREATE_BASE + init_code_word_cost
735+
736+
if sim_state_gas_left >= new_account_state_gas:
737+
sim_state_gas_left -= new_account_state_gas
738+
else:
739+
sim_gas_left -= new_account_state_gas - sim_state_gas_left
740+
sim_state_gas_left = 0
741+
742+
# `child_reservoir` is what the parent forwards to the child.
743+
# Under Policy A halt, incorporate refunds child.state_gas_used
744+
# + child.state_gas_left = max(sstore, child_reservoir) back to
745+
# the parent. The simulator already implicitly retains
746+
# `child_reservoir` in `sim_state_gas_left`, so the additional
747+
# Policy A refund versus the Policy B "burn the spill" rule is
748+
# `max(0, sstore_state_gas - child_reservoir)`.
749+
child_reservoir = sim_state_gas_left
750+
sim_gas_left = sim_gas_left // 64
751+
sim_state_gas_left += max(0, sstore_state_gas - child_reservoir)
752+
sim_state_gas_left += new_account_state_gas
753+
754+
sim_gas_left -= factory_post_create_regular
755+
756+
if sim_state_gas_left >= sstore_state_gas:
757+
sim_state_gas_left -= sstore_state_gas
758+
else:
759+
sim_gas_left -= sstore_state_gas - sim_state_gas_left
760+
sim_state_gas_left = 0
761+
762+
expected_cumulative = gas_limit - sim_gas_left - sim_state_gas_left
763+
else:
764+
# REVERT preserves gas_left and refunds the child frame's
765+
# state gas (initcode SSTORE + new account). Only the
766+
# factory's own post-CREATE SSTORE consumes net state gas.
767+
expected_cumulative = (
768+
intrinsic_cost
769+
+ factory_regular
770+
+ initcode_regular_revert
771+
+ sstore_state_gas
772+
)
773+
774+
tx = Transaction(
775+
to=factory,
776+
gas_limit=gas_limit,
777+
sender=pre.fund_eoa(),
778+
expected_receipt=TransactionReceipt(
779+
cumulative_gas_used=expected_cumulative,
780+
),
781+
)
782+
783+
state_test(
784+
pre=pre,
785+
post={factory: Account(storage=factory_storage)},
786+
tx=tx,
787+
)
788+
789+
599790
@pytest.mark.valid_from("EIP8037")
600791
def test_nested_create_code_deposit_cannot_borrow_parent_gas(
601792
state_test: StateTestFiller,

0 commit comments

Comments
 (0)