Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions docs/writing_tests/post_mortems.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 6 additions & 9 deletions src/ethereum/forks/amsterdam/fork.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 42 additions & 20 deletions src/ethereum/forks/amsterdam/vm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand All @@ -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)


Expand All @@ -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)
Expand All @@ -234,19 +237,42 @@ 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,
) -> None:
"""
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
----------
Expand All @@ -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


Expand Down
1 change: 1 addition & 0 deletions src/ethereum/forks/amsterdam/vm/gas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions src/ethereum/forks/amsterdam/vm/instructions/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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))

Expand Down
13 changes: 12 additions & 1 deletion src/ethereum/forks/amsterdam/vm/interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
get_account,
get_code,
increment_nonce,
is_account_alive,
mark_account_created,
move_ether,
restore_tx_state,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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(
Expand All @@ -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 != ():
Expand Down Expand Up @@ -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,
)


Expand Down Expand Up @@ -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""
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading