diff --git a/docs/writing_tests/post_mortems.md b/docs/writing_tests/post_mortems.md index 6e51f3aa7b..91a4961268 100644 --- a/docs/writing_tests/post_mortems.md +++ b/docs/writing_tests/post_mortems.md @@ -72,6 +72,52 @@ None required - the existing framework supported writing these tests. --- +## 2026-06 - CREATE2 Failed Deposit Storage State-Gas Refund - Amsterdam + +### Description + +A consensus divergence was found via goevmlab differential fuzzing in +go-ethereum's Amsterdam (bal-devnet-7) EIP-8037 implementation: when a `CREATE2` +whose init code writes new storage slots fails its code deposit — either because +the deposited code is rejected by EIP-3541, or because the EIP-8037 code-deposit +state gas cannot be paid — the create frame reverts, but only the new-account +state-creation gas is refunded; the init's storage-slot state-creation gas +(`STATE_BYTES_PER_STORAGE_SET * COST_PER_STATE_BYTE` per slot) is not. The +transaction over-reports gas used (by `num_slots * 97920`), so the sender and +coinbase balances — and the post-state root — diverge from the reference spec +and from revm/nethermind/besu/erigon/ethrex. + +### Root Cause Analysis + +- State-creation gas charged inside a `CREATE`/`CREATE2` init frame must be fully + reverted when the create fails, for both the new account and any storage slots + the init wrote. The existing `eip8037` suite covered the create-init storage + charge on the success path and same-tx slot-reset refunds, but never isolated + the refund of storage-slot state gas on a create *failure*. +- The new-account state-gas refund on failure was already correct, which masked + the missing storage-slot refund: a failing create with no init storage agrees + across clients, so only the combination "failing create + init storage" + exposes it. +- Differential fuzzing (goevmlab) surfaced it where direct enumeration had not. + +### Steps Taken To Avoid Recurrence + +- Added a parametric regression test over the failure mechanism (EIP-3541 reject + and code-deposit OOG) and the number of init storage slots (`0`, `1`, `3`). The + `slots=0` case is a negative control (account-creation refund only) that must + not diverge; the `slots>=1` cases isolate the storage-slot state-gas refund on + create failure and scale the discrepancy with the slot count. + +### Implemented Test Case + +- `tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_create.py::test_create2_failed_deposit_refunds_storage_state_gas` + +### Framework/Documentation Changes + +None required - the existing framework supported writing this test. + +--- + ## TEMPLATE ## Date - Title - Fork diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index f6c4afaba2..30798e9606 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -1066,15 +1066,12 @@ def process_transaction( tx_output = process_message_call(message) - if tx_output.error is not None: - tx_output.state_gas_left = Uint( - int(tx_output.state_gas_left) + tx_output.state_gas_used - ) - tx_output.state_gas_used = 0 - if isinstance(tx.to, Bytes0): - new_account_refund = StateGasCosts.NEW_ACCOUNT - tx_output.state_gas_left += new_account_refund - tx_output.state_refund += new_account_refund + if isinstance(tx.to, Bytes0) and ( + tx_output.error is not None or tx_output.created_target_alive + ): + new_account_refund = StateGasCosts.NEW_ACCOUNT + tx_output.state_gas_left += new_account_refund + tx_output.state_refund += new_account_refund tx_gas_used_before_refund = ( tx.gas - tx_output.gas_left - tx_output.state_gas_left diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index 3c12b81e47..d49759578e 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -186,18 +186,17 @@ class Evm: accessed_storage_keys: Set[Tuple[Address, Bytes32]] regular_gas_used: Uint = Uint(0) state_gas_used: int = 0 - """ - State gas that has been consumed by this execution frame and its - children. - - `state_gas_used` may go negative when the refund matches an - ancestor's charge (e.g. an `SSTORE` clearing a slot a parent set). - """ + state_gas_spilled: Uint = Uint(0) def credit_state_gas_refund(evm: Evm, amount: Uint) -> None: """ - Credit an inline state gas refund to the local frame's reservoir. + Credit a state gas refund to the local frame, in LIFO order. + + State-gas charges draw from the reservoir first and from `gas_left` + last, so refills credit the pool charged last first: `gas_left` up + to `state_gas_spilled`, then the reservoir. This restores the + exact pools the charge drew from, so the two never drift. Parameters ---------- @@ -207,7 +206,10 @@ def credit_state_gas_refund(evm: Evm, amount: Uint) -> None: The refund amount to credit. """ - evm.state_gas_left += amount + from_gas_left = min(amount, evm.state_gas_spilled) + evm.gas_left += from_gas_left + evm.state_gas_spilled -= from_gas_left + evm.state_gas_left += amount - from_gas_left evm.state_gas_used -= int(amount) @@ -225,6 +227,7 @@ def incorporate_child_on_success(evm: Evm, child_evm: Evm) -> None: """ evm.gas_left += child_evm.gas_left evm.state_gas_left += child_evm.state_gas_left + evm.state_gas_spilled += child_evm.state_gas_spilled evm.logs += child_evm.logs evm.refund_counter += child_evm.refund_counter evm.accounts_to_delete.update(child_evm.accounts_to_delete) @@ -234,6 +237,30 @@ def incorporate_child_on_success(evm: Evm, child_evm: Evm) -> None: evm.state_gas_used += child_evm.state_gas_used +def refill_frame_state_gas(evm: Evm) -> None: + """ + Roll back the frame's state gas in LIFO order on revert or halt. + + The frame's state changes are undone, so the state gas it consumed + is credited back to `gas_left` first and then to the reservoir, + restoring the pools the charges drew from. + + Parameters + ---------- + evm : + The frame whose state gas is rolled back. + + """ + evm.gas_left += evm.state_gas_spilled + evm.state_gas_left = Uint( + int(evm.state_gas_left) + + evm.state_gas_used + - int(evm.state_gas_spilled) + ) + evm.state_gas_used = 0 + evm.state_gas_spilled = Uint(0) + + def incorporate_child_on_error( evm: Evm, child_evm: Evm, @@ -241,12 +268,11 @@ def incorporate_child_on_error( """ Incorporate the state of an unsuccessful `child_evm` into the parent `evm`. - State is rolled back, restoring all state gas to the parent's - reservoir via the `state_gas_left + state_gas_used` invariant. The - child's `state_gas_used` is not inherited (only the success path - propagates it), satisfying the EIP-8037 revert rule that - `execution_state_gas_used` decreases by the child's charged state - gas. Inline refunds roll back with their matching charges. + The child rolls back its own state gas via `refill_frame_state_gas` + before returning (on both reverts and exceptional halts), so its + `gas_left` and reservoir already reflect the LIFO refill and its + `state_gas_used` is zero. The parent therefore only reabsorbs the + child's `gas_left` and reservoir. Parameters ---------- @@ -257,11 +283,7 @@ def incorporate_child_on_error( """ evm.gas_left += child_evm.gas_left - evm.state_gas_left = Uint( - int(evm.state_gas_left) - + child_evm.state_gas_used - + int(child_evm.state_gas_left) - ) + evm.state_gas_left += child_evm.state_gas_left evm.regular_gas_used += child_evm.regular_gas_used diff --git a/src/ethereum/forks/amsterdam/vm/gas.py b/src/ethereum/forks/amsterdam/vm/gas.py index c9eabffa4a..7ce827a240 100644 --- a/src/ethereum/forks/amsterdam/vm/gas.py +++ b/src/ethereum/forks/amsterdam/vm/gas.py @@ -311,6 +311,7 @@ def charge_state_gas(evm: Evm, amount: Uint) -> None: remainder = amount - evm.state_gas_left evm.state_gas_left = Uint(0) evm.gas_left -= remainder + evm.state_gas_spilled += remainder else: raise OutOfGasError diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 39fa98ebf0..03b0a247c7 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -130,6 +130,8 @@ def generic_create( push(evm.stack, U256(0)) return + target_alive = is_account_alive(tx_state, contract_address) + increment_nonce(tx_state, evm.message.current_target) child_message = Message( @@ -162,6 +164,8 @@ def generic_create( push(evm.stack, U256(0)) else: incorporate_child_on_success(evm, child_evm) + if target_alive: + credit_state_gas_refund(evm, StateGasCosts.NEW_ACCOUNT) evm.return_data = b"" push(evm.stack, U256.from_be_bytes(child_evm.message.current_target)) diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index 2ef5719ce3..2fe53a3982 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -39,6 +39,7 @@ get_account, get_code, increment_nonce, + is_account_alive, mark_account_created, move_ether, restore_tx_state, @@ -53,7 +54,7 @@ charge_state_gas, ) from ..vm.precompiled_contracts.mapping import PRE_COMPILED_CONTRACTS -from . import Evm, emit_transfer_log +from . import Evm, emit_transfer_log, refill_frame_state_gas from .exceptions import ( AddressCollision, ExceptionalHalt, @@ -91,6 +92,8 @@ class MessageCallOutput: authorities that already existed in state. Subtracted from `tx_state_gas` in block accounting so `block.gas_used` matches the receipt `cumulative_gas_used`. + 10. `created_target_alive`: Whether a top-level creation + transaction targeted an already-existent account. """ gas_left: Uint @@ -103,6 +106,7 @@ class MessageCallOutput: regular_gas_used: Uint state_gas_used: int state_refund: Uint + created_target_alive: bool def process_message_call(message: Message) -> MessageCallOutput: @@ -124,8 +128,10 @@ def process_message_call(message: Message) -> MessageCallOutput: tx_state = message.tx_env.state refund_counter = U256(0) state_refund = Uint(0) + target_alive = False if message.target == Bytes0(b""): if account_deployable(tx_state, message.current_target): + target_alive = is_account_alive(tx_state, message.current_target) evm = process_create_message(message) else: return MessageCallOutput( @@ -139,6 +145,7 @@ def process_message_call(message: Message) -> MessageCallOutput: regular_gas_used=message.gas, state_gas_used=0, state_refund=Uint(0), + created_target_alive=False, ) else: if message.tx_env.authorizations != (): @@ -180,6 +187,7 @@ def process_message_call(message: Message) -> MessageCallOutput: regular_gas_used=evm.regular_gas_used, state_gas_used=evm.state_gas_used, state_refund=state_refund, + created_target_alive=target_alive, ) @@ -241,6 +249,7 @@ def process_create_message(message: Message) -> Evm: charge_state_gas(evm, code_deposit_state_gas) except ExceptionalHalt as error: restore_tx_state(tx_state, snapshot) + refill_frame_state_gas(evm) evm.regular_gas_used += evm.gas_left evm.gas_left = Uint(0) evm.output = b"" @@ -329,12 +338,14 @@ def process_message(message: Message) -> Evm: except ExceptionalHalt as error: evm_trace(evm, OpException(error)) + refill_frame_state_gas(evm) evm.regular_gas_used += evm.gas_left evm.gas_left = Uint(0) evm.output = b"" evm.error = error except Revert as error: evm_trace(evm, OpException(error)) + refill_frame_state_gas(evm) evm.error = error if evm.error: diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py index 658decefdd..737ba9e4c4 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py @@ -3288,7 +3288,16 @@ def test_bal_create_and_oog( init_code_size=len(init_code_bytes), ) factory_sstore = Op.SSTORE(0x00, 1) - factory_code = factory_mstore + factory_create + factory_sstore + oog_sink_memory_size = 10000 * 32 + factory_oog_sink = Op.MSTORE( + oog_sink_memory_size - 32, + 0, + old_memory_size=32, + new_memory_size=oog_sink_memory_size, + ) + factory_code = ( + factory_mstore + factory_create + factory_oog_sink + factory_sstore + ) factory = pre.deploy_contract( code=factory_code, @@ -3313,10 +3322,11 @@ def test_bal_create_and_oog( gas_limit = intrinsic_cost + create_static_cost - 1 elif oog_boundary == OutOfGasBoundary.OOG_AFTER_TARGET_ACCESS: # Exactly the CREATE static cost — address accessed, child - # frame gets 0 gas, CREATE fails, parent OOGs at next opcode + # frame gets 0 gas, CREATE fails, sink forces OOG after access gas_limit = intrinsic_cost + create_static_cost else: - # Full success: static cost + child frame (63/64 rule) + SSTORE + # Full success: static cost + child frame (63/64 rule) + + # SSTORE + gas sink. child_gas = init_code.gas_cost(fork) remaining_needed = (child_gas * 64 + 62) // 63 gas_limit = ( @@ -3324,6 +3334,7 @@ def test_bal_create_and_oog( + create_static_cost + remaining_needed + factory_sstore.gas_cost(fork) + + factory_oog_sink.gas_cost(fork) ) tx = Transaction( @@ -3517,6 +3528,147 @@ def test_bal_create_early_failure( ) +@pytest.mark.with_all_create_opcodes +@pytest.mark.parametrize("creation_outcome", ["pre_frame_failure", "success"]) +def test_bal_create_existing_target( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + create_opcode: Op, + creation_outcome: str, +) -> None: + """ + Test BAL for CREATE/CREATE2 into a pre-existing balance-only target. + + Under EIP-8037 the account-creation charge is unconditional, so the + target's existence is never read to decide it. On a pre-frame failure + (insufficient endowment) the pre-existing target is never accessed and + stays absent from the BAL; on success it appears with the deployed + nonce and code. + """ + alice = pre.fund_eoa() + + init_code = Initcode(deploy_code=Op.STOP) + init_code_bytes = bytes(init_code) + + if creation_outcome == "pre_frame_failure": + factory_balance, endowment = 50, 100 + else: + factory_balance, endowment = 0, 0 + + factory_code = ( + Op.MSTORE(0, Op.PUSH32(init_code_bytes)) + + Op.SSTORE( + 0x00, + Op.GT( + create_opcode( + value=endowment, + offset=32 - len(init_code_bytes), + size=len(init_code_bytes), + ), + 0, + ), + ) + + Op.STOP + ) + + factory = pre.deploy_contract( + code=factory_code, + balance=factory_balance, + storage={0x00: 0xDEAD}, + ) + + target = compute_create_address( + address=factory, + nonce=1, + salt=0, + initcode=init_code_bytes, + opcode=create_opcode, + ) + # Pre-existing balance-only leaf (balance, no code, zero nonce). + pre.fund_address(target, amount=1) + + tx = Transaction(sender=alice, to=factory) + + if creation_outcome == "pre_frame_failure": + expected_bal = BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + factory: BalAccountExpectation( + nonce_changes=[], + storage_changes=[ + BalStorageSlot( + slot=0x00, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=0 + ) + ], + ) + ], + ), + # Never accessed despite pre-existing: absent from the BAL. + target: None, + } + ) + post = { + alice: Account(nonce=1), + factory: Account( + nonce=1, balance=factory_balance, storage={0x00: 0} + ), + target: Account(balance=1), + } + else: + expected_bal = BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + factory: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=2) + ], + storage_changes=[ + BalStorageSlot( + slot=0x00, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=1 + ) + ], + ) + ], + ), + target: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + code_changes=[ + BalCodeChange( + block_access_index=1, new_code=bytes(Op.STOP) + ) + ], + ), + } + ) + post = { + alice: Account(nonce=1), + factory: Account(nonce=2, storage={0x00: 1}), + target: Account(nonce=1, balance=1, code=Op.STOP), + } + + blockchain_test( + pre=pre, + blocks=[Block(txs=[tx], expected_block_access_list=expected_bal)], + post=post, + ) + + @pytest.mark.with_all_create_opcodes @pytest.mark.parametrize( "storage_op", diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_call.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_call.py index d90bfbcb63..0afbf769a4 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_call.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_call.py @@ -206,11 +206,13 @@ def test_reservoir_restored_after_child_spill_and_revert( Test all state gas recovered when child spills then reverts. The child performs two SSTOREs (zero-to-nonzero) but only one - SSTORE's worth of state gas fits in the reservoir — the second + SSTORE's worth of state gas fits in the reservoir, so the second spills into `gas_left`. The child then REVERTs. Because state - changes are rolled back, all state gas (reservoir + spill) is - restored to the parent's reservoir. The parent can then perform - two SSTOREs using only the recovered reservoir. + changes are rolled back, the state gas is refilled LIFO: the + spilled portion returns to `gas_left` and the reservoir-funded + portion restores the reservoir to its start value. The parent + then performs two SSTOREs, drawing one from the restored + reservoir and spilling the other from the recovered `gas_left`. """ sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) @@ -224,8 +226,8 @@ def test_reservoir_restored_after_child_spill_and_revert( parent = pre.deploy_contract( code=( Op.POP(Op.CALL(gas=500_000, address=child)) - # All state gas recovered (reservoir + spill), parent - # can perform two SSTOREs from the recovered reservoir + # State gas recovered LIFO: the spilled SSTORE returns to + # gas_left, the other restores the reservoir + Op.SSTORE(parent_storage.store_next(1), 1) + Op.SSTORE(parent_storage.store_next(1), 1) ), @@ -335,10 +337,11 @@ def test_sequential_calls_reservoir_restored_between_reverts( """ Test reservoir restored across sequential child reverts. - Parent calls child1 which spills and reverts, then calls child2 - which also uses state gas from the restored reservoir. Both - child failures restore the reservoir, so the parent can use it - for its own SSTORE at the end. + Parent calls child1, which uses the reservoir for an SSTORE and + reverts, restoring the reservoir. It then calls child2, which + reuses the restored reservoir and reverts, restoring it again. + The parent then performs its own SSTORE from the restored + reservoir. """ sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) @@ -1282,7 +1285,7 @@ def test_call_value_to_pre_existing_selfdestructed_account( ], ) @pytest.mark.valid_from("EIP8037") -def test_top_level_halt_refunds_total_state_gas( +def test_top_level_halt_burns_spilled_state_gas( blockchain_test: BlockchainTestFiller, pre: Alloc, fork: Fork, @@ -1290,22 +1293,24 @@ def test_top_level_halt_refunds_total_state_gas( reservoir_delta: int, ) -> None: """ - Verify a top-level halt refunds the total state-gas consumed - (reservoir-portion + spilled-portion) regardless of child failure - mode. The parent calls a child that either reverts or halts, then - INVALIDs at the top level. - - Per the updated EIP, both child failure modes propagate the full - `state_gas_used` back through `incorporate_child_on_error`, and - the top-level halt no longer overrides it. The tx-level error - handler then folds the residual into the reservoir, so - `state_gas_left_end = max(reservoir, child_charge)` and - `tx_gas_used = tx.gas - state_gas_left_end`: - - - `reservoir < child_charge` (one_short): spill is refunded too, - `tx_gas_used = gas_limit_cap - (child_charge - reservoir)`. - - `reservoir >= child_charge`: no spill, `tx_gas_used = - gas_limit_cap`. + Verify a top-level halt burns the spilled state gas, so only the + start reservoir survives. The parent calls a child that reverts or + halts, then INVALIDs at the top level. + + Under LIFO refills a frame's spilled state gas refills to + `gas_left`, which the halt then zeros. Only the reservoir-funded + portion survives, equal to the reservoir at frame start. + + That start value equals the sized reservoir R, so for every child + failure mode and `reservoir_delta`: + + `state_gas_left_end = R`, + `tx_gas_used = tx.gas - R = gas_limit_cap`. + + With the reservoir one short (`reservoir_delta == -1`) the child's + SSTORE spills one unit from `gas_left`, which is refilled then + burned by the halt. So `tx_gas_used` stays `gas_limit_cap`. The + old behavior refunded the spill, giving `gas_limit_cap - 1`. """ gas_limit_cap = fork.transaction_gas_limit_cap() assert gas_limit_cap is not None @@ -1331,10 +1336,10 @@ def test_top_level_halt_refunds_total_state_gas( sender=pre.fund_eoa(), ) - # Policy A halt: state_gas counters preserved through the child - # halt/revert, parent halt, and tx-level fold. - # state_gas_left_end = max(reservoir, sstore_state_gas). - state_gas_left_end = max(reservoir, sstore_state_gas) + # LIFO refills: the spill refills to `gas_left` and is burned by + # the halt. Only the sized reservoir survives, so + # `tx_gas_used = gas_limit_cap`. + state_gas_left_end = reservoir expected_gas_used = tx_gas - state_gas_left_end blockchain_test( diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_create.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_create.py index 54d0cc76bf..0047d2cf3d 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_create.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_create.py @@ -635,24 +635,27 @@ def test_parent_state_gas_after_child_failure( """ Test parent state-gas pools after CREATE child failure. - A factory invokes CREATE whose initcode performs an SSTORE - (charging state gas) then either REVERTs or hits INVALID. The - factory's own SSTORE after the failed CREATE acts as the - discriminator that the parent's state-gas accounting (reservoir - and gas_left) is in the expected state. + A factory runs CREATE whose initcode does an SSTORE, then either + REVERTs or hits INVALID. The factory's own SSTORE after the failed + CREATE checks the parent's reservoir and gas_left are correct. + + Under EIP-8037 state-gas refunds are LIFO. Gas spilled from + gas_left refunds to gas_left, only the reservoir-funded portion + returns to the reservoir. Four scenarios cover the gas-pool state space: - - `with_reservoir x revert`: child state gas (new account + - initcode SSTORE) is fully refunded to the parent reservoir on - REVERT. - - `with_reservoir x halt`: HALT resets the child frame to - `(0, R0_child)`; only the reservoir-portion entering the - initcode is returned, any spilled gas stays burned. - - `no_reservoir x revert`: child state gas refunded forms a - fresh reservoir even though `R0_parent` started at 0. - - `no_reservoir x halt`: no phantom reservoir may form; the - factory's post-CREATE SSTORE must spill from gas_left. + - `with_reservoir x revert`: child state gas refills LIFO. The + reservoir-funded portion returns to the parent reservoir, any + spill to the parent gas_left. + - `with_reservoir x halt`: halt refills the child frame LIFO then + burns its gas_left. Only the child's start reservoir survives. + - `no_reservoir x revert`: child state gas spilled wholly from + gas_left, so the LIFO refill returns it there. No phantom + reservoir forms. + - `no_reservoir x halt`: no phantom reservoir forms. The spilled + child state gas is burned with the child gas_left and the + factory's post-CREATE SSTORE spills from gas_left. """ gas_limit_cap = fork.transaction_gas_limit_cap() assert gas_limit_cap is not None @@ -728,21 +731,21 @@ def test_parent_state_gas_after_child_failure( initcode_regular_revert = initcode.gas_cost(fork) - sstore_state_gas if failure_op == Op.INVALID: - # Simulate runtime gas accounting for HALT using fork helpers: - # 1. Initial regular pool capped by transaction_gas_limit_cap; + # Simulate runtime gas for HALT under EIP-8037 LIFO refills: + # 1. Regular pool capped by transaction_gas_limit_cap. The # remainder forms the state reservoir. - # 2. CREATE op charges new_account state gas (from reservoir - # first, spilled to gas_left otherwise). - # 3. 63/64 retention rule: parent retains gas_left // 64. - # 4. INVALID burns all forwarded regular gas in the child. - # Per the updated EIP, child halt preserves its state-gas - # counters and `incorporate_child_on_error` refunds the - # full child charge — including any spilled portion — to - # the parent's reservoir. - # 5. CREATE failure refunds new_account state gas to the - # parent's state pool (account creation rolled back). - # 6. Factory's post-CREATE SSTORE charges sstore_state_gas - # (state pool first, spilled to gas_left otherwise). + # 2. CREATE charges new_account state gas, reservoir first + # then spilled to gas_left and tracked. + # 3. 63/64 retention: parent keeps gas_left // 64. The + # reservoir is forwarded to the child frame. + # 4. Child initcode SSTORE charges sstore_state_gas, child + # reservoir first then spilled to child gas_left. + # 5. INVALID refills the child frame LIFO then burns its + # gas_left. Only the child's start reservoir survives. + # 6. CREATE failure refills new_account LIFO: the spill to + # parent gas_left, the rest to the parent reservoir. + # 7. Factory post-CREATE SSTORE charges sstore_state_gas, + # reservoir first then spilled to gas_left. execution_gas = gas_limit - intrinsic_cost regular_budget = gas_limit_cap - intrinsic_cost sim_gas_left = min(regular_budget, execution_gas) @@ -751,26 +754,31 @@ def test_parent_state_gas_after_child_failure( sim_gas_left -= factory_pre_create_regular sim_gas_left -= gas_costs.OPCODE_CREATE_BASE + init_code_word_cost - if sim_state_gas_left >= new_account_state_gas: - sim_state_gas_left -= new_account_state_gas - else: - sim_gas_left -= new_account_state_gas - sim_state_gas_left - sim_state_gas_left = 0 + # CREATE new_account state gas: reservoir first, spill tracked. + new_account_from_reservoir = min( + sim_state_gas_left, new_account_state_gas + ) + new_account_spill = new_account_state_gas - new_account_from_reservoir + sim_state_gas_left -= new_account_from_reservoir + sim_gas_left -= new_account_spill - # `child_reservoir` is what the parent forwards to the child. - # Under Policy A halt, incorporate refunds child.state_gas_used - # + child.state_gas_left = max(sstore, child_reservoir) back to - # the parent. The simulator already implicitly retains - # `child_reservoir` in `sim_state_gas_left`, so the additional - # Policy A refund versus the Policy B "burn the spill" rule is - # `max(0, sstore_state_gas - child_reservoir)`. + # 63/64 retention: parent keeps gas_left // 64. The reservoir + # is forwarded to the child frame and survives on halt. child_reservoir = sim_state_gas_left sim_gas_left = sim_gas_left // 64 - sim_state_gas_left += max(0, sstore_state_gas - child_reservoir) - sim_state_gas_left += new_account_state_gas + + # INVALID burns child gas_left, including any spilled SSTORE + # state gas. Only the forwarded reservoir survives. + sim_state_gas_left = child_reservoir + + # CREATE failure refills new_account LIFO: spilled portion to + # gas_left, reservoir-funded portion to the reservoir. + sim_gas_left += new_account_spill + sim_state_gas_left += new_account_from_reservoir sim_gas_left -= factory_post_create_regular + # Factory post-CREATE SSTORE: reservoir first, spill otherwise. if sim_state_gas_left >= sstore_state_gas: sim_state_gas_left -= sstore_state_gas else: @@ -1202,11 +1210,20 @@ def test_code_deposit_halt_discards_initcode_state_gas( ) +@pytest.mark.parametrize( + "target", + [ + pytest.param("new", id="new_account"), + pytest.param("existing", id="existing_account"), + ], +) +@pytest.mark.pre_alloc_mutable() @pytest.mark.valid_from("EIP8037") def test_create_tx_header_gas_used( blockchain_test: BlockchainTestFiller, pre: Alloc, fork: Fork, + target: str, ) -> None: """ Verify block header gas_used for a successful CREATE transaction. @@ -1215,22 +1232,45 @@ def test_create_tx_header_gas_used( exact gas_used from first principles and verify against the block header. Catches bugs where clients report gas_limit instead of actual consumed gas. + + For a fresh target the NEW_ACCOUNT state gas is charged and + dominates the regular gas, so gas_used == NEW_ACCOUNT. For a + pre-existing balance-only leaf the NEW_ACCOUNT charge is refunded, + so net state gas is zero and the regular intrinsic gas dominates. + The expected value subtracts NEW_ACCOUNT and so fails if the + refund regresses. """ gas_costs = fork.gas_costs() initcode = Op.STOP create_state_gas = fork.create_state_gas(code_size=1) + if target == "existing": + sender = pre.fund_eoa(nonce=0) + contract_address = compute_create_address(address=sender, nonce=0) + # Balance-only leaf: alive and deployable, so the creation + # succeeds and the intrinsic NEW_ACCOUNT charge is refunded. + pre.fund_address(contract_address, amount=1) + else: + sender = pre.fund_eoa() + tx = Transaction( to=None, data=initcode, state_gas_reservoir=create_state_gas, - sender=pre.fund_eoa(), + sender=sender, ) # block_gas_used = max(block_regular, block_state) - # For a minimal CREATE tx deploying Op.STOP (1 byte), - # state gas (new account) dominates regular gas. - expected_gas_used = gas_costs.NEW_ACCOUNT + if target == "existing": + intrinsic_cost = fork.transaction_intrinsic_cost_calculator() + intrinsic_total = intrinsic_cost( + calldata=bytes(initcode), contract_creation=True + ) + expected_gas_used = intrinsic_total - gas_costs.NEW_ACCOUNT + else: + # For a minimal CREATE tx deploying Op.STOP (1 byte), + # state gas (new account) dominates regular gas. + expected_gas_used = gas_costs.NEW_ACCOUNT blockchain_test( pre=pre, @@ -1829,6 +1869,173 @@ def test_create_code_deposit_oog_refunds_state_gas( state_test(pre=pre, post={factory: Account(storage=storage)}, tx=tx) +@pytest.mark.parametrize("slots", [0, 1, 3]) +@pytest.mark.parametrize("fail_mode", ["eip3541", "oog_deposit"]) +@pytest.mark.valid_from("EIP8037") +def test_create2_failed_deposit_refunds_storage_state_gas( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + slots: int, + fail_mode: str, +) -> None: + """ + Test a failed CREATE2 deposit refunds the init's storage-slot state gas. + + Total gas used is independent of `slots`, so a client that drops the + slot refund diverges for `slots >= 1`; `slots == 0` is the negative + control. + """ + gas_limit_cap = fork.transaction_gas_limit_cap() + assert gas_limit_cap is not None + + # init: write `slots` new storage slots, then trigger a deposit failure + init_code = Bytecode() + for i in range(slots): + init_code += Op.SSTORE(i, i + 1) + if fail_mode == "eip3541": + # return 0xEF -> EIP-3541 rejects the deposited code + init_code += Op.MSTORE8(0, 0xEF) + Op.RETURN(0, 1) + else: + # return max-size code: the code-deposit state gas cannot be paid + init_code += Op.RETURN(0, fork.max_code_size()) + mstore_value, size = init_code_at_high_bytes(init_code) + + storage = Storage() + factory = pre.deploy_contract( + code=( + Op.MSTORE(0, mstore_value) + + Op.SSTORE( + storage.store_next(0, "create2_failed"), + Op.CREATE2(value=0, offset=0, size=size, salt=0), + ) + ), + ) + + tx = Transaction( + to=factory, + gas_limit=gas_limit_cap, + sender=pre.fund_eoa(), + ) + + state_test( + pre=pre, + post={factory: Account(storage=storage)}, + tx=tx, + ) + + +@pytest.mark.parametrize( + "reservoir_covers", + [ + pytest.param(True, id="charge_from_reservoir"), + pytest.param(False, id="charge_spills_from_gas_left"), + ], +) +@pytest.mark.with_all_create_opcodes() +@pytest.mark.valid_from("EIP8037") +def test_create_account_charge_reduces_child_gas( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + create_opcode: Op, + reservoir_covers: bool, +) -> None: + """ + Verify the early NEW_ACCOUNT charge reduces forwarded child gas. + + `generic_create` charges NEW_ACCOUNT before computing the child's + 63/64 share. When the reservoir covers the charge `gas_left` is + untouched and the child receives the full share. When the reservoir + is empty the charge spills NEW_ACCOUNT from `gas_left` first, so the + child receives `NEW_ACCOUNT * 63 / 64` less. The init code burns a + fixed amount sized between the two shares, so it deploys when the + charge comes from the reservoir and runs out of gas when it spills. + The target is a pre-existing balance-only leaf, the EIP-8037 + success-refund path that the old conditional charge skipped. + """ + new_account = fork.gas_costs().NEW_ACCOUNT + memory_gas = fork.memory_expansion_gas_calculator() + + # Factory `gas_left` at the NEW_ACCOUNT charge. Three times + # NEW_ACCOUNT gives a wide discrimination window and a large + # absolute child share. + gas_at_charge = 3 * new_account + full_share = gas_at_charge - gas_at_charge // 64 + spilled = gas_at_charge - new_account + reduced_share = spilled - spilled // 64 + # Burn the middle of `(reduced_share, full_share]` for robustness. + target_burn = (full_share + reduced_share) // 2 + + # Init code burns `target_burn` regular gas via one MSTORE memory + # expansion, then deploys empty code (zero code deposit). Invert + # `words * MEMORY_PER_WORD + words ** 2 // 512 = target_mem` to size + # the sink offset from gas rather than a magic number. + init_static = (Op.MSTORE(0, 0) + Op.RETURN(0, 0)).gas_cost(fork) + target_mem = target_burn - init_static + # Memory cost is monotonic in word count, so binary search the + # largest word count whose expansion stays within `target_mem`. + low, high = 1, target_mem + while low < high: + mid = (low + high + 1) // 2 + if int(memory_gas(new_bytes=mid * 32)) <= target_mem: + low = mid + else: + high = mid - 1 + words = low + sink_offset = (words - 1) * 32 + child_burn = init_static + int(memory_gas(new_bytes=words * 32)) + assert reduced_share < child_burn <= full_share + + init_code = Op.MSTORE(sink_offset, 0) + Op.RETURN(0, 0) + mstore_value, size = init_code_at_high_bytes(init_code) + create_call = ( + create_opcode(value=0, offset=0, size=size, salt=0) + if create_opcode == Op.CREATE2 + else create_opcode(value=0, offset=0, size=size) + ) + + storage = Storage() + expected = 1 if reservoir_covers else 0 + factory = pre.deploy_contract( + code=Op.MSTORE(0, mstore_value) + + Op.SSTORE( + storage.store_next(expected, "child_succeeds"), + Op.GT(create_call, 0), + ), + ) + + # Pre-existing balance-only target: the success-refund path. Under + # the old conditional approach this alive target skips NEW_ACCOUNT, + # so the child gets the full share and fits in both cases. + if create_opcode == Op.CREATE2: + create_address = compute_create2_address( + address=factory, salt=0, initcode=bytes(init_code) + ) + else: + create_address = compute_create_address(address=factory, nonce=1) + pre.fund_address(create_address, amount=1) + + # Regular gas the factory spends before the NEW_ACCOUNT charge: the + # initcode setup MSTORE plus the create opcode regular portion + # (`gas_cost` folds NEW_ACCOUNT into the create op, so strip it). + setup = Op.MSTORE(0, mstore_value) + pre_charge_regular = ( + setup.gas_cost(fork) + create_call.gas_cost(fork) - new_account + ) + forwarded_gas = gas_at_charge + pre_charge_regular + caller = pre.deploy_contract( + code=Op.CALL(gas=forwarded_gas, address=factory) + ) + tx = Transaction( + to=caller, + state_gas_reservoir=new_account if reservoir_covers else 0, + sender=pre.fund_eoa(), + ) + + state_test(pre=pre, post={factory: Account(storage=storage)}, tx=tx) + + @pytest.mark.parametrize( "init_code", [ @@ -2560,3 +2767,74 @@ def test_create_collision_burned_gas_counted_in_block_regular( ], post={}, ) + + +@pytest.mark.parametrize( + "target", + [ + pytest.param("new", id="new_account"), + pytest.param("existing", id="existing_account"), + ], +) +@pytest.mark.with_all_create_opcodes() +@pytest.mark.valid_from("EIP8037") +def test_create_account_creation_charge( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + create_opcode: Op, + target: str, +) -> None: + """ + Verify NEW_ACCOUNT is charged for a new account and refunded for a + pre-existing balance-only leaf. + + Empty init code means zero code deposit, so NEW_ACCOUNT is the only + create state cost. A fresh target is charged it; a pre-existing + balance-only target (balance, no code, zero nonce) refunds it on + success. The probe SSTORE both confirms the create succeeded and + makes state gas dominate, so gas_used drops by exactly NEW_ACCOUNT + when refunded. + """ + new_account = fork.gas_costs().NEW_ACCOUNT + sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) + mstore_value, size = init_code_at_high_bytes(Op.STOP) + create_call = ( + create_opcode(value=0, offset=0, size=size, salt=0) + if create_opcode == Op.CREATE2 + else create_opcode(value=0, offset=0, size=size) + ) + + storage = Storage() + factory = pre.deploy_contract( + code=Op.MSTORE(0, mstore_value) + + Op.SSTORE( + storage.store_next(1, "create_succeeds"), Op.GT(create_call, 0) + ) + ) + + # Factory deployed via deploy_contract starts at nonce 1. + if create_opcode == Op.CREATE2: + create_address = compute_create2_address( + address=factory, salt=0, initcode=bytes(Op.STOP) + ) + else: + create_address = compute_create_address(address=factory, nonce=1) + if target == "existing": + pre.fund_address(create_address, amount=1) + + tx = Transaction( + to=factory, + state_gas_reservoir=new_account + sstore_state_gas, + sender=pre.fund_eoa(), + ) + + # State gas dominates regular: a new account adds NEW_ACCOUNT on top + # of the probe SSTORE, a pre-existing target refunds it. + expected = sstore_state_gas + (new_account if target == "new" else 0) + state_test( + pre=pre, + tx=tx, + post={factory: Account(storage=storage)}, + blockchain_test_header_verify=Header(gas_used=expected), + ) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_reservoir.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_reservoir.py index 1da756d4a2..b949c150b0 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_reservoir.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_reservoir.py @@ -931,73 +931,13 @@ def test_subcall_failure_does_not_zero_top_level_state_gas( @pytest.mark.parametrize( - "failure_mode", + "spill_source", [ - pytest.param("revert", id="revert"), - pytest.param("halt", id="halt"), + pytest.param("own", id="own_spill"), + pytest.param("propagated", id="propagated_spill"), + pytest.param("both", id="own_and_propagated_spill"), ], ) -@pytest.mark.valid_from("EIP8037") -def test_top_level_failure_spilled_state_gas( - state_test: StateTestFiller, - pre: Alloc, - fork: Fork, - failure_mode: str, -) -> None: - """ - Verify the top-level failure handling for state gas that spilled - from the reservoir into `gas_left`. - - When the reservoir is smaller than the state gas charge, the - overflow spills and is drawn from `gas_left`. Both failure - modes refund the full `state_gas_used` (reservoir-portion + - spilled-portion) to the reservoir per the updated EIP. They - differ only in `gas_left` handling: - - - REVERT preserves `gas_left`; sender billed only the regular - component. - - Exceptional halt zeros `gas_left` (existing EVM rule); sender - pays for everything except the state-gas refund. - """ - gas_limit_cap = fork.transaction_gas_limit_cap() - assert gas_limit_cap is not None - sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() - - if failure_mode == "revert": - code = Op.SSTORE(0, 1) + Op.REVERT(0, 0) - else: - code = Op.SSTORE(0, 1) + Op.INVALID - contract = pre.deploy_contract(code=code) - - # Reservoir sized to cover only half the SSTORE state gas; the - # other half spills into gas_left. - tx_gas = gas_limit_cap + sstore_state_gas // 2 - - if failure_mode == "revert": - # gas_left preserved; full state_gas_used refunded to - # reservoir → sender billed only the regular component. - expected_cumulative = ( - intrinsic_cost + code.gas_cost(fork) - sstore_state_gas - ) - else: - # gas_left burned; full state_gas_used (reservoir-portion + - # spilled-portion) refunded via reservoir. - # tx_gas_used = tx_gas - 0 - sstore_state_gas. - expected_cumulative = tx_gas - sstore_state_gas - - tx = Transaction( - to=contract, - state_gas_reservoir=sstore_state_gas // 2, - sender=pre.fund_eoa(), - expected_receipt=TransactionReceipt( - cumulative_gas_used=expected_cumulative, - ), - ) - - state_test(pre=pre, post={contract: Account(storage={})}, tx=tx) - - @pytest.mark.parametrize( "failure_mode", [ @@ -1006,76 +946,83 @@ def test_top_level_failure_spilled_state_gas( ], ) @pytest.mark.valid_from("EIP8037") -def test_top_level_failure_propagated_state_gas( +def test_top_level_failure_spilled_state_gas( state_test: StateTestFiller, pre: Alloc, fork: Fork, failure_mode: str, + spill_source: str, ) -> None: """ - Verify the top-level failure handling for state gas propagated - from a successful subcall. - - The parent calls a child that runs SSTORE and returns. The - child's `state_gas_used` is folded into the parent frame via the - success path so the parent's reservoir is empty and its - `state_gas_used` carries the SSTORE charge. - - Per the updated EIP both failure modes refund the full propagated - `state_gas_used` (reservoir-portion + spilled-portion) to the - reservoir. They differ only in `gas_left` handling: - - - REVERT preserves `gas_left`; sender billed only the regular - component. - - Exceptional halt zeros `gas_left`; sender pays for everything - except the state-gas refund. + Verify top-level failure handling for spilled state gas, whether + the spill is charged in the frame itself, propagated from a + successful subcall, or both. + + The reservoir covers half an SSTORE's state gas, so each SSTORE + charge spills into `gas_left`. A successful child propagates its + `state_gas_spilled` into the parent, accumulating with the parent's + own spill. Refunds are LIFO, so the spilled portion returns to + `gas_left` and only the reservoir-funded portion to the reservoir. + + - REVERT preserves `gas_left`, so all state gas is refunded and the + sender pays only the regular component. + - Halt refills LIFO then zeros `gas_left`, so the spill is burned + and only the start reservoir survives. """ gas_limit_cap = fork.transaction_gas_limit_cap() assert gas_limit_cap is not None sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() + terminator = Op.REVERT(0, 0) if failure_mode == "revert" else Op.INVALID + has_child = spill_source in ("propagated", "both") child_code = Op.SSTORE(0, 1) - child = pre.deploy_contract(code=child_code) - if failure_mode == "revert": - parent_code = Op.POP(Op.CALL(gas=Op.GAS, address=child)) + Op.REVERT( - 0, 0 - ) - else: - parent_code = Op.POP(Op.CALL(gas=Op.GAS, address=child)) + Op.INVALID + + parent_code = Bytecode() + if spill_source in ("own", "both"): + parent_code += Op.SSTORE(0, 1) + child = None + if has_child: + child = pre.deploy_contract(code=child_code) + parent_code += Op.POP(Op.CALL(gas=Op.GAS, address=child)) + parent_code += terminator parent = pre.deploy_contract(code=parent_code) - # Reservoir sized to half the SSTORE state gas so the child's - # charge drains the reservoir AND spills into gas_left. The halt - # path then exercises a non-trivial spill case rather than the - # degenerate no-spill case. - tx_gas = gas_limit_cap + sstore_state_gas // 2 + # Reservoir covers half an SSTORE's state gas, so every SSTORE + # spills into gas_left. + reservoir = sstore_state_gas // 2 + tx_gas = gas_limit_cap + reservoir + total_state = sstore_state_gas * ( + (1 if spill_source in ("own", "both") else 0) + (1 if has_child else 0) + ) if failure_mode == "revert": - # gas_left preserved; full propagated state_gas_used refunded - # → sender billed only the regular component. + # gas_left preserved, all state gas refunded, so the sender + # pays only the regular component. expected_cumulative = ( - intrinsic_cost - + parent_code.gas_cost(fork) - + child_code.gas_cost(fork) - - sstore_state_gas + intrinsic_cost + parent_code.gas_cost(fork) - total_state ) + if has_child: + expected_cumulative += child_code.gas_cost(fork) else: - # gas_left burned; full propagated state_gas_used (reservoir - # + spill) refunded via reservoir. - # tx_gas_used = tx_gas - 0 - sstore_state_gas. - expected_cumulative = tx_gas - sstore_state_gas + # gas_left burned after LIFO refill. The spill returns to + # gas_left and is consumed, so only the start reservoir + # survives. + expected_cumulative = tx_gas - reservoir tx = Transaction( to=parent, - state_gas_reservoir=sstore_state_gas // 2, + state_gas_reservoir=reservoir, sender=pre.fund_eoa(), expected_receipt=TransactionReceipt( cumulative_gas_used=expected_cumulative, ), ) - state_test(pre=pre, post={child: Account(storage={})}, tx=tx) + post = {parent: Account(storage={})} + if child is not None: + post[child] = Account(storage={}) + state_test(pre=pre, post=post, tx=tx) def _build_call_chain( @@ -1118,10 +1065,9 @@ def _build_create_chain( then terminates with `terminator`. The deepest level's initcode just executes its body and terminates. - Each CREATE pre-charges `STATE_NEW × cpsb` of state-gas on the - parent frame, which is what makes this chain exercise the - credit-on-failure path that distinguishes Policy A from Policy B - for top-level halt. + Each CREATE pre-charges `STATE_NEW * cpsb` of state gas on the + parent frame, which makes this chain exercise the LIFO + refill-on-failure path for top-level halt. """ remaining_frame_bodies = frame_bodies[:] # Deepest level is just body + terminator (runs as initcode of @@ -1133,8 +1079,8 @@ def _build_create_chain( inner_bytes = bytes(inner_initcode) inner_size = len(inner_bytes) # Pad to 32-byte alignment so Om.MSTORE uses the cheap - # PUSH32+MSTORE path on the trailing chunk; CREATE reads - # only `size` bytes so the trailing zeros are ignored. + # PUSH32+MSTORE path on the trailing chunk. CREATE reads + # only `size` bytes, so the trailing zeros are ignored. padded = inner_bytes + b"\x00" * ((-inner_size) % 32) code = ( remaining_frame_bodies.pop() @@ -1270,21 +1216,23 @@ def test_nested_failure_resets_to_tx_reservoir( so the cascade reaches the top. Axes: - - `failure_mode`: REVERT vs HALT (top-level gas_left semantics - differ; state-gas refund must agree per the updated EIP). - - `spill_mode`: `no_spill` sizes the reservoir to cover all - state-gas charges. `spill` shrinks it so charges drain into - gas_left, exercising the spill-refund-on-halt rule. - - `frame_op`: `call` chains via CALL (no per-frame pre-charge). - `create` chains via CREATE (each level pre-charges - `STATE_BYTES_PER_NEW_ACCOUNT × cpsb`, exercising - credit-on-failure interleaved with the spill). - - Per the updated EIP, every state-gas charge — body charges, - spilled portions, and CREATE pre-charges — is refunded to the - top-level reservoir on either revert or halt. So the user pays - `tx_gas - max(reservoir, total_state_charges)` on halt and only - regular charges + intrinsic on revert, regardless of axes. + - `failure_mode`: REVERT vs HALT. Top-level gas_left semantics + differ, but state gas refund must agree per the updated EIP. + - `spill_mode`: `no_spill` sizes the reservoir to cover all state + gas charges. `spill` shrinks it so charges drain into gas_left, + exercising the spill-refund-on-halt rule. + - `frame_op`: `call` chains via CALL with no per-frame pre-charge. + `create` chains via CREATE, where each level pre-charges + `STATE_BYTES_PER_NEW_ACCOUNT * cpsb` and exercises + credit-on-failure interleaved with the spill. + + Refunds are LIFO. On REVERT every state gas charge (body charges, + spilled portions, and CREATE pre-charges) is refilled, the spill + landing back in `gas_left`, so the user pays only regular charges + plus intrinsic. On HALT the LIFO refill returns spilled state gas + to `gas_left`, which is then zeroed, so only the start reservoir + survives and the user pays `tx_gas - reservoir = gas_limit_cap`, + regardless of spill axis or CREATE pre-charges. Two assertions cross-check the gas accounting: - `cumulative_gas_used` (receipt) pins `tx.gas - gas_left - @@ -1320,20 +1268,19 @@ def test_nested_failure_resets_to_tx_reservoir( top, frame_codes = _build_create_chain(pre, frame_bodies, terminator) sum_regular = sum(code.regular_cost(fork) for code in frame_codes) - spill = max(0, total_state_charges - reservoir) if failure_mode == "halt": - # Policy A (updated EIP): all state-gas — body charges, spilled - # portions, and CREATE pre-charges (returned via credit) — folds - # into state_gas_left at tx end. gas_left is zeroed by halt. - state_gas_at_end = max(reservoir, total_state_charges) - expected_cumulative = tx_gas - state_gas_at_end - # Header: block_regular = gas_limit_cap - spill (spilled - # state-gas drained gas_left but is no longer reclassified to - # regular under Policy A); block_state ≈ 0 for plain CALLs. - expected_header_gas_used = gas_limit_cap - spill + # LIFO refill returns spilled state gas (and spilled CREATE + # pre-charges) to gas_left, which halt then zeros. Only the + # start reservoir survives. + expected_cumulative = tx_gas - reservoir + assert expected_cumulative == gas_limit_cap + # Header: all gas_left (including the refilled spill) is + # consumed as regular. Block state gas is zero for plain + # frames. + expected_header_gas_used = gas_limit_cap elif failure_mode == "revert": - # Revert preserves gas_left; full state-gas refund. - # User pays only regular costs + intrinsic. + # Revert preserves gas_left, full state gas refund, so the + # user pays only regular costs plus intrinsic. expected_cumulative = intrinsic_cost + sum_regular # Header reflects the regular-vs-state attribution directly: # state_gas_used is zeroed by the tx error handler, so only @@ -1394,26 +1341,36 @@ def test_nested_state_gas_refund_consumed_at_depth( consume_at: str, ) -> None: """ - Verify state-gas refund credits propagate through a CALL chain so - they can be consumed at any depth. - - Refund sources: SSTORE `0→1→0`, CREATE collision, CREATE initcode - revert (all credit deepest's reservoir), and a SetCode auth on an - `existing_leaf` authority (credits the top reservoir at message - entry). - - A probe CALL sized one short of covering an SSTORE on full spill - runs either at the refund-source frame or back at the top after - the chain returns; it succeeds only when its frame holds enough - reservoir, so a missing or mis-propagated credit OOGs it. + Verify how state gas refund credits route under LIFO refills. + + Refund sources SSTORE `0->1->0`, CREATE collision, and CREATE + initcode revert all refund LIFO, so the credit returns to + `gas_left`, not the reservoir. A SetCode auth on an `existing_leaf` + authority still credits the reservoir directly at message entry. + + A probe CALL sized one short of covering an SSTORE forwards a fixed + gas to a sub-call, so it can only observe the reservoir, never the + `gas_left` refund. It therefore succeeds only for the auth scenario + and fails (stores 0) for the SSTORE/CREATE scenarios whose refund + lands in `gas_left`. """ is_auth_scenario = refund_scenario == "auth_existing_leaf" probe_address = pre.deploy_contract(code=Op.SSTORE(0, 1)) probe_gas = Op.SSTORE(0, 1).gas_cost(fork) - 1 consumer_storage = Storage() + # The probe forwards a fixed gas and can only see the reservoir, + # so it succeeds (CALL returns 1) only when the refund credited the + # reservoir, the auth scenario. Otherwise the LIFO refund lands in + # gas_left, the sub-call OOGs, and CALL returns 0. + if is_auth_scenario: + probe_label = "auth_reservoir_probe_must_succeed" + probe_result = 1 + else: + probe_label = "gas_left_refund_probe_must_fail" + probe_result = 0 consume_op = Op.SSTORE( - consumer_storage.store_next(1, "probe_must_succeed"), + consumer_storage.store_next(probe_result, probe_label), Op.CALL(gas=probe_gas, address=probe_address), ) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_sstore.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_sstore.py index d8b25968d9..50e404d149 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_sstore.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_sstore.py @@ -870,19 +870,19 @@ def test_sstore_restoration_sub_frame_revert( call_opcode: Op, ) -> None: """ - Verify 0 to x to 0 reservoir refund returns to the caller on - sub-frame REVERT. - - The sub-call performs 0 to x to 0 then REVERTs. Since both the - set-charge and its refund roll back together, the - `state_gas_used + state_gas_left` sum reflects the unconsumed - reservoir and is returned to the caller via - `incorporate_child_on_error`. A single-SSTORE probe sized to OOG - by 1 succeeds, confirming the caller's reservoir was replenished. + Verify a sub-frame REVERT does not inflate the caller's reservoir + under source-based (LIFO) refills. + + The sub-call does 0 to x to 0 then REVERTs. The set spilled its + state gas from `gas_left`, so the refill at x to 0 returns it to + `gas_left`, not the reservoir. On REVERT the state gas refills to + the parent's `gas_left`, so the reservoir stays at 0. A probe sized + to OOG by 1 then fails, since its fixed forwarded gas cannot reach + the `gas_left` refund. """ gas_costs = fork.gas_costs() - # Probe SSTORE(0, 1): 2 pushes + cold storage write + state gas - 1, - # so it OOGs by 1 when the reservoir is 0 and succeeds otherwise. + # Probe SSTORE(0, 1): 2 pushes + cold write + state gas - 1. OOGs by + # 1 when the reservoir is 0, as forwarded gas misses gas_left. probe_gas = ( 2 * gas_costs.VERY_LOW + gas_costs.COLD_STORAGE_WRITE @@ -894,11 +894,10 @@ def test_sstore_restoration_sub_frame_revert( child = pre.deploy_contract(code=child_code) probe = pre.deploy_contract(code=Op.SSTORE(0, 1)) - # Forward all remaining gas so the child completes both SSTOREs - # and REVERT without a hard-coded budget. + # Forward all gas so the child does both SSTOREs and REVERT. caller_storage = Storage() caller_code = Op.POP(call_opcode(gas=Op.GAS, address=child)) + Op.SSTORE( - caller_storage.store_next(1, "probe_must_succeed"), + caller_storage.store_next(0, "probe_must_fail"), Op.CALL(gas=probe_gas, address=probe), ) caller = pre.deploy_contract(code=caller_code) @@ -925,20 +924,21 @@ def test_sstore_restoration_ancestor_revert( call_opcode: Op, ) -> None: """ - Verify the SSTORE 0 to x to 0 refund returns to the caller when an - ancestor frame (not the applying frame itself) reverts. - - Inner frame applies the refund and returns successfully; its - `state_gas_left` (inflated by the refund) propagates to middle - via `incorporate_child_on_success`. Middle then REVERTs; the - refunded reservoir flows back to the caller via - `incorporate_child_on_error`, so the caller's reservoir is - replenished by `sstore_state_gas`. + Verify an ancestor REVERT does not inflate the caller's reservoir + under source-based (LIFO) refills. + + Inner's set spills its state gas from `gas_left`. The refill at + x to 0 returns it to `gas_left`, and inner's + `state_gas_spilled` propagates to middle on success. Middle + then REVERTs, refilling the spilled state gas to the caller's + `gas_left`, not the reservoir. The reservoir stays at 0, so a probe + sized to OOG by 1 fails, since its fixed forwarded gas cannot reach + the `gas_left` refund. """ gas_costs = fork.gas_costs() intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() - # Probe SSTORE(0, 1): 2 pushes + cold storage write + state gas - 1, - # so it OOGs by 1 when the reservoir is 0 and succeeds otherwise. + # Probe SSTORE(0, 1): 2 pushes + cold write + state gas - 1. OOGs by + # 1 when the reservoir is 0, as forwarded gas misses gas_left. probe_gas = ( 2 * gas_costs.VERY_LOW + gas_costs.COLD_STORAGE_WRITE @@ -969,22 +969,27 @@ def test_sstore_restoration_ancestor_revert( caller_storage = Storage() caller_code = Op.POP(call_opcode(gas=Op.GAS, address=middle)) + Op.SSTORE( - caller_storage.store_next(1, "probe_must_succeed"), + caller_storage.store_next(0, "probe_must_fail"), Op.CALL(gas=probe_gas, address=probe), ) caller = pre.deploy_contract(code=caller_code) - # Block state gas commits: probe's SSTORE-set and caller's outer - # SSTORE-set; inner's set+clear cancel before middle reverts and - # don't propagate. Header gas_used is max(regular, state). + # Block state gas commits only the caller's outer SSTORE-set. The + # probe OOGs and inner's set+clear cancel before middle reverts. + # The probe's CALL burns its forwarded budget on the OOG, less the + # cold-call surcharge already in the caller's static regular cost. + # Header gas_used is max(regular, state). + probe_burned = ( + probe_gas - gas_costs.COLD_ACCOUNT_ACCESS - 2 * gas_costs.WARM_ACCESS + ) expected_regular = ( intrinsic_cost + caller_code.regular_cost(fork) + middle_code.regular_cost(fork) + inner_code.regular_cost(fork) - + probe_code.regular_cost(fork) + + probe_burned ) - expected_state = 2 * Op.SSTORE(new_value=1).state_cost(fork) + expected_state = Op.SSTORE(new_value=1).state_cost(fork) expected_gas_used = max(expected_regular, expected_state) # gas_limit at the cap means the caller's reservoir starts at 0. @@ -1109,21 +1114,20 @@ def test_sstore_restoration_create_init_revert( create_opcode: Op, ) -> None: """ - Verify reservoir refunds return to the caller when CREATE init - code REVERTs inside a sub-frame that also REVERTs. - - Wrapping the CREATE in an outer reverting frame isolates the - rollback concern from the legitimate CREATE silent-failure refund - (`create_account_state_gas` credited to the frame executing the - CREATE opcode). When the outer frame reverts, the refunded - reservoir flows back to the caller via - `incorporate_child_on_error`, replenishing the caller's - reservoir by at least `sstore_state_gas`. A single-SSTORE probe - sized to OOG by 1 succeeds, confirming the propagation. + Verify a reverting CREATE sub-frame does not inflate the caller's + reservoir under source-based (LIFO) refills. + + The init code spills its state gas from `gas_left`, does 0 to x to 0 + and REVERTs. The CREATE is wrapped in an outer frame that also + REVERTs. Each refill returns the spilled state gas to `gas_left`, + and the reverts refill it to the caller's `gas_left`, not the + reservoir. The reservoir stays at 0, so a probe sized to OOG by 1 + fails, since its fixed forwarded gas cannot reach the `gas_left` + refund. """ gas_costs = fork.gas_costs() - # Probe SSTORE(0, 1): 2 pushes + cold storage write + state gas - 1, - # so it OOGs by 1 when the reservoir is 0 and succeeds otherwise. + # Probe SSTORE(0, 1): 2 pushes + cold write + state gas - 1. OOGs by + # 1 when the reservoir is 0, as forwarded gas misses gas_left. probe_gas = ( 2 * gas_costs.VERY_LOW + gas_costs.COLD_STORAGE_WRITE @@ -1157,7 +1161,7 @@ def test_sstore_restoration_create_init_revert( code=( Op.POP(Op.CALL(gas=Op.GAS, address=inner)) + Op.SSTORE( - caller_storage.store_next(1, "probe_must_succeed"), + caller_storage.store_next(0, "probe_must_fail"), Op.CALL(gas=probe_gas, address=probe), ) ), diff --git a/tests/cancun/eip5656_mcopy/test_mcopy_memory_expansion.py b/tests/cancun/eip5656_mcopy/test_mcopy_memory_expansion.py index 0502a64556..23f32896ed 100644 --- a/tests/cancun/eip5656_mcopy/test_mcopy_memory_expansion.py +++ b/tests/cancun/eip5656_mcopy/test_mcopy_memory_expansion.py @@ -128,19 +128,14 @@ def tx( # noqa: D103 initial_memory: bytes, tx_gas_limit: int, tx_access_list: List[AccessList], - successful: bool, - fork: Fork, ) -> Transaction: - expected_gas = tx_gas_limit - if not successful and fork.is_eip_enabled(8037): - expected_gas -= Op.SSTORE(new_value=1).state_cost(fork) return Transaction( sender=sender, to=caller_address, access_list=tx_access_list, data=initial_memory, gas_limit=tx_gas_limit, - expected_receipt=TransactionReceipt(cumulative_gas_used=expected_gas), + expected_receipt=TransactionReceipt(cumulative_gas_used=tx_gas_limit), )