Skip to content

Commit a23c650

Browse files
authored
feat(spec-specs,tests): EIP-8037 nested child frame refunds (#2733)
1 parent 1766f3a commit a23c650

5 files changed

Lines changed: 236 additions & 37 deletions

File tree

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

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -174,12 +174,44 @@ class Evm:
174174
regular_gas_used: Uint = Uint(0)
175175
state_gas_used: Uint = Uint(0)
176176
state_gas_refund: Uint = Uint(0)
177+
state_gas_refund_pending: Uint = Uint(0)
178+
179+
180+
def credit_state_gas_refund(evm: Evm, amount: Uint) -> None:
181+
"""
182+
Credit an inline state gas refund to `evm.state_gas_left`.
183+
184+
Clamp the applied portion to this frame's `state_gas_used` — the
185+
matching charge may sit in an ancestor sharing storage via
186+
CALLCODE/DELEGATECALL. Track it in `state_gas_refund` so
187+
`incorporate_child_on_error` can undo the inflation, and defer the
188+
unapplied remainder in `state_gas_refund_pending` for propagation
189+
on success.
190+
191+
Parameters
192+
----------
193+
evm :
194+
The frame crediting the refund.
195+
amount :
196+
The refund amount to credit.
197+
198+
"""
199+
applied = min(amount, evm.state_gas_used)
200+
evm.state_gas_left += applied
201+
evm.state_gas_used -= applied
202+
evm.state_gas_refund += applied
203+
evm.state_gas_refund_pending += amount - applied
177204

178205

179206
def incorporate_child_on_success(evm: Evm, child_evm: Evm) -> None:
180207
"""
181208
Incorporate the state of a successful `child_evm` into the parent `evm`.
182209
210+
Propagate `state_gas_refund` (inline credits the child applied) so
211+
an ancestor revert can undo the inflation, and apply
212+
`state_gas_refund_pending` (the unapplied remainder) to the parent
213+
via `credit_state_gas_refund`; any leftover propagates further up.
214+
183215
Parameters
184216
----------
185217
evm :
@@ -198,6 +230,7 @@ def incorporate_child_on_success(evm: Evm, child_evm: Evm) -> None:
198230
evm.regular_gas_used += child_evm.regular_gas_used
199231
evm.state_gas_used += child_evm.state_gas_used
200232
evm.state_gas_refund += child_evm.state_gas_refund
233+
credit_state_gas_refund(evm, child_evm.state_gas_refund_pending)
201234

202235

203236
def incorporate_child_on_error(
@@ -212,11 +245,11 @@ def incorporate_child_on_error(
212245
that spilled into `gas_left`, is restored to the parent's reservoir and
213246
the child's `state_gas_used` is not accumulated.
214247
215-
Inline state-gas refunds (SSTORE 0 to x to 0) accumulated in the child or
216-
its successful descendants are dropped: `state_gas_refund` is subtracted
217-
from the amount returned to the parent's reservoir and is not propagated.
218-
This matches `refund_counter`'s error-path behavior and keeps the refund
219-
frame-scoped.
248+
Inline state-gas refunds (SSTORE 0 to x to 0, CREATE silent failure)
249+
credited by the child inflated its `state_gas_left`; subtract
250+
`state_gas_refund` from the amount returned to the parent's
251+
reservoir so the inflation does not leak across the error boundary.
252+
`state_gas_refund_pending` is discarded with the child frame.
220253
221254
Parameters
222255
----------

src/ethereum/forks/amsterdam/vm/instructions/storage.py

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
set_storage,
2121
set_transient_storage,
2222
)
23-
from .. import Evm
23+
from .. import Evm, credit_state_gas_refund
2424
from ..exceptions import WriteInStaticContext
2525
from ..gas import (
2626
GAS_CALL_STIPEND,
@@ -127,28 +127,11 @@ def sstore(evm: Evm) -> None:
127127
if original_value == new_value:
128128
# Storage slot being restored to its original value
129129
if original_value == 0:
130-
# Slot set then cleared: credit refund, clamped to this
131-
# frame's state_gas_used since the 0 to N SSTORE may
132-
# have charged state gas in an ancestor sharing storage
133-
# via CALLCODE/DELEGATECALL.
134-
state_gas_refund_applied = min(
135-
state_gas_storage_set, evm.state_gas_used
136-
)
137-
evm.state_gas_left += state_gas_refund_applied
138-
evm.state_gas_used -= state_gas_refund_applied
139-
evm.state_gas_refund += state_gas_storage_set
140-
evm.refund_counter += int(
141-
GAS_STORAGE_UPDATE
142-
- GAS_COLD_STORAGE_ACCESS
143-
- GAS_WARM_ACCESS
144-
)
145-
else:
146-
# Slot was originally non-empty and was UPDATED earlier
147-
evm.refund_counter += int(
148-
GAS_STORAGE_UPDATE
149-
- GAS_COLD_STORAGE_ACCESS
150-
- GAS_WARM_ACCESS
151-
)
130+
# Slot set then cleared: refund the state gas charge.
131+
credit_state_gas_refund(evm, state_gas_storage_set)
132+
evm.refund_counter += int(
133+
GAS_STORAGE_UPDATE - GAS_COLD_STORAGE_ACCESS - GAS_WARM_ACCESS
134+
)
152135

153136
# Charge regular gas before state gas so that a regular-gas OOG
154137
# does not consume state gas that would inflate the parent's

src/ethereum/forks/amsterdam/vm/instructions/system.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from .. import (
3939
Evm,
4040
Message,
41+
credit_state_gas_refund,
4142
incorporate_child_on_error,
4243
incorporate_child_on_success,
4344
)
@@ -122,9 +123,7 @@ def generic_create(
122123
evm.gas_left += create_message_gas
123124
evm.state_gas_left += create_message_state_gas_reservoir
124125
# No account created — refund state gas to reservoir.
125-
evm.state_gas_left += create_account_state_gas
126-
evm.state_gas_used -= create_account_state_gas
127-
evm.state_gas_refund += create_account_state_gas
126+
credit_state_gas_refund(evm, create_account_state_gas)
128127
push(evm.stack, U256(0))
129128
return
130129

@@ -137,9 +136,7 @@ def generic_create(
137136
evm.regular_gas_used += create_message_gas
138137
evm.state_gas_left += create_message_state_gas_reservoir
139138
# Address collision — no account created, refund state gas.
140-
evm.state_gas_left += create_account_state_gas
141-
evm.state_gas_used -= create_account_state_gas
142-
evm.state_gas_refund += create_account_state_gas
139+
credit_state_gas_refund(evm, create_account_state_gas)
143140
push(evm.stack, U256(0))
144141
return
145142

@@ -170,9 +167,7 @@ def generic_create(
170167
if child_evm.error:
171168
incorporate_child_on_error(evm, child_evm)
172169
# No account created, refund parent's CREATE state gas.
173-
evm.state_gas_left += create_account_state_gas
174-
evm.state_gas_used -= create_account_state_gas
175-
evm.state_gas_refund += create_account_state_gas
170+
credit_state_gas_refund(evm, create_account_state_gas)
176171
evm.return_data = child_evm.output
177172
push(evm.stack, U256(0))
178173
else:

tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_selfdestruct.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,3 +528,109 @@ def test_selfdestruct_pre_existing_account_no_refund(
528528
blocks=[Block(txs=[tx], header_verify=Header(gas_used=tx_regular))],
529529
post={victim: Account(code=victim_code)},
530530
)
531+
532+
533+
@pytest.mark.parametrize(
534+
"num_hops",
535+
[
536+
pytest.param(1, id="single_hop"),
537+
pytest.param(2, id="two_hops"),
538+
],
539+
)
540+
@pytest.mark.with_all_call_opcodes(
541+
selector=lambda call_opcode: call_opcode in (Op.DELEGATECALL, Op.CALLCODE)
542+
)
543+
@pytest.mark.valid_from("EIP8037")
544+
def test_selfdestruct_via_delegatecall_chain(
545+
blockchain_test: BlockchainTestFiller,
546+
pre: Alloc,
547+
fork: Fork,
548+
num_hops: int,
549+
call_opcode: Op,
550+
) -> None:
551+
"""
552+
Verify SELFDESTRUCT refund when the opcode executes in a nested
553+
DELEGATECALL/CALLCODE frame below a same-tx-created contract.
554+
555+
A factory CREATEs contract A; A delegates down `num_hops` frames
556+
into a helper that runs SELFDESTRUCT(Op.ADDRESS). `current_target`
557+
is preserved by DELEGATECALL/CALLCODE, so A is queued for deletion
558+
and its account + code-deposit state gas is refunded at tx end.
559+
Exercises `accounts_to_delete` propagation across multiple
560+
`incorporate_child_on_success` hops.
561+
"""
562+
gas_limit_cap = fork.transaction_gas_limit_cap()
563+
assert gas_limit_cap is not None
564+
new_account_state_gas = fork.gas_costs().GAS_NEW_ACCOUNT
565+
sstore_state_gas = fork.sstore_state_gas()
566+
567+
# Bottom of the chain does the SELFDESTRUCT; intermediate helpers
568+
# just delegate further down.
569+
delegate_target = pre.deploy_contract(code=Op.SELFDESTRUCT(Op.ADDRESS))
570+
for _ in range(num_hops - 1):
571+
delegate_target = pre.deploy_contract(
572+
code=Op.POP(call_opcode(gas=Op.GAS, address=delegate_target))
573+
+ Op.STOP,
574+
)
575+
576+
# A's deployed runtime: one delegation into the top of the chain.
577+
deployed = bytes(
578+
Op.POP(call_opcode(gas=Op.GAS, address=delegate_target)) + Op.STOP
579+
)
580+
code_deposit_state_gas = fork.code_deposit_state_gas(
581+
code_size=len(deployed)
582+
)
583+
initcode = Initcode(deploy_code=deployed)
584+
initcode_len = len(initcode)
585+
586+
# Slots 0 and 1 guard against a vacuously-NONEXISTENT A: slot 0
587+
# fails if CREATE silently returned 0, slot 1 fails if the factory
588+
# OOGed before completing the nested CALL. TSTORE caches the
589+
# CREATE return so both can reuse it.
590+
factory_storage = Storage()
591+
factory_code = (
592+
Op.CALLDATACOPY(
593+
0,
594+
0,
595+
Op.CALLDATASIZE,
596+
data_size=initcode_len,
597+
new_memory_size=initcode_len,
598+
)
599+
+ Op.TSTORE(
600+
0,
601+
Op.CREATE(
602+
value=0,
603+
offset=0,
604+
size=Op.CALLDATASIZE,
605+
init_code_size=initcode_len,
606+
),
607+
)
608+
+ Op.SSTORE(
609+
factory_storage.store_next(1, "create_returned_nonzero"),
610+
Op.ISZERO(Op.ISZERO(Op.TLOAD(0))),
611+
)
612+
+ Op.SSTORE(
613+
factory_storage.store_next(1, "call_returned_success"),
614+
Op.CALL(gas=Op.GAS, address=Op.TLOAD(0)),
615+
)
616+
)
617+
factory = pre.deploy_contract(code=factory_code)
618+
created_address = compute_create_address(address=factory, nonce=1)
619+
620+
# Reservoir must also cover the two fresh SSTORE markers.
621+
total_state_refund = new_account_state_gas + code_deposit_state_gas
622+
tx = Transaction(
623+
to=factory,
624+
data=bytes(initcode),
625+
gas_limit=gas_limit_cap + total_state_refund + 2 * sstore_state_gas,
626+
sender=pre.fund_eoa(),
627+
)
628+
629+
blockchain_test(
630+
pre=pre,
631+
blocks=[Block(txs=[tx])],
632+
post={
633+
created_address: Account.NONEXISTENT,
634+
factory: Account(storage=factory_storage),
635+
},
636+
)

tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_sstore.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -735,6 +735,88 @@ def test_sstore_restoration_cross_frame(
735735
)
736736

737737

738+
@pytest.mark.parametrize(
739+
"num_hops",
740+
[
741+
pytest.param(1, id="single_hop"),
742+
pytest.param(2, id="two_hops"),
743+
pytest.param(3, id="three_hops"),
744+
],
745+
)
746+
@pytest.mark.with_all_call_opcodes(
747+
selector=lambda call_opcode: call_opcode in (Op.DELEGATECALL, Op.CALLCODE)
748+
)
749+
@pytest.mark.valid_from("EIP8037")
750+
def test_sstore_restoration_charge_in_ancestor(
751+
state_test: StateTestFiller,
752+
pre: Alloc,
753+
fork: Fork,
754+
call_opcode: Op,
755+
num_hops: int,
756+
) -> None:
757+
"""
758+
Verify 0 to x to 0 refund when the 0 to x charge is in the parent
759+
and x to 0 runs `num_hops` DELEGATECALL/CALLCODE frames below,
760+
each sharing storage with the parent.
761+
762+
Every intermediate frame has zero local `state_gas_used`, so the
763+
refund must propagate up the chain to the ancestor that charged
764+
the 0 to x. A probe SSTORE sized to OOG by 1 detects any loss.
765+
"""
766+
gas_limit_cap = fork.transaction_gas_limit_cap()
767+
assert gas_limit_cap is not None
768+
gas_costs = fork.gas_costs()
769+
sstore_state_gas = fork.sstore_state_gas()
770+
probe_gas = (
771+
2 * gas_costs.GAS_VERY_LOW
772+
+ gas_costs.GAS_COLD_STORAGE_WRITE
773+
+ sstore_state_gas
774+
- 1
775+
)
776+
777+
# Innermost frame does x to 0; each hop above delegates down.
778+
delegate_target = pre.deploy_contract(
779+
code=(
780+
Op.SSTORE.with_metadata(
781+
key_warm=True,
782+
original_value=0,
783+
current_value=1,
784+
new_value=0,
785+
)(0, 0)
786+
+ Op.STOP
787+
)
788+
)
789+
for _ in range(num_hops - 1):
790+
delegate_target = pre.deploy_contract(
791+
code=Op.POP(call_opcode(gas=Op.GAS, address=delegate_target))
792+
+ Op.STOP,
793+
)
794+
795+
probe = pre.deploy_contract(code=Op.SSTORE(0, 1))
796+
797+
parent_storage = Storage()
798+
parent_code = (
799+
Op.SSTORE(parent_storage.store_next(0, "cycle_restored"), 1)
800+
+ Op.POP(call_opcode(gas=Op.GAS, address=delegate_target))
801+
+ Op.SSTORE(
802+
parent_storage.store_next(1, "probe_must_succeed"),
803+
Op.CALL(gas=probe_gas, address=probe),
804+
)
805+
)
806+
parent = pre.deploy_contract(code=parent_code)
807+
808+
# Reservoir starts at exactly sstore_state_gas; the parent's 0 to 1
809+
# drains it to zero before entering the delegation chain.
810+
tx = Transaction(
811+
sender=pre.fund_eoa(),
812+
to=parent,
813+
gas_limit=gas_limit_cap + sstore_state_gas,
814+
)
815+
816+
post = {parent: Account(storage=parent_storage)}
817+
state_test(pre=pre, tx=tx, post=post)
818+
819+
738820
@pytest.mark.with_all_call_opcodes(
739821
selector=lambda call_opcode: call_opcode != Op.STATICCALL
740822
)

0 commit comments

Comments
 (0)