diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index dbbec903de2..9fb3decfed9 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -15,7 +15,7 @@ from typing import List, Optional, Tuple from ethereum_rlp import rlp -from ethereum_types.bytes import Bytes +from ethereum_types.bytes import Bytes, Bytes0 from ethereum_types.frozen import slotted_freezable from ethereum_types.numeric import U64, U256, Uint, ulen @@ -1076,6 +1076,12 @@ def process_transaction( if tx_output.error is not None: tx_output.state_gas_left += tx_output.state_gas_used tx_output.state_gas_used = Uint(0) + if isinstance(tx.to, Bytes0): + new_account_refund = ( + STATE_BYTES_PER_NEW_ACCOUNT * COST_PER_STATE_BYTE + ) + tx_output.state_gas_left += new_account_refund + tx_output.state_refund += new_account_refund else: # Refund state gas for accounts created and destroyed in the # same tx (EIP-6780). Covers account, storage, and code. diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index e1aca5e609b..f3f003e8cb1 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -181,7 +181,6 @@ class Evm: accessed_storage_keys: Set[Tuple[Address, Bytes32]] regular_gas_used: Uint = Uint(0) state_gas_used: Uint = Uint(0) - state_gas_refund: Uint = Uint(0) state_gas_refund_pending: Uint = Uint(0) @@ -191,10 +190,8 @@ def credit_state_gas_refund(evm: Evm, amount: Uint) -> None: Clamp the applied portion to this frame's `state_gas_used` — the matching charge may sit in an ancestor sharing storage via - CALLCODE/DELEGATECALL. Track it in `state_gas_refund` so - `incorporate_child_on_error` can undo the inflation, and defer the - unapplied remainder in `state_gas_refund_pending` for propagation - on success. + CALLCODE/DELEGATECALL. Defer the unapplied remainder in + `state_gas_refund_pending` for propagation on success. Parameters ---------- @@ -207,7 +204,6 @@ def credit_state_gas_refund(evm: Evm, amount: Uint) -> None: applied = min(amount, evm.state_gas_used) evm.state_gas_left += applied evm.state_gas_used -= applied - evm.state_gas_refund += applied evm.state_gas_refund_pending += amount - applied @@ -215,10 +211,9 @@ def incorporate_child_on_success(evm: Evm, child_evm: Evm) -> None: """ Incorporate the state of a successful `child_evm` into the parent `evm`. - Propagate `state_gas_refund` (inline credits the child applied) so - an ancestor revert can undo the inflation, and apply - `state_gas_refund_pending` (the unapplied remainder) to the parent - via `credit_state_gas_refund`; any leftover propagates further up. + Apply `state_gas_refund_pending` (the unapplied remainder of any + refund credited inside the child) to the parent via + `credit_state_gas_refund`; any leftover propagates further up. Parameters ---------- @@ -237,7 +232,6 @@ def incorporate_child_on_success(evm: Evm, child_evm: Evm) -> None: evm.accessed_storage_keys.update(child_evm.accessed_storage_keys) evm.regular_gas_used += child_evm.regular_gas_used evm.state_gas_used += child_evm.state_gas_used - evm.state_gas_refund += child_evm.state_gas_refund credit_state_gas_refund(evm, child_evm.state_gas_refund_pending) @@ -253,11 +247,11 @@ def incorporate_child_on_error( that spilled into `gas_left`, is restored to the parent's reservoir and the child's `state_gas_used` is not accumulated. - Inline state-gas refunds (SSTORE 0 to x to 0, CREATE silent failure) - credited by the child inflated its `state_gas_left`; subtract - `state_gas_refund` from the amount returned to the parent's - reservoir so the inflation does not leak across the error boundary. - `state_gas_refund_pending` is discarded with the child frame. + `state_gas_refund_pending` is discarded with the child frame: any + inline credits the child applied are keyed to charges (its own + SSTORE or CREATE pre-charge) that are themselves rolled back, so + the matching `state_gas_left + state_gas_used` sum already reflects + the correct amount to return to the parent. Parameters ---------- @@ -268,11 +262,7 @@ def incorporate_child_on_error( """ evm.gas_left += child_evm.gas_left - evm.state_gas_left += ( - child_evm.state_gas_used - + child_evm.state_gas_left - - child_evm.state_gas_refund - ) + evm.state_gas_left += child_evm.state_gas_used + child_evm.state_gas_left evm.regular_gas_used += child_evm.regular_gas_used 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 0577934815f..8db97ffb032 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 @@ -1844,15 +1844,16 @@ def test_create_code_deposit_oog_refunds_state_gas( ], ) @pytest.mark.valid_from("EIP8037") -def test_failed_create_tx_state_gas_dominates( +def test_failed_create_tx_refunds_intrinsic_new_account( blockchain_test: BlockchainTestFiller, pre: Alloc, fork: Fork, init_code: Bytecode, ) -> None: """ - Verify the header gas is set by intrinsic state gas when a - creation tx fails with a tight regular budget. + Verify the NEW_ACCOUNT × CPSB portion of intrinsic_state_gas is + refunded on creation-tx revert/halt, so block state-gas excludes + it and header gas_used reflects only the regular component. """ intrinsic_calc = fork.transaction_intrinsic_cost_calculator() create_state_gas = fork.create_state_gas(code_size=0) @@ -1863,9 +1864,12 @@ def test_failed_create_tx_state_gas_dominates( intrinsic_regular = intrinsic_total - create_state_gas gas_limit = intrinsic_total + 1000 - assert intrinsic_regular + 1000 < create_state_gas, ( - "tight gas budget must keep block_regular below create_state_gas" - ) + if init_code == Op.INVALID: + regular_consumed = gas_limit - intrinsic_total + else: + regular_consumed = init_code.regular_cost(fork) + + expected_gas_used = intrinsic_regular + regular_consumed tx = Transaction( to=None, @@ -1879,7 +1883,54 @@ def test_failed_create_tx_state_gas_dominates( blocks=[ Block( txs=[tx], - header_verify=Header(gas_used=create_state_gas), + header_verify=Header(gas_used=expected_gas_used), + ), + ], + post={}, + ) + + +@pytest.mark.pre_alloc_mutable() +@pytest.mark.valid_from("EIP8037") +def test_create_tx_collision_refunds_intrinsic_new_account( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Verify the NEW_ACCOUNT × CPSB portion of intrinsic_state_gas is + refunded on creation-tx address collision, so block state-gas + excludes it and header gas_used reflects only the regular + consumption (full forwarded gas, no initcode runs). + """ + intrinsic_calc = fork.transaction_intrinsic_cost_calculator() + create_state_gas = fork.create_state_gas(code_size=0) + + init_code = Op.STOP + intrinsic_total = intrinsic_calc( + calldata=bytes(init_code), contract_creation=True + ) + gas_limit = intrinsic_total + 1000 + + sender = pre.fund_eoa() + collision_target = compute_create_address(address=sender, nonce=0) + pre[collision_target] = Account(nonce=1) + + expected_gas_used = gas_limit - create_state_gas + + tx = Transaction( + to=None, + data=init_code, + gas_limit=gas_limit, + sender=sender, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + header_verify=Header(gas_used=expected_gas_used), ), ], post={}, @@ -2131,11 +2182,6 @@ def test_inner_create_succeeds_code_deposit_state_gas( initcode_gas = initcode.gas_cost(fork) gas_limit = intrinsic_total + initcode_gas + inner_code_deposit + 1000 - if outer_outcome == "succeeds": - expected_state = outer_state_gas + inner_state_gas - else: - expected_state = outer_state_gas - create_address = compute_create_address(address=sender, nonce=0) tx = Transaction( @@ -2147,19 +2193,15 @@ def test_inner_create_succeeds_code_deposit_state_gas( if outer_outcome == "succeeds": post: dict = {create_address: Account(code=b"")} + block = Block( + txs=[tx], + header_verify=Header(gas_used=outer_state_gas + inner_state_gas), + ) else: post = {create_address: Account.NONEXISTENT} + block = Block(txs=[tx]) - blockchain_test( - pre=pre, - blocks=[ - Block( - txs=[tx], - header_verify=Header(gas_used=expected_state), - ), - ], - post=post, - ) + blockchain_test(pre=pre, blocks=[block], post=post) @pytest.mark.parametrize( @@ -2355,8 +2397,6 @@ def test_inner_create_fail_refunds_in_creation_tx( + num_inner_ops * (gas_costs.NEW_ACCOUNT + per_inner_slack) ) - expected_state = outer_state_gas - create_address = compute_create_address(address=sender, nonce=0) tx = Transaction( @@ -2368,19 +2408,15 @@ def test_inner_create_fail_refunds_in_creation_tx( if outer_outcome == "succeeds": post: dict = {create_address: Account(code=b"")} + block = Block( + txs=[tx], + header_verify=Header(gas_used=outer_state_gas), + ) else: post = {create_address: Account.NONEXISTENT} + block = Block(txs=[tx]) - blockchain_test( - pre=pre, - blocks=[ - Block( - txs=[tx], - header_verify=Header(gas_used=expected_state), - ), - ], - post=post, - ) + blockchain_test(pre=pre, blocks=[block], post=post) @pytest.mark.pre_alloc_mutable 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 041a0bd4244..69fac5f3ca4 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 @@ -1328,9 +1328,7 @@ def test_nested_failure_resets_to_tx_reservoir( - `cumulative_gas_used` (receipt) pins `tx.gas - gas_left - state_gas_left`, catching bugs in the leftover split. - `header.gas_used` pins `max(block_regular, block_state)` via - the block accumulators. They differ from the receipt by - exactly `non_top_burns` (inline refunds the user pays for but - the block doesn't track in either accumulator). + the block accumulators. """ gas_limit_cap = fork.transaction_gas_limit_cap() assert gas_limit_cap is not None @@ -1359,27 +1357,13 @@ def test_nested_failure_resets_to_tx_reservoir( else: top, frame_codes = _build_create_chain(pre, frame_bodies, terminator) - # Non-top inline state-gas refunds (body SSTORE x→0 plus CREATE - # pre-charge credits on child failure) accumulate in each frame's - # `state_gas_refund` and get subtracted at the parent's - # `incorporate_child_on_error` boundary so the inflation does not - # leak across the rolled-back state change. The top frame's - # refund is preserved by the tx-level error handler. - non_top_body_refund_burn = sum( - b.state_refund(fork) for b in frame_bodies[1:] - ) - non_top_create_credit_burn = max(0, n_creates - 1) * new_account_state_gas - non_top_burns = non_top_body_refund_burn + non_top_create_credit_burn - 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. - # `non_top_burns` is the inline refund burn at incorporate - # boundaries that does not return to the user's reservoir. - state_gas_at_end = max(reservoir, total_state_charges) - non_top_burns + 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 @@ -1387,13 +1371,11 @@ def test_nested_failure_resets_to_tx_reservoir( expected_header_gas_used = gas_limit_cap - spill elif failure_mode == "revert": # Revert preserves gas_left; full state-gas refund. - # User pays only regular costs + intrinsic + non-top burns. - expected_cumulative = intrinsic_cost + sum_regular + non_top_burns + # User pays only regular costs + 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 - # regular gas usage shows up. The refund burn lives in the - # `state_gas_left` shortfall (visible in cumulative), not - # the regular accumulator. + # regular gas usage shows up. expected_header_gas_used = intrinsic_cost + sum_regular else: raise ValueError("Invariant, unreachable code.") @@ -1842,3 +1824,79 @@ def test_set_and_clear_pays_no_state_gas( # never touched because frame-end byte_delta was zero. post = {contract: Account(storage={0: 0})} state_test(pre=pre, post=post, tx=tx) + + +@pytest.mark.parametrize( + "spill_mode", + [ + pytest.param("no_spill", id="no_spill"), + pytest.param("spill", id="spill"), + ], +) +@pytest.mark.valid_from("EIP8037") +def test_subcall_set_clear_revert_pays_no_state_gas( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + spill_mode: str, +) -> None: + """ + A child frame doing SSTORE 0 to x to 0 then REVERT must bill the + sender only intrinsic + regular costs. + + Both SSTOREs roll back with the REVERT, so the matching + state-gas charge and refund cancel cleanly. The receipt's + `cumulative_gas_used` equals the regular baseline; a leftover + `sstore_state_gas` would surface a double-charge at the failure + boundary. + + `spill_mode` toggles whether the set draws from the reservoir + directly (`no_spill`, reservoir sized to `sstore_state_gas`) or + spills into `gas_left` (`spill`, reservoir = 0). + """ + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() + sstore_state_gas = fork.sstore_state_gas() + gas_limit_cap = fork.transaction_gas_limit_cap() + assert gas_limit_cap is not None + + set_op = Op.SSTORE.with_metadata( + key_warm=False, + original_value=0, + current_value=0, + new_value=1, + )(0, 1) + clear_op = Op.SSTORE.with_metadata( + key_warm=True, + original_value=0, + current_value=1, + new_value=0, + )(0, 0) + inner_code = set_op + clear_op + Op.REVERT(0, 0) + inner = pre.deploy_contract(code=inner_code) + + top_code = Op.POP(Op.CALL(gas=Op.GAS, address=inner)) + Op.STOP + top = pre.deploy_contract(code=top_code) + + reservoir = 0 if spill_mode == "spill" else sstore_state_gas + tx_gas = gas_limit_cap + reservoir + + expected_cumulative = ( + intrinsic_cost + + top_code.regular_cost(fork) + + inner_code.regular_cost(fork) + ) + + tx = Transaction( + to=top, + gas_limit=tx_gas, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_cumulative, + ), + ) + state_test( + pre=pre, + post={top: Account(), inner: Account(storage={0: 0})}, + tx=tx, + blockchain_test_header_verify=Header(gas_used=expected_cumulative), + ) 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 0001613dce6..481aa8d0890 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 @@ -828,12 +828,15 @@ def test_sstore_restoration_sub_frame_revert( call_opcode: Op, ) -> None: """ - Verify 0 to x to 0 reservoir refund unwinds on sub-frame REVERT. - - The sub-call performs 0 to x to 0 then REVERTs. If the reservoir - refund is not rolled back with the reverted frame, the reservoir - stays inflated by `sstore_state_gas`. A single-SSTORE probe sized - to OOG by 1 would then succeed; the test asserts it OOGs. + 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. """ gas_costs = fork.gas_costs() # Probe SSTORE(0, 1): 2 pushes + cold storage write + state gas - 1, @@ -853,7 +856,7 @@ def test_sstore_restoration_sub_frame_revert( # and REVERT without a hard-coded budget. caller_storage = Storage() caller_code = Op.POP(call_opcode(gas=Op.GAS, address=child)) + Op.SSTORE( - caller_storage.store_next(0, "probe_must_fail"), + caller_storage.store_next(1, "probe_must_succeed"), Op.CALL(gas=probe_gas, address=probe), ) caller = pre.deploy_contract(code=caller_code) @@ -869,25 +872,29 @@ def test_sstore_restoration_sub_frame_revert( state_test(pre=pre, tx=tx, post=post) +@pytest.mark.with_all_call_opcodes( + selector=lambda call_opcode: call_opcode != Op.STATICCALL +) @pytest.mark.valid_from("EIP8037") def test_sstore_restoration_ancestor_revert( state_test: StateTestFiller, pre: Alloc, fork: Fork, + call_opcode: Op, ) -> None: """ - Verify the SSTORE 0 to x to 0 refund unwinds when an ancestor frame - (not the applying frame itself) reverts. + 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 - refund propagates to middle via `incorporate_child_on_success`. - Middle then REVERTs; its refund must be dropped by the caller's - `incorporate_child_on_error`, rather than propagating up. This - exercises the recursive scope that single-frame revert tests do - not: a bug in the success propagation of `state_gas_refund` would - leak the refund into the caller's reservoir. + `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`. """ 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_gas = ( @@ -897,24 +904,46 @@ def test_sstore_restoration_ancestor_revert( - 1 ) - inner = pre.deploy_contract( - code=Op.SSTORE(0, 1) + Op.SSTORE(0, 0) + Op.STOP, - ) - middle = pre.deploy_contract( - code=Op.POP(Op.CALL(gas=Op.GAS, address=inner)) + Op.REVERT(0, 0), - ) - probe = pre.deploy_contract(code=Op.SSTORE(0, 1)) + set_op = Op.SSTORE.with_metadata( + key_warm=False, + original_value=0, + current_value=0, + new_value=1, + )(0, 1) + clear_op = Op.SSTORE.with_metadata( + key_warm=True, + original_value=0, + current_value=1, + new_value=0, + )(0, 0) + inner_code = set_op + clear_op + Op.STOP + inner = pre.deploy_contract(code=inner_code) + + middle_code = Op.POP(Op.CALL(gas=Op.GAS, address=inner)) + Op.REVERT(0, 0) + middle = pre.deploy_contract(code=middle_code) + + probe_code = Op.SSTORE(0, 1) + probe = pre.deploy_contract(code=probe_code) caller_storage = Storage() - caller = pre.deploy_contract( - code=( - Op.POP(Op.CALL(gas=Op.GAS, address=middle)) - + Op.SSTORE( - caller_storage.store_next(0, "probe_must_fail"), - Op.CALL(gas=probe_gas, address=probe), - ) - ), + caller_code = Op.POP(call_opcode(gas=Op.GAS, address=middle)) + Op.SSTORE( + caller_storage.store_next(1, "probe_must_succeed"), + 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). + expected_regular = ( + intrinsic_cost + + caller_code.regular_cost(fork) + + middle_code.regular_cost(fork) + + inner_code.regular_cost(fork) + + probe_code.regular_cost(fork) ) + expected_state = 2 * fork.sstore_state_gas() + expected_gas_used = max(expected_regular, expected_state) # gas_limit at the cap means the caller's reservoir starts at 0. tx = Transaction( @@ -923,8 +952,112 @@ def test_sstore_restoration_ancestor_revert( gas_limit=fork.transaction_gas_limit_cap(), ) - post = {caller: Account(storage=caller_storage)} - state_test(pre=pre, tx=tx, post=post) + state_test( + pre=pre, + tx=tx, + post={caller: Account(storage=caller_storage)}, + blockchain_test_header_verify=Header(gas_used=expected_gas_used), + ) + + +@pytest.mark.with_all_call_opcodes( + selector=lambda call_opcode: call_opcode in (Op.DELEGATECALL, Op.CALLCODE) +) +@pytest.mark.valid_from("EIP8037") +def test_sstore_restoration_charge_in_ancestor_intermediate_revert( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + call_opcode: Op, +) -> None: + """ + Verify a deferred refund applied in an intermediate frame still + flows back to the caller when that frame REVERTs. + + Caller's SSTORE charges; the matching clear in inner is deferred + through the chain and lands on middle's own SSTORE-set during + `incorporate_child_on_success`. Middle REVERTs; the applied + amount must reach the caller via `incorporate_child_on_error`. + A probe SSTORE sized to OOG by 1 detects loss. + """ + gas_costs = fork.gas_costs() + sstore_state_gas = fork.sstore_state_gas() + gas_limit_cap = fork.transaction_gas_limit_cap() + assert gas_limit_cap is not None + 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_gas = ( + 2 * gas_costs.VERY_LOW + + gas_costs.COLD_STORAGE_WRITE + + sstore_state_gas + - 1 + ) + + inner_code = ( + Op.SSTORE.with_metadata( + key_warm=True, + original_value=0, + current_value=1, + new_value=0, + )(0, 0) + + Op.STOP + ) + inner = pre.deploy_contract(code=inner_code) + + # Middle's own SSTORE on slot 1 supplies the `state_gas_used` + # that inner's deferred credit lands on, then middle REVERTs. + middle_code = ( + Op.SSTORE(1, 1) + + Op.POP(call_opcode(gas=Op.GAS, address=inner)) + + Op.REVERT(0, 0) + ) + middle = pre.deploy_contract(code=middle_code) + + probe_code = Op.SSTORE(0, 1) + probe = pre.deploy_contract(code=probe_code) + + caller_storage = Storage() + caller_code = ( + Op.SSTORE(caller_storage.store_next(1, "caller_set_persists"), 1) + + Op.POP(call_opcode(gas=Op.GAS, address=middle)) + + Op.SSTORE( + caller_storage.store_next(1, "probe_must_succeed"), + Op.CALL(gas=probe_gas, address=probe), + ) + ) + caller = pre.deploy_contract(code=caller_code) + + # Block state gas commits: caller's slot-0 set + probe's + # SSTORE-set + caller's outer SSTORE-set on slot 1. Middle's + # own slot-1 set is washed by inner's deferred credit before + # middle reverts, so it does not propagate. Header gas_used + # is max(regular, state). + expected_regular = ( + intrinsic_cost + + caller_code.regular_cost(fork) + + middle_code.regular_cost(fork) + + inner_code.regular_cost(fork) + + probe_code.regular_cost(fork) + ) + expected_state = 3 * sstore_state_gas + expected_gas_used = max(expected_regular, expected_state) + + # Reservoir = 2 * sstore_state_gas covers caller's and middle's + # sets; the deferred credit refills middle by sstore_state_gas, + # which flows to the caller on revert. + tx = Transaction( + sender=pre.fund_eoa(), + to=caller, + gas_limit=gas_limit_cap + 2 * sstore_state_gas, + ) + + state_test( + pre=pre, + tx=tx, + post={caller: Account(storage=caller_storage)}, + blockchain_test_header_verify=Header(gas_used=expected_gas_used), + ) @pytest.mark.with_all_create_opcodes @@ -936,16 +1069,17 @@ def test_sstore_restoration_create_init_revert( create_opcode: Op, ) -> None: """ - Verify reservoir refunds unwind when CREATE init code REVERTs - inside a sub-frame that also REVERTs. + 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, every refund that - occurred inside it must unwind, leaving the caller's reservoir at - its pre-call value. A single-SSTORE probe sized to OOG by 1 - detects any leaked refund. + 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. """ gas_costs = fork.gas_costs() # Probe SSTORE(0, 1): 2 pushes + cold storage write + state gas - 1, @@ -965,9 +1099,7 @@ def test_sstore_restoration_create_init_revert( else: create_call = Op.CREATE2(0, 0, len(init_code), 0) - # Inner contract performs the CREATE then REVERTs, so any refunds - # (SSTORE restoration or CREATE silent-failure) applied during its - # execution must unwind with the frame. + # Inner contract performs the CREATE then REVERTs. inner = pre.deploy_contract( code=( Op.MSTORE( @@ -985,7 +1117,7 @@ def test_sstore_restoration_create_init_revert( code=( Op.POP(Op.CALL(gas=Op.GAS, address=inner)) + Op.SSTORE( - caller_storage.store_next(0, "probe_must_fail"), + caller_storage.store_next(1, "probe_must_succeed"), Op.CALL(gas=probe_gas, address=probe), ) ),