Skip to content

Commit d4ad6a3

Browse files
kclowesspencer-tb
authored andcommitted
feat(spec-specs, tests): EIP-8037 - zero execution state gas on top-level failure (#2689)
Co-authored-by: spencer-tb <spencer.tb@ethereum.org>
1 parent 8aca162 commit d4ad6a3

4 files changed

Lines changed: 339 additions & 6 deletions

File tree

src/ethereum/forks/amsterdam/fork.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,6 +1050,10 @@ def process_transaction(
10501050

10511051
tx_output = process_message_call(message)
10521052

1053+
if tx_output.error is not None:
1054+
tx_output.state_gas_left += tx_output.state_gas_used
1055+
tx_output.state_gas_used = Uint(0)
1056+
10531057
tx_gas_used_before_refund = (
10541058
tx.gas - tx_output.gas_left - tx_output.state_gas_left
10551059
)

tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_reservoir.py

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
Storage,
2929
Transaction,
3030
TransactionException,
31+
TransactionReceipt,
3132
)
3233
from execution_testing.checklists import EIPChecklist
3334

@@ -415,3 +416,319 @@ def test_create_tx_reservoir(
415416
)
416417

417418
state_test(env=env, pre=pre, post={}, tx=tx)
419+
420+
421+
@pytest.mark.parametrize(
422+
"failure_mode",
423+
[
424+
pytest.param("revert", id="revert"),
425+
pytest.param("halt", id="halt"),
426+
pytest.param("oog", id="oog"),
427+
],
428+
)
429+
@pytest.mark.valid_from("EIP8037")
430+
def test_top_level_failure_refunds_execution_state_gas(
431+
state_test: StateTestFiller,
432+
pre: Alloc,
433+
fork: Fork,
434+
failure_mode: str,
435+
) -> None:
436+
"""
437+
Verify top level tx failure returns execution state gas to the
438+
reservoir across revert, exceptional halt, and out of gas paths.
439+
440+
On top level failure no state was created, so execution state gas
441+
is credited back to the reservoir and `state_gas_used` is zeroed.
442+
The billing formula `tx.gas - gas_left - state_gas_left` sees a
443+
restored reservoir and refunds the sender. Without the refund the
444+
receipt would bill the consumed state gas despite the failure.
445+
"""
446+
gas_limit_cap = fork.transaction_gas_limit_cap()
447+
assert gas_limit_cap is not None
448+
sstore_state_gas = fork.sstore_state_gas()
449+
intrinsic_cost = fork.transaction_intrinsic_cost_calculator()()
450+
451+
if failure_mode == "revert":
452+
code = Op.SSTORE(0, 1) + Op.REVERT(0, 0)
453+
elif failure_mode == "halt":
454+
code = Op.SSTORE(0, 1) + Op.INVALID
455+
else:
456+
# OOG: perform the SSTORE then spin with JUMPDEST loop until
457+
# gas runs out.
458+
code = Op.SSTORE(0, 1) + Op.JUMPDEST + Op.JUMP(0x5)
459+
contract = pre.deploy_contract(code=code)
460+
461+
tx_gas = gas_limit_cap + sstore_state_gas
462+
463+
if failure_mode == "revert":
464+
# REVERT preserves unused gas_left.
465+
expected_cumulative = (
466+
intrinsic_cost + code.gas_cost(fork) - sstore_state_gas
467+
)
468+
else:
469+
# Exceptional halt and out of gas zero gas_left.
470+
expected_cumulative = tx_gas - sstore_state_gas
471+
472+
tx = Transaction(
473+
to=contract,
474+
gas_limit=tx_gas,
475+
sender=pre.fund_eoa(),
476+
expected_receipt=TransactionReceipt(
477+
cumulative_gas_used=expected_cumulative,
478+
),
479+
)
480+
481+
state_test(pre=pre, post={contract: Account(storage={})}, tx=tx)
482+
483+
484+
@pytest.mark.parametrize(
485+
"failure_mode",
486+
[
487+
pytest.param("revert", id="revert"),
488+
pytest.param("halt", id="halt"),
489+
pytest.param("oog", id="oog"),
490+
],
491+
)
492+
@pytest.mark.valid_from("EIP8037")
493+
def test_top_level_failure_zeros_block_state_gas(
494+
blockchain_test: BlockchainTestFiller,
495+
pre: Alloc,
496+
fork: Fork,
497+
failure_mode: str,
498+
) -> None:
499+
"""
500+
Verify the block header reflects zero execution state gas after a
501+
top level failure.
502+
503+
With `state_gas_used` zeroed on failure, `block_state_gas_used`
504+
excludes any state gas consumed during the failed transaction and
505+
the block header `gas_used` falls back to the regular gas
506+
component alone.
507+
"""
508+
gas_limit_cap = fork.transaction_gas_limit_cap()
509+
assert gas_limit_cap is not None
510+
sstore_state_gas = fork.sstore_state_gas()
511+
intrinsic_cost = fork.transaction_intrinsic_cost_calculator()()
512+
513+
if failure_mode == "revert":
514+
code = Op.SSTORE(0, 1) + Op.REVERT(0, 0)
515+
elif failure_mode == "halt":
516+
code = Op.SSTORE(0, 1) + Op.INVALID
517+
else:
518+
code = Op.SSTORE(0, 1) + Op.JUMPDEST + Op.JUMP(0x5)
519+
contract = pre.deploy_contract(code=code)
520+
521+
tx_gas = gas_limit_cap + sstore_state_gas
522+
tx = Transaction(
523+
to=contract,
524+
gas_limit=tx_gas,
525+
sender=pre.fund_eoa(),
526+
)
527+
528+
if failure_mode == "revert":
529+
expected_block_regular = (
530+
intrinsic_cost + code.gas_cost(fork) - sstore_state_gas
531+
)
532+
else:
533+
# Exceptional halt and out of gas zero gas_left.
534+
expected_block_regular = tx_gas - sstore_state_gas
535+
536+
blockchain_test(
537+
pre=pre,
538+
blocks=[
539+
Block(
540+
txs=[tx],
541+
header_verify=Header(gas_used=expected_block_regular),
542+
),
543+
],
544+
post={contract: Account(storage={})},
545+
)
546+
547+
548+
@pytest.mark.valid_from("EIP8037")
549+
def test_creation_tx_failure_preserves_intrinsic_state_gas(
550+
blockchain_test: BlockchainTestFiller,
551+
pre: Alloc,
552+
fork: Fork,
553+
) -> None:
554+
"""
555+
Regression test for the creation tx failure path.
556+
557+
A creation tx (to=None) whose initcode halts exercises both the
558+
intrinsic state gas for the new account and the top level failure
559+
refund of execution state gas. The test asserts the block header
560+
`gas_used` equals `max(block_regular, intrinsic_state_gas)`,
561+
guarding that the failure path does not raise and that block
562+
accounting does not underflow when the refund is applied.
563+
"""
564+
gas_limit_cap = fork.transaction_gas_limit_cap()
565+
assert gas_limit_cap is not None
566+
567+
create_intrinsic_state = fork.transaction_intrinsic_state_gas(
568+
contract_creation=True,
569+
)
570+
sstore_state_gas = fork.sstore_state_gas()
571+
tx_gas = gas_limit_cap + create_intrinsic_state + sstore_state_gas
572+
573+
tx = Transaction(
574+
to=None,
575+
data=Op.SSTORE(0, 1) + Op.INVALID,
576+
gas_limit=tx_gas,
577+
sender=pre.fund_eoa(),
578+
)
579+
580+
block_regular = tx_gas - create_intrinsic_state - sstore_state_gas
581+
expected_gas_used = max(block_regular, create_intrinsic_state)
582+
583+
blockchain_test(
584+
pre=pre,
585+
blocks=[
586+
Block(
587+
txs=[tx],
588+
header_verify=Header(gas_used=expected_gas_used),
589+
),
590+
],
591+
post={},
592+
)
593+
594+
595+
@pytest.mark.valid_from("EIP8037")
596+
def test_subcall_failure_does_not_zero_top_level_state_gas(
597+
blockchain_test: BlockchainTestFiller,
598+
pre: Alloc,
599+
fork: Fork,
600+
) -> None:
601+
"""
602+
Verify a subcall failure does not zero the top level execution
603+
state gas.
604+
605+
The top level tx succeeds end to end even though a subcall
606+
reverts, so the top level failure refund does not apply. The
607+
parent's own SSTORE contributes state gas that appears in
608+
`block_state_gas_used`.
609+
"""
610+
gas_limit_cap = fork.transaction_gas_limit_cap()
611+
assert gas_limit_cap is not None
612+
sstore_state_gas = fork.sstore_state_gas()
613+
614+
child = pre.deploy_contract(code=Op.REVERT(0, 0))
615+
parent_storage = Storage()
616+
parent = pre.deploy_contract(
617+
code=(
618+
Op.POP(Op.CALL(gas=Op.GAS, address=child))
619+
+ Op.SSTORE(parent_storage.store_next(1, "parent_sstore"), 1)
620+
),
621+
)
622+
623+
tx = Transaction(
624+
to=parent,
625+
gas_limit=gas_limit_cap + sstore_state_gas,
626+
sender=pre.fund_eoa(),
627+
)
628+
629+
# Parent's SSTORE state gas dominates tx_regular and surfaces in
630+
# the block header, proving the top level refund is scoped to
631+
# top level failures and not child reverts.
632+
blockchain_test(
633+
pre=pre,
634+
blocks=[
635+
Block(
636+
txs=[tx],
637+
header_verify=Header(gas_used=sstore_state_gas),
638+
),
639+
],
640+
post={parent: Account(storage=parent_storage)},
641+
)
642+
643+
644+
@pytest.mark.valid_from("EIP8037")
645+
def test_top_level_failure_refunds_spilled_state_gas(
646+
state_test: StateTestFiller,
647+
pre: Alloc,
648+
fork: Fork,
649+
) -> None:
650+
"""
651+
Verify the top level failure refund covers state gas that
652+
spilled from the reservoir into gas_left.
653+
654+
When the reservoir is smaller than the state gas charge, the
655+
overflow spills and is drawn from gas_left. On top level failure
656+
the full consumed state gas (reservoir portion plus spilled
657+
portion) is credited back to the reservoir so the sender is not
658+
billed for any of it.
659+
"""
660+
gas_limit_cap = fork.transaction_gas_limit_cap()
661+
assert gas_limit_cap is not None
662+
sstore_state_gas = fork.sstore_state_gas()
663+
intrinsic_cost = fork.transaction_intrinsic_cost_calculator()()
664+
665+
code = Op.SSTORE(0, 1) + Op.REVERT(0, 0)
666+
contract = pre.deploy_contract(code=code)
667+
668+
# Reservoir sized to cover only half the SSTORE state gas; the
669+
# other half must spill into gas_left.
670+
tx_gas = gas_limit_cap + sstore_state_gas // 2
671+
expected_cumulative = (
672+
intrinsic_cost + code.gas_cost(fork) - sstore_state_gas
673+
)
674+
675+
tx = Transaction(
676+
to=contract,
677+
gas_limit=tx_gas,
678+
sender=pre.fund_eoa(),
679+
expected_receipt=TransactionReceipt(
680+
cumulative_gas_used=expected_cumulative,
681+
),
682+
)
683+
684+
state_test(pre=pre, post={contract: Account(storage={})}, tx=tx)
685+
686+
687+
@pytest.mark.valid_from("EIP8037")
688+
def test_top_level_failure_refunds_state_gas_propagated_from_child(
689+
state_test: StateTestFiller,
690+
pre: Alloc,
691+
fork: Fork,
692+
) -> None:
693+
"""
694+
Verify the top level failure refund catches state gas propagated
695+
from a successful subcall.
696+
697+
The parent calls a child that runs SSTORE and returns. The
698+
child's state gas usage is folded into the parent frame via the
699+
success path. When the parent then reverts at the top level, the
700+
full propagated state gas must be refunded so the sender fee
701+
excludes it.
702+
"""
703+
gas_limit_cap = fork.transaction_gas_limit_cap()
704+
assert gas_limit_cap is not None
705+
sstore_state_gas = fork.sstore_state_gas()
706+
intrinsic_cost = fork.transaction_intrinsic_cost_calculator()()
707+
708+
child_code = Op.SSTORE(0, 1)
709+
child = pre.deploy_contract(code=child_code)
710+
parent_code = Op.POP(Op.CALL(gas=Op.GAS, address=child)) + Op.REVERT(0, 0)
711+
parent = pre.deploy_contract(code=parent_code)
712+
713+
# Reservoir sized for the child's SSTORE. After the propagated
714+
# state gas is refunded, the sender is billed only the regular
715+
# gas: parent + CALL dispatch + child regular (SSTORE minus its
716+
# state component).
717+
tx_gas = gas_limit_cap + sstore_state_gas
718+
expected_cumulative = (
719+
intrinsic_cost
720+
+ parent_code.gas_cost(fork)
721+
+ child_code.gas_cost(fork)
722+
- sstore_state_gas
723+
)
724+
725+
tx = Transaction(
726+
to=parent,
727+
gas_limit=tx_gas,
728+
sender=pre.fund_eoa(),
729+
expected_receipt=TransactionReceipt(
730+
cumulative_gas_used=expected_cumulative,
731+
),
732+
)
733+
734+
state_test(pre=pre, post={child: Account(storage={})}, tx=tx)

tests/cancun/create/test_create_oog_from_eoa_refunds.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -326,12 +326,16 @@ def test_create_oog_from_eoa_refunds(
326326
)
327327
post[sender] = Account(nonce=1)
328328
else:
329-
# OOG case: contract not created, sender balance is fully consumed
329+
# OOG case: contract not created
330330
post[created_address] = Account.NONEXISTENT
331-
post[sender] = Account(
332-
nonce=1,
333-
balance=0,
334-
)
331+
if fork.is_eip_enabled(8037):
332+
# EIP-8037: execution state gas is returned to the
333+
# reservoir on top-level failure, so the sender retains
334+
# some balance (the refunded state gas × gas_price).
335+
post[sender] = Account(nonce=1)
336+
else:
337+
# Pre-EIP-8037: sender balance is fully consumed
338+
post[sender] = Account(nonce=1, balance=0)
335339

336340
if refund_type == RefundType.SELFDESTRUCT:
337341
selfdestruct_code = Op.SELFDESTRUCT(Op.ORIGIN) + Op.STOP

tests/cancun/eip5656_mcopy/test_mcopy_memory_expansion.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,14 +128,22 @@ def tx( # noqa: D103
128128
initial_memory: bytes,
129129
tx_gas_limit: int,
130130
tx_access_list: List[AccessList],
131+
fork: Fork,
132+
successful: bool,
131133
) -> Transaction:
134+
# EIP-8037: on top-level OOG, execution state gas is returned to the
135+
# reservoir and not billed. The callee's SSTORE contributes state
136+
# gas that gets refunded on failure.
137+
expected_gas = tx_gas_limit
138+
if not successful and fork.is_eip_enabled(8037):
139+
expected_gas -= fork.sstore_state_gas()
132140
return Transaction(
133141
sender=sender,
134142
to=caller_address,
135143
access_list=tx_access_list,
136144
data=initial_memory,
137145
gas_limit=tx_gas_limit,
138-
expected_receipt=TransactionReceipt(cumulative_gas_used=tx_gas_limit),
146+
expected_receipt=TransactionReceipt(cumulative_gas_used=expected_gas),
139147
)
140148

141149

0 commit comments

Comments
 (0)