diff --git a/packages/testing/src/execution_testing/__init__.py b/packages/testing/src/execution_testing/__init__.py index 6b079cd3d5d..87e87352186 100644 --- a/packages/testing/src/execution_testing/__init__.py +++ b/packages/testing/src/execution_testing/__init__.py @@ -31,6 +31,7 @@ ) from .fixtures import BaseFixture, FixtureCollector from .forks import Fork, GasCosts, RefundTypes, TransitionFork +from .recipient_type import RecipientType from .specs import ( BaseTest, BenchmarkTest, @@ -195,6 +196,7 @@ "OpcodeCallArg", "Opcodes", "ParameterSet", + "RecipientType", "ReferenceSpec", "ReferenceSpecTypes", "RefundTypes", diff --git a/packages/testing/src/execution_testing/forks/base_fork.py b/packages/testing/src/execution_testing/forks/base_fork.py index 6449ced32d4..7d2bafc517d 100644 --- a/packages/testing/src/execution_testing/forks/base_fork.py +++ b/packages/testing/src/execution_testing/forks/base_fork.py @@ -32,6 +32,7 @@ Opcodes, ) +from ..recipient_type import RecipientType from .gas_costs import GasCosts @@ -116,6 +117,8 @@ def __call__( access_list: List[AccessList] | None = None, authorization_list_or_count: Sized | int | None = None, return_cost_deducted_prior_execution: bool = False, + sends_value: bool = False, + recipient_type: RecipientType = RecipientType.CONTRACT, ) -> int: """ Return the intrinsic gas cost of a transaction given its properties. @@ -135,6 +138,14 @@ def __call__( that is deducted from the gas limit before the transaction starts execution. + sends_value: Whether the transaction transfers a non-zero value. + Forks that itemize the value-transfer charge in + intrinsic gas use this flag; ignored by older forks. + recipient_type: Category of the transaction recipient. Forks + that vary intrinsic gas by recipient kind + (e.g. no access cost for precompiles, no value + charge for self-transfers) use this; ignored + by older forks. Returns: Gas cost of a transaction @@ -142,6 +153,49 @@ def __call__( pass +class TopFrameGasCalculator(Protocol): + """ + A protocol to calculate the additional regular gas charged at the + top-level transaction frame, after intrinsic gas is deducted but + before EVM execution begins. + + Returns only the regular-gas portion of the post-intrinsic + state-aware preparation (e.g. the delegated-recipient access + charge). The state-gas portion is exposed separately by + ``BaseFork.transaction_top_frame_state_gas`` so tests can model the + two-dimensional reservoir explicitly or sum the two via + ``oog_budget_lift`` when targeting the spillover boundary. + + Returns 0 for forks that do not perform any such preparation. + """ + + def __call__( + self, + *, + contract_creation: bool = False, + sends_value: bool = False, + recipient_type: RecipientType = RecipientType.CONTRACT, + ) -> int: + """ + Return the regular gas consumed by top-frame preparation for a + transaction at this fork. + + Args: + contract_creation: Whether the transaction creates a contract. + Top-frame charges are zero for creates; + equivalent charges are paid via intrinsic + gas. + sends_value: Whether the transaction transfers a non-zero + value. + recipient_type: Category of the transaction recipient. + Drives the conditional charges. + + Returns: Regular gas added by top-frame preparation. + + """ + pass + + class BlobGasPriceCalculator(Protocol): """ A protocol to calculate the blob gas price given the excess blob gas at a @@ -705,6 +759,51 @@ def transaction_intrinsic_state_gas( del contract_creation, authorization_count return 0 + @classmethod + def transaction_top_frame_gas_calculator( + cls, + ) -> TopFrameGasCalculator: + """ + Return a callable that calculates the additional regular gas + charged at the top-level transaction frame, after intrinsic + gas is deducted but before EVM execution begins. + + Defaults to returning 0 for forks that do not perform such + post-intrinsic preparation. + """ + + def fn( + *, + contract_creation: bool = False, + sends_value: bool = False, + recipient_type: RecipientType = RecipientType.CONTRACT, + ) -> int: + del contract_creation, sends_value, recipient_type + return 0 + + return fn + + @classmethod + def transaction_top_frame_state_gas( + cls, + *, + contract_creation: bool = False, + sends_value: bool = False, + recipient_type: RecipientType = RecipientType.CONTRACT, + ) -> int: + """ + Return the state gas charged at the top-level transaction + frame, after intrinsic gas is deducted but before EVM execution + begins. Companion to ``transaction_top_frame_gas_calculator``; + tests targeting the spillover boundary feed this through + ``oog_budget_lift`` to get the equivalent regular-gas budget. + + Defaults to 0 for forks that do not perform such + post-intrinsic preparation. + """ + del contract_creation, sends_value, recipient_type + return 0 + @classmethod def system_call_gas_limit(cls) -> int: """ diff --git a/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_2780.py b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_2780.py new file mode 100644 index 00000000000..8ef87da63b0 --- /dev/null +++ b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_2780.py @@ -0,0 +1,156 @@ +""" +EIP-2780: Resource-based intrinsic transaction gas. + +Decompose the intrinsic transaction gas into explicit recipient-access +and value-transfer primitives so that the cost paid before execution +reflects the actual work the transaction will perform. + +https://eips.ethereum.org/EIPS/eip-2780 +""" + +from dataclasses import replace +from typing import List, Sized + +from execution_testing.base_types import AccessList +from execution_testing.base_types.conversions import BytesConvertible + +from .....recipient_type import RecipientType +from ....base_fork import ( + BaseFork, + TopFrameGasCalculator, + TransactionIntrinsicCostCalculator, +) +from ....gas_costs import GasCosts + + +class EIP2780(BaseFork): + """EIP-2780 class.""" + + @classmethod + def gas_costs(cls) -> GasCosts: + """ + Lower ``TX_BASE`` to 12_000 to reflect the removal of the + bundled recipient access and account-write charges, and add + the transfer-log and value-transfer constants. + """ + parent = super(EIP2780, cls).gas_costs() + return replace( + parent, + TX_BASE=12_000, + TRANSFER_LOG_COST=1_756, + TX_VALUE_COST=4_244, + ) + + @classmethod + def transaction_intrinsic_cost_calculator( + cls, + ) -> TransactionIntrinsicCostCalculator: + """ + Decompose intrinsic gas into explicit recipient and + value-transfer primitives. + + Non-create, non-self targets pay ``COLD_ACCOUNT_ACCESS`` + unconditionally; access lists do not warm transaction-level + accounts. Value-bearing transactions pay + ``TRANSFER_LOG_COST`` plus ``TX_VALUE_COST``; self-transfers + suppress the value-transfer charge entirely. + """ + super_fn = super(EIP2780, cls).transaction_intrinsic_cost_calculator() + gas_costs = cls.gas_costs() + + def fn( + *, + calldata: BytesConvertible = b"", + contract_creation: bool = False, + access_list: List[AccessList] | None = None, + authorization_list_or_count: Sized | int | None = None, + return_cost_deducted_prior_execution: bool = False, + sends_value: bool = False, + recipient_type: RecipientType = RecipientType.CONTRACT, + ) -> int: + intrinsic_cost: int = super_fn( + calldata=calldata, + contract_creation=contract_creation, + access_list=access_list, + authorization_list_or_count=authorization_list_or_count, + return_cost_deducted_prior_execution=True, + ) + + is_self_transfer = recipient_type == RecipientType.SELF + + if contract_creation: + if sends_value: + intrinsic_cost += gas_costs.TRANSFER_LOG_COST + elif not is_self_transfer: + intrinsic_cost += gas_costs.COLD_ACCOUNT_ACCESS + if sends_value: + intrinsic_cost += ( + gas_costs.TRANSFER_LOG_COST + gas_costs.TX_VALUE_COST + ) + + if return_cost_deducted_prior_execution: + return intrinsic_cost + + transaction_data_floor_cost_calculator = ( + cls.transaction_data_floor_cost_calculator() + ) + transaction_floor_data_cost = ( + transaction_data_floor_cost_calculator( + data=calldata, access_list=access_list + ) + ) + return max(intrinsic_cost, transaction_floor_data_cost) + + return fn + + @classmethod + def transaction_top_frame_gas_calculator( + cls, + ) -> TopFrameGasCalculator: + """ + Return the additional regular gas charged at the top-level + transaction frame, after intrinsic gas is deducted but before + the EVM dispatches. + + Charges ``COLD_ACCOUNT_ACCESS`` when the recipient is an + existing delegated account. The empty-recipient + ``NEW_ACCOUNT`` charge is state gas, returned separately by + ``transaction_top_frame_state_gas``. + """ + gas_costs = cls.gas_costs() + + def fn( + *, + contract_creation: bool = False, + sends_value: bool = False, + recipient_type: RecipientType = RecipientType.CONTRACT, + ) -> int: + del sends_value + if contract_creation: + return 0 + + if recipient_type == RecipientType.DELEGATION_7702: + return gas_costs.COLD_ACCOUNT_ACCESS + return 0 + + return fn + + @classmethod + def transaction_top_frame_state_gas( + cls, + *, + contract_creation: bool = False, + sends_value: bool = False, + recipient_type: RecipientType = RecipientType.CONTRACT, + ) -> int: + """ + Return the state gas charged at the top-level transaction + frame. Charges ``NEW_ACCOUNT`` when value is transferred to an + empty recipient; zero otherwise. + """ + gas_costs = cls.gas_costs() + if contract_creation: + return 0 + if sends_value and recipient_type == RecipientType.EMPTY_ACCOUNT: + return gas_costs.NEW_ACCOUNT + return 0 diff --git a/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7981.py b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7981.py index b2f927b507c..4aef4807938 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7981.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7981.py @@ -11,6 +11,7 @@ from execution_testing.base_types import AccessList from execution_testing.base_types.conversions import BytesConvertible +from .....recipient_type import RecipientType from ....base_fork import ( BaseFork, TransactionDataFloorCostCalculator, @@ -85,7 +86,11 @@ def fn( access_list: List[AccessList] | None = None, authorization_list_or_count: Sized | int | None = None, return_cost_deducted_prior_execution: bool = False, + sends_value: bool = False, + recipient_type: RecipientType = RecipientType.CONTRACT, ) -> int: + del sends_value, recipient_type + intrinsic_cost: int = super_fn( calldata=calldata, contract_creation=contract_creation, diff --git a/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_8037.py b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_8037.py index f1c2a4a4bda..c9f5fd170be 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_8037.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_8037.py @@ -4,6 +4,13 @@ Harmonization, increase and separate metering of state creation gas costs to mitigate state growth and unblock scaling. +The companion EIP-8038 state-access repricing lives in its own `EIP8038` +mixin. Because the EIP mixins are ordered by number, `EIP8037` sits +immediately above `EIP8038` in the MRO, so `super().gas_costs()` here +returns the EIP-8038 schedule and this mixin folds its state-creation gas +into the shared `STORAGE_SET`, `TX_CREATE`, and `AUTH_PER_EMPTY_ACCOUNT` +totals on top of it. + https://eips.ethereum.org/EIPS/eip-8037 """ @@ -23,9 +30,6 @@ STATE_BYTES_PER_STORAGE_SET = 64 STATE_BYTES_PER_AUTH_BASE = 23 -PER_AUTH_BASE_COST = 7_500 -REGULAR_GAS_CREATE = 9_000 - SYSTEM_MAX_SSTORES_PER_CALL = 16 @@ -74,25 +78,23 @@ def create_state_gas(cls, *, code_size: int = 0) -> int: @classmethod def gas_costs(cls) -> GasCosts: """ - Return gas costs updated for two-dimensional gas metering, - with state gas folded into the relevant totals. + Return gas costs with the EIP-8037 state-creation gas folded + into the relevant totals, layered on top of the EIP-8038 + state-access repricing returned by `super().gas_costs()`. """ cpsb = cls.cost_per_state_byte() parent = super(EIP8037, cls).gas_costs() new_acct = STATE_BYTES_PER_NEW_ACCOUNT * cpsb + return replace( parent, - BLOCK_ACCESS_LIST_ITEM=2000, STORAGE_SET=( - parent.COLD_STORAGE_WRITE - - parent.COLD_STORAGE_ACCESS - + STATE_BYTES_PER_STORAGE_SET * cpsb + parent.STORAGE_SET + STATE_BYTES_PER_STORAGE_SET * cpsb ), NEW_ACCOUNT=new_acct, - OPCODE_CREATE_BASE=REGULAR_GAS_CREATE, - TX_CREATE=(REGULAR_GAS_CREATE + new_acct), + TX_CREATE=parent.TX_CREATE + new_acct, AUTH_PER_EMPTY_ACCOUNT=( - PER_AUTH_BASE_COST + parent.AUTH_PER_EMPTY_ACCOUNT + (STATE_BYTES_PER_NEW_ACCOUNT + STATE_BYTES_PER_AUTH_BASE) * cpsb ), @@ -144,6 +146,9 @@ def opcode_state_map( Opcodes.CREATE2: lambda op: cls._calculate_create_state_gas( op, gas_costs ), + Opcodes.SELFDESTRUCT: ( + lambda op: cls._calculate_selfdestruct_state_gas(op, gas_costs) + ), } @classmethod @@ -250,36 +255,6 @@ def transaction_intrinsic_state_gas( ) return state_gas - @classmethod - def _calculate_sstore_gas( - cls, opcode: OpcodeBase, gas_costs: GasCosts - ) -> int: - """ - Calculate the regular SSTORE gas cost. The state portion is - returned separately by `_calculate_sstore_state_gas`. A cold - slot adds `COLD_STORAGE_ACCESS`, a write to an unchanged - original adds `COLD_STORAGE_WRITE` minus `COLD_STORAGE_ACCESS`, - and every other case adds `WARM_SLOAD`. - """ - metadata = opcode.metadata - - original_value = metadata["original_value"] - current_value = metadata["current_value"] - if current_value is None: - current_value = original_value - new_value = metadata["new_value"] - - gas_cost = 0 if metadata["key_warm"] else gas_costs.COLD_STORAGE_ACCESS - - if original_value == current_value and current_value != new_value: - gas_cost += ( - gas_costs.COLD_STORAGE_WRITE - gas_costs.COLD_STORAGE_ACCESS - ) - else: - gas_cost += gas_costs.WARM_SLOAD - - return gas_cost - @classmethod def _calculate_sstore_state_gas( cls, opcode: OpcodeBase, gas_costs: GasCosts @@ -307,39 +282,6 @@ def _calculate_sstore_state_gas( return STATE_BYTES_PER_STORAGE_SET * cpsb return 0 - @classmethod - def _calculate_sstore_refund( - cls, opcode: OpcodeBase, gas_costs: GasCosts - ) -> int: - """ - Calculate the regular SSTORE gas refund. The state portion is - returned separately by `_calculate_sstore_state_refund`. - """ - metadata = opcode.metadata - - original_value = metadata["original_value"] - current_value = metadata["current_value"] - if current_value is None: - current_value = original_value - new_value = metadata["new_value"] - - refund = 0 - if current_value != new_value: - if original_value != 0 and current_value != 0 and new_value == 0: - refund += gas_costs.REFUND_STORAGE_CLEAR - - if original_value != 0 and current_value == 0: - refund -= gas_costs.REFUND_STORAGE_CLEAR - - if original_value == new_value: - refund += ( - gas_costs.COLD_STORAGE_WRITE - - gas_costs.COLD_STORAGE_ACCESS - - gas_costs.WARM_SLOAD - ) - - return refund - @classmethod def _calculate_sstore_state_refund( cls, opcode: OpcodeBase, gas_costs: GasCosts @@ -444,3 +386,39 @@ def _calculate_create_state_gas( """ del opcode return gas_costs.NEW_ACCOUNT + + @classmethod + def _calculate_selfdestruct_state_gas( + cls, opcode: OpcodeBase, gas_costs: GasCosts + ) -> int: + """ + Calculate the SELFDESTRUCT state gas cost: `NEW_ACCOUNT` when a + positive balance funds a new account. Before EIP-8037 this was + folded into the regular SELFDESTRUCT cost; under EIP-8037 it is + exposed here as state gas (mirroring `_calculate_create_state_gas`) + so the regular cost matches the spec EVM + (`OPCODE_SELFDESTRUCT_BASE` + account access + the EIP-8038 + `ACCOUNT_WRITE` surcharge). + """ + if opcode.metadata["account_new"]: + return gas_costs.NEW_ACCOUNT + return 0 + + @classmethod + def _calculate_selfdestruct_gas( + cls, opcode: OpcodeBase, gas_costs: GasCosts + ) -> int: + """ + Calculate the regular SELFDESTRUCT gas cost. The Frontier base + calculation folds `NEW_ACCOUNT` into the regular cost when a + positive balance funds a new account; EIP-8038 (the mixin between + the base and EIP-8037 in the MRO) adds only the `ACCOUNT_WRITE` + surcharge. EIP-8037 moves that funding cost to the state-gas + dimension (see `_calculate_selfdestruct_state_gas`), so this + subtracts the `NEW_ACCOUNT` term back out of the inherited regular + cost; the EIP-8038 `ACCOUNT_WRITE` surcharge stays in regular gas. + """ + gas_cost = super()._calculate_selfdestruct_gas(opcode, gas_costs) + if opcode.metadata["account_new"]: + gas_cost -= gas_costs.NEW_ACCOUNT + return gas_cost diff --git a/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_8038.py b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_8038.py new file mode 100644 index 00000000000..26c05c5a0ea --- /dev/null +++ b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_8038.py @@ -0,0 +1,188 @@ +""" +EIP-8038: State Access Gas Cost Increase. + +Harmonization and increase of state-access gas costs, repricing warm and +cold account and storage access, account writes, and the related access +list and authorization costs. + +This mixin ships alongside EIP-8037 in Amsterdam. It carries the +state-access repricing only; the EIP-8037 state-creation gas is folded in +on top by the (lower-numbered, therefore shallower) `EIP8037` mixin, which +reads these values via `super().gas_costs()` and adds its state-byte +portions to the shared `STORAGE_SET`, `TX_CREATE`, and +`AUTH_PER_EMPTY_ACCOUNT` totals. + +https://eips.ethereum.org/EIPS/eip-8038 +""" + +from dataclasses import replace +from typing import Callable, Dict + +from execution_testing.vm import ( + OpcodeBase, + Opcodes, +) + +from ....base_fork import BaseFork +from ....gas_costs import GasCosts + + +class EIP8038(BaseFork): + """EIP-8038 class.""" + + @classmethod + def gas_costs(cls) -> GasCosts: + """ + Return the EIP-8038 state-access gas repricing, layered on top + of the parent fork's schedule. EIP-8037 then folds its + state-creation gas into the relevant totals via + `super().gas_costs()`. + """ + parent = super(EIP8038, cls).gas_costs() + + warm_access = 100 + cold_account_access = 3_000 + cold_storage_access = 3_000 + storage_write = 10_000 + # The framework models the SSTORE write via the compound + # COLD_STORAGE_WRITE (access + write), so preserve the invariant + # COLD_STORAGE_WRITE - COLD_STORAGE_ACCESS == STORAGE_WRITE. + cold_storage_write = cold_storage_access + storage_write + # Surcharge for the first write to an account leaf, introduced as a + # standalone parameter by this repricing. + account_write = 8_000 + create_access = 11_000 + # ecRecover stays PRECOMPILE_ECRECOVER (3000) until EIP-7904 lands. + regular_per_auth_base_cost = ( + 1_616 + 3_000 + cold_account_access + 2 * warm_access + ) + + return replace( + parent, + WARM_ACCESS=warm_access, + WARM_SLOAD=warm_access, + COLD_ACCOUNT_ACCESS=cold_account_access, + COLD_STORAGE_ACCESS=cold_storage_access, + COLD_STORAGE_WRITE=cold_storage_write, + ACCOUNT_WRITE=account_write, + CALL_VALUE=account_write + 2_300, # ACCOUNT_WRITE + CALL_STIPEND + REFUND_STORAGE_CLEAR=12_480, + TX_ACCESS_LIST_ADDRESS=3_000, + TX_ACCESS_LIST_STORAGE_KEY=3_000, + BLOCK_ACCESS_LIST_ITEM=2000, + STORAGE_SET=storage_write, + OPCODE_CREATE_BASE=create_access, + TX_CREATE=create_access, + AUTH_PER_EMPTY_ACCOUNT=account_write + regular_per_auth_base_cost, + ) + + @classmethod + def opcode_gas_map( + cls, + ) -> Dict[OpcodeBase, int | Callable[[OpcodeBase], int]]: + """ + Return the opcode gas map with the EIP-8038 `EXT*` update: + `EXTCODESIZE` and `EXTCODECOPY` charge an extra `WARM_ACCESS` + for the second database read (the code). + """ + gas_costs = cls.gas_costs() + opcode_gas_map = dict(super(EIP8038, cls).opcode_gas_map()) + + def with_extra_warm_access( + inner: int | Callable[[OpcodeBase], int], + ) -> Callable[[OpcodeBase], int]: + def fn(opcode: OpcodeBase) -> int: + inner_gas = inner(opcode) if callable(inner) else inner + return inner_gas + gas_costs.WARM_ACCESS + + return fn + + for opcode in (Opcodes.EXTCODESIZE, Opcodes.EXTCODECOPY): + opcode_gas_map[opcode] = with_extra_warm_access( + opcode_gas_map[opcode] + ) + return opcode_gas_map + + @classmethod + def _calculate_selfdestruct_gas( + cls, opcode: OpcodeBase, gas_costs: GasCosts + ) -> int: + """ + Calculate the regular SELFDESTRUCT gas cost. EIP-8038 adds + `ACCOUNT_WRITE` when a positive balance is sent to an empty + account, on top of the inherited cost (where `NEW_ACCOUNT` + holds the EIP-8037 state-gas portion). + """ + gas_cost = super(EIP8038, cls)._calculate_selfdestruct_gas( + opcode, gas_costs + ) + if opcode.metadata["account_new"]: + gas_cost += gas_costs.ACCOUNT_WRITE + return gas_cost + + @classmethod + def _calculate_sstore_gas( + cls, opcode: OpcodeBase, gas_costs: GasCosts + ) -> int: + """ + Calculate the regular SSTORE gas cost. The state portion is + returned separately by `_calculate_sstore_state_gas`. Under + EIP-8038 the access cost (`COLD_STORAGE_ACCESS` when cold, else + `WARM_SLOAD`) is always charged, and a first-time change to the + slot additionally charges the write cost `STORAGE_WRITE` + (modeled as `COLD_STORAGE_WRITE` minus `COLD_STORAGE_ACCESS`). + """ + metadata = opcode.metadata + + original_value = metadata["original_value"] + current_value = metadata["current_value"] + if current_value is None: + current_value = original_value + new_value = metadata["new_value"] + + gas_cost = ( + gas_costs.WARM_SLOAD + if metadata["key_warm"] + else gas_costs.COLD_STORAGE_ACCESS + ) + + if original_value == current_value and current_value != new_value: + gas_cost += ( + gas_costs.COLD_STORAGE_WRITE - gas_costs.COLD_STORAGE_ACCESS + ) + + return gas_cost + + @classmethod + def _calculate_sstore_refund( + cls, opcode: OpcodeBase, gas_costs: GasCosts + ) -> int: + """ + Calculate the regular SSTORE gas refund. The state portion is + returned separately by `_calculate_sstore_state_refund`. + """ + metadata = opcode.metadata + + original_value = metadata["original_value"] + current_value = metadata["current_value"] + if current_value is None: + current_value = original_value + new_value = metadata["new_value"] + + refund = 0 + if current_value != new_value: + if original_value != 0 and current_value != 0 and new_value == 0: + refund += gas_costs.REFUND_STORAGE_CLEAR + + if original_value != 0 and current_value == 0: + refund -= gas_costs.REFUND_STORAGE_CLEAR + + if original_value == new_value: + # Refund the STORAGE_WRITE charged on the first-time + # change earlier in the transaction. + refund += ( + gas_costs.COLD_STORAGE_WRITE + - gas_costs.COLD_STORAGE_ACCESS + ) + + return refund diff --git a/packages/testing/src/execution_testing/forks/forks/eips/berlin/eip_2930.py b/packages/testing/src/execution_testing/forks/forks/eips/berlin/eip_2930.py index 7dede0ace15..675df0b7f10 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/berlin/eip_2930.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/berlin/eip_2930.py @@ -12,6 +12,7 @@ from execution_testing.base_types import AccessList from execution_testing.base_types.conversions import BytesConvertible +from .....recipient_type import RecipientType from ....base_fork import BaseFork, TransactionIntrinsicCostCalculator @@ -45,8 +46,11 @@ def fn( access_list: List[AccessList] | None = None, authorization_list_or_count: Sized | int | None = None, return_cost_deducted_prior_execution: bool = False, + sends_value: bool = False, + recipient_type: RecipientType = RecipientType.CONTRACT, ) -> int: del return_cost_deducted_prior_execution + del sends_value, recipient_type intrinsic_cost: int = super_fn( calldata=calldata, diff --git a/packages/testing/src/execution_testing/forks/forks/eips/cancun/eip_1153.py b/packages/testing/src/execution_testing/forks/forks/eips/cancun/eip_1153.py index c98a22bc3f4..68133d5e8cd 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/cancun/eip_1153.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/cancun/eip_1153.py @@ -7,16 +7,31 @@ https://eips.ethereum.org/EIPS/eip-1153 """ +from dataclasses import replace from typing import Callable, Dict, List from execution_testing.vm import OpcodeBase, Opcodes from ....base_fork import BaseFork +from ....gas_costs import GasCosts class EIP1153(BaseFork): """EIP-1153 class.""" + @classmethod + def gas_costs(cls) -> GasCosts: + """ + Set dedicated TLOAD and TSTORE gas costs. Transient storage is + in-memory only; its cost matches a warm storage access at + introduction but is independent of state-access pricing. + """ + return replace( + super(EIP1153, cls).gas_costs(), + OPCODE_TLOAD=100, + OPCODE_TSTORE=100, + ) + @classmethod def opcode_gas_map( cls, @@ -26,8 +41,8 @@ def opcode_gas_map( base_map = super(EIP1153, cls).opcode_gas_map() return { **base_map, - Opcodes.TLOAD: gas_costs.WARM_SLOAD, - Opcodes.TSTORE: gas_costs.WARM_SLOAD, + Opcodes.TLOAD: gas_costs.OPCODE_TLOAD, + Opcodes.TSTORE: gas_costs.OPCODE_TSTORE, } @classmethod diff --git a/packages/testing/src/execution_testing/forks/forks/eips/homestead/eip_2.py b/packages/testing/src/execution_testing/forks/forks/eips/homestead/eip_2.py index 811094f0a98..2f4f8385d3f 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/homestead/eip_2.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/homestead/eip_2.py @@ -9,6 +9,7 @@ from execution_testing.base_types import AccessList from execution_testing.base_types.conversions import BytesConvertible +from .....recipient_type import RecipientType from ....base_fork import BaseFork, TransactionIntrinsicCostCalculator @@ -33,8 +34,11 @@ def fn( access_list: List[AccessList] | None = None, authorization_list_or_count: Sized | int | None = None, return_cost_deducted_prior_execution: bool = False, + sends_value: bool = False, + recipient_type: RecipientType = RecipientType.CONTRACT, ) -> int: del return_cost_deducted_prior_execution + del sends_value, recipient_type intrinsic_cost: int = super_fn( calldata=calldata, diff --git a/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_7623.py b/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_7623.py index d52c5601a16..6b82b788379 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_7623.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_7623.py @@ -12,6 +12,7 @@ from execution_testing.base_types import AccessList, Bytes from execution_testing.base_types.conversions import BytesConvertible +from .....recipient_type import RecipientType from ....base_fork import ( BaseFork, CalldataGasCalculator, @@ -95,7 +96,11 @@ def fn( access_list: List[AccessList] | None = None, authorization_list_or_count: Sized | int | None = None, return_cost_deducted_prior_execution: bool = False, + sends_value: bool = False, + recipient_type: RecipientType = RecipientType.CONTRACT, ) -> int: + del sends_value, recipient_type + intrinsic_cost: int = super_fn( calldata=calldata, contract_creation=contract_creation, diff --git a/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_7702.py b/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_7702.py index 424c4ab2dab..7ce6a0876bf 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_7702.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_7702.py @@ -13,6 +13,7 @@ from execution_testing.base_types.conversions import BytesConvertible from execution_testing.vm import OpcodeBase +from .....recipient_type import RecipientType from ....base_fork import ( BaseFork, RefundTypes, @@ -74,7 +75,11 @@ def fn( access_list: List[AccessList] | None = None, authorization_list_or_count: Sized | int | None = None, return_cost_deducted_prior_execution: bool = False, + sends_value: bool = False, + recipient_type: RecipientType = RecipientType.CONTRACT, ) -> int: + del sends_value, recipient_type + intrinsic_cost: int = super_fn( calldata=calldata, contract_creation=contract_creation, diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index 04fc4838045..168a4b19e2b 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -21,6 +21,7 @@ Opcodes, ) +from ...recipient_type import RecipientType from ..base_fork import ( BaseFeeChangeCalculator, BaseFeePerGasCalculator, @@ -872,8 +873,11 @@ def fn( access_list: List[AccessList] | None = None, authorization_list_or_count: Sized | int | None = None, return_cost_deducted_prior_execution: bool = False, + sends_value: bool = False, + recipient_type: RecipientType = RecipientType.CONTRACT, ) -> int: del return_cost_deducted_prior_execution + del sends_value, recipient_type assert access_list is None, ( f"Access list is not supported in {cls.name()}" diff --git a/packages/testing/src/execution_testing/forks/gas_costs.py b/packages/testing/src/execution_testing/forks/gas_costs.py index fcb8148cca0..a899d5a129a 100644 --- a/packages/testing/src/execution_testing/forks/gas_costs.py +++ b/packages/testing/src/execution_testing/forks/gas_costs.py @@ -36,6 +36,10 @@ class GasCosts: CALL_VALUE: int CALL_STIPEND: int NEW_ACCOUNT: int + ACCOUNT_WRITE: int = 0 + CREATE_ACCESS: int = 0 + TRANSFER_LOG_COST: int = 0 + TX_VALUE_COST: int = 0 # Contract Creation CODE_DEPOSIT_PER_BYTE: int @@ -146,3 +150,5 @@ class GasCosts: OPCODE_BLOBHASH: int = 0 OPCODE_MCOPY_BASE: int = 0 OPCODE_CLZ: int = 0 + OPCODE_TLOAD: int = 0 + OPCODE_TSTORE: int = 0 diff --git a/packages/testing/src/execution_testing/recipient_type.py b/packages/testing/src/execution_testing/recipient_type.py new file mode 100644 index 00000000000..f1bfe8770bb --- /dev/null +++ b/packages/testing/src/execution_testing/recipient_type.py @@ -0,0 +1,14 @@ +"""Recipient type enumeration for transaction gas calculations.""" + +from enum import Enum, auto + + +class RecipientType(Enum): + """The type of recipient for a transaction.""" + + SELF = auto() + EOA = auto() + CONTRACT = auto() + DELEGATION_7702 = auto() + PRECOMPILE = auto() + EMPTY_ACCOUNT = auto() diff --git a/packages/testing/src/execution_testing/tools/utility/generators.py b/packages/testing/src/execution_testing/tools/utility/generators.py index 028a114a6c4..a70b3831ce2 100644 --- a/packages/testing/src/execution_testing/tools/utility/generators.py +++ b/packages/testing/src/execution_testing/tools/utility/generators.py @@ -409,7 +409,6 @@ def wrapper( test_tx = Transaction( to=value_receiver, value=1, - gas_limit=100_000, sender=pre.fund_eoa(), ) post = Alloc() diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 245161e2e1c..c2001554bd1 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -503,8 +503,9 @@ def check_transaction( block_env: vm.BlockEnvironment, block_output: vm.BlockOutput, tx: Transaction, + sender: Address, tx_state: TransactionState, -) -> Tuple[Address, Uint, Tuple[VersionedHash, ...], U64]: +) -> Tuple[Uint, Tuple[VersionedHash, ...], U64]: """ Check if the transaction is includable in the block. @@ -516,13 +517,13 @@ def check_transaction( The block output for the current block. tx : The transaction. + sender : + The recovered sender address of the transaction. tx_state : The transaction state tracker. Returns ------- - sender_address : - The sender of the transaction. effective_gas_price : The price to charge for gas when the transaction is executed. blob_versioned_hashes : @@ -584,15 +585,7 @@ def check_transaction( if tx_blob_gas_used > blob_gas_available: raise BlobGasLimitExceededError("blob gas limit exceeded") - tx_chain_id = chain_id(tx) - if tx_chain_id is not None and tx_chain_id != block_env.chain_id: - raise WrongChainIdError( - expected=block_env.chain_id, - actual=tx_chain_id, - ) - - sender_address = recover_sender(tx) - sender_account = get_account(tx_state, sender_address) + sender_account = get_account(tx_state, sender) if isinstance(tx, FeeMarketCapableTransaction): if tx.max_fee_per_gas < tx.max_priority_fee_per_gas: @@ -665,7 +658,6 @@ def check_transaction( raise InvalidSenderError("not EOA") return ( - sender_address, effective_gas_price, blob_versioned_hashes, tx_blob_gas_used, @@ -800,6 +792,8 @@ def process_unchecked_system_transaction( tx_env = vm.TransactionEnvironment( origin=SYSTEM_ADDRESS, + recipient=target_address, + value=U256(0), gas_price=block_env.base_fee_per_gas, gas=SYSTEM_TRANSACTION_GAS, state_gas_reservoir=( @@ -1030,12 +1024,19 @@ def process_transaction( encode_transaction(tx), ) - intrinsic = validate_transaction(tx) + tx_chain_id = chain_id(tx) + if tx_chain_id is not None and tx_chain_id != block_env.chain_id: + raise WrongChainIdError( + expected=block_env.chain_id, + actual=tx_chain_id, + ) + + sender = recover_sender(tx) + intrinsic = validate_transaction(tx, sender) intrinsic_gas = Uint(intrinsic.regular) + Uint(intrinsic.state) ( - sender, effective_gas_price, blob_versioned_hashes, tx_blob_gas_used, @@ -1043,6 +1044,7 @@ def process_transaction( block_env=block_env, block_output=block_output, tx=tx, + sender=sender, tx_state=tx_state, ) @@ -1084,6 +1086,8 @@ def process_transaction( tx_env = vm.TransactionEnvironment( origin=sender, + recipient=tx.to, + value=tx.value, gas_price=effective_gas_price, gas=gas, state_gas_reservoir=state_gas_reservoir, diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index cda7bbf53ea..5f7d0eaf33c 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -92,6 +92,73 @@ class TransactionState: ) +def get_pre_state_account_optional( + tx_state: TransactionState, address: Address +) -> Optional[Account]: + """ + Get the `Account` object at an address that existed before the current + transaction, or `None` (rather than [`EMPTY_ACCOUNT`]) if there was no + account at the address at that point. + + Use [`get_pre_state_account()`][pre] if the difference between a + non-existent account and [`EMPTY_ACCOUNT`] isn't important. + + [`EMPTY_ACCOUNT`]: ref:ethereum.state.EMPTY_ACCOUNT + [pre]: ref:ethereum.forks.amsterdam.state_tracker.get_pre_state_account + + Parameters + ---------- + tx_state : + The transaction state. + address : + Address to look up. + + Returns + ------- + account : ``Optional[Account]`` + Account at address before the current transaction. + + """ + tx_state.account_reads.add(address) + if address in tx_state.parent.account_writes: + return tx_state.parent.account_writes[address] + return tx_state.parent.pre_state.get_account_optional(address) + + +def get_pre_state_account( + tx_state: TransactionState, address: Address +) -> Account: + """ + Get the `Account` object at an address that existed before the current + transaction, or [`EMPTY_ACCOUNT`]) if there was no account at the address + at that point. + + Use [`get_pre_state_account_optional()`][opt] if the difference between a + non-existent account and [`EMPTY_ACCOUNT`] is material. + + [`EMPTY_ACCOUNT`]: ref:ethereum.state.EMPTY_ACCOUNT + [opt]: ref:ethereum.forks.amsterdam.state_tracker.get_pre_state_account_optional + + Parameters + ---------- + tx_state : + The transaction state. + address : + Address to look up. + + Returns + ------- + account : ``Account`` + Account at address before the current transaction. + + """ # noqa: E501 + account = get_pre_state_account_optional(tx_state, address) + if account is None: + return EMPTY_ACCOUNT + else: + return account + + def get_account_optional( tx_state: TransactionState, address: Address ) -> Optional[Account]: @@ -115,9 +182,7 @@ def get_account_optional( tx_state.account_reads.add(address) if address in tx_state.account_writes: return tx_state.account_writes[address] - if address in tx_state.parent.account_writes: - return tx_state.parent.account_writes[address] - return tx_state.parent.pre_state.get_account_optional(address) + return get_pre_state_account_optional(tx_state, address) def get_account(tx_state: TransactionState, address: Address) -> Account: diff --git a/src/ethereum/forks/amsterdam/transactions.py b/src/ethereum/forks/amsterdam/transactions.py index b05711d28b4..136d0d91e72 100644 --- a/src/ethereum/forks/amsterdam/transactions.py +++ b/src/ethereum/forks/amsterdam/transactions.py @@ -577,7 +577,7 @@ def decode_transaction(tx: LegacyTransaction | Bytes) -> Transaction: return tx -def validate_transaction(tx: Transaction) -> IntrinsicGasCost: +def validate_transaction(tx: Transaction, sender: Address) -> IntrinsicGasCost: """ Verifies a transaction. @@ -609,7 +609,7 @@ def validate_transaction(tx: Transaction) -> IntrinsicGasCost: """ from .vm.interpreter import MAX_INIT_CODE_SIZE - intrinsic = calculate_intrinsic_cost(tx) + intrinsic = calculate_intrinsic_cost(tx, sender) intrinsic_gas = Uint(intrinsic.regular) + Uint(intrinsic.state) if intrinsic_gas > tx.gas: raise InsufficientTransactionGasError("Insufficient intrinsic gas") @@ -631,7 +631,9 @@ def validate_transaction(tx: Transaction) -> IntrinsicGasCost: return intrinsic -def calculate_intrinsic_cost(tx: Transaction) -> IntrinsicGasCost: +def calculate_intrinsic_cost( + tx: Transaction, sender: Address +) -> IntrinsicGasCost: """ Calculates the gas that is charged before execution is started. @@ -645,12 +647,18 @@ def calculate_intrinsic_cost(tx: Transaction) -> IntrinsicGasCost: for all operations to be implemented. The intrinsic cost includes: - 1. Base cost (`TX_BASE`) - 2. Cost for data (zero and non-zero bytes) - 3. Cost for contract creation (if applicable) - 4. Cost for access list entries (if applicable) - 5. Cost for authorizations (if applicable) - + 1. Sender cost (`TX_BASE`). + 2. Recipient cost (`COLD_ACCOUNT_ACCESS` for a non-self-transfer + call, or `CREATE_ACCESS` plus `NEW_ACCOUNT` state gas for a + contract creation). + 3. Value cost (`TRANSFER_LOG_COST`, plus `TX_VALUE_COST` for a + non-self-transfer call) when ``tx.value > 0``. + 4. Calldata cost (zero and non-zero bytes). + 5. Access list entries (if applicable). + 6. Authorizations (if applicable). + + Self-transfers (``sender == tx.to``) skip the recipient and value + charges. This function takes a transaction and gas_limit as parameters and returns the intrinsic regular gas cost, intrinsic state gas cost, and the @@ -666,13 +674,24 @@ def calculate_intrinsic_cost(tx: Transaction) -> IntrinsicGasCost: data_cost = tokens_in_calldata * GasCosts.TX_DATA_TOKEN_STANDARD - create_regular_gas = Uint(0) - create_state_gas = Uint(0) - if tx.to == Bytes0(b""): - create_state_gas = StateGasCosts.NEW_ACCOUNT - create_regular_gas = GasCosts.REGULAR_GAS_CREATE + init_code_cost( + is_create = tx.to == Bytes0(b"") + is_self_transfer = tx.to == sender + + recipient_regular_gas = Uint(0) + recipient_state_gas = Uint(0) + if is_create: + recipient_regular_gas = GasCosts.CREATE_ACCESS + init_code_cost( ulen(tx.data) ) + recipient_state_gas = StateGasCosts.NEW_ACCOUNT + if tx.value > U256(0): + recipient_regular_gas += GasCosts.TRANSFER_LOG_COST + elif not is_self_transfer: + recipient_regular_gas = GasCosts.COLD_ACCOUNT_ACCESS + if tx.value > U256(0): + recipient_regular_gas += ( + GasCosts.TRANSFER_LOG_COST + GasCosts.TX_VALUE_COST + ) access_list_cost = Uint(0) tokens_in_access_list = Uint(0) @@ -693,9 +712,9 @@ def calculate_intrinsic_cost(tx: Transaction) -> IntrinsicGasCost: auth_regular_gas = Uint(0) auth_state_gas = Uint(0) if isinstance(tx, SetCodeTransaction): - auth_regular_gas = GasCosts.PER_AUTH_BASE_COST * ulen( - tx.authorizations - ) + auth_regular_gas = ( + GasCosts.ACCOUNT_WRITE + GasCosts.REGULAR_PER_AUTH_BASE_COST + ) * ulen(tx.authorizations) auth_state_gas = ( StateGasCosts.NEW_ACCOUNT + StateGasCosts.AUTH_BASE ) * ulen(tx.authorizations) @@ -714,12 +733,12 @@ def calculate_intrinsic_cost(tx: Transaction) -> IntrinsicGasCost: intrinsic_regular_gas = ( GasCosts.TX_BASE + data_cost - + create_regular_gas + + recipient_regular_gas + access_list_cost + auth_regular_gas ) - intrinsic_state_gas = create_state_gas + auth_state_gas + intrinsic_state_gas = recipient_state_gas + auth_state_gas return IntrinsicGasCost( regular=RegularGas(intrinsic_regular_gas), diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index 1f54d2b3c64..0b9dae40e86 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -120,6 +120,8 @@ class TransactionEnvironment: """ origin: Address + recipient: Bytes0 | Address + value: U256 gas_price: Uint gas: Uint state_gas_reservoir: Uint diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index 8237225c713..2060d5465d7 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -5,18 +5,20 @@ from typing import Optional, Tuple from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes from ethereum_types.numeric import U64, U256, Uint from ethereum.crypto.elliptic_curve import SECP256K1N, secp256k1_recover from ethereum.crypto.hash import keccak256 from ethereum.exceptions import InvalidBlock, InvalidSignatureError -from ethereum.state import EMPTY_CODE_HASH, Account, Address +from ethereum.state import Address -from ..fork_types import Authorization +from ..fork_types import Authorization, StateGas from ..state_tracker import ( account_exists, get_account, get_code, + get_pre_state_account, increment_nonce, set_code, ) @@ -157,11 +159,11 @@ def calculate_delegation_cost( def validate_authorization( message: Message, auth: Authorization -) -> None | Tuple[Address, Account]: +) -> None | Tuple[Address, Bytes]: """ Check if the given `Authorization` is valid against the current state. - Returns the `authority` address and its `Account`, or `None` if the + Returns the `authority` address and its code, or `None` if the validation was unsuccessful. """ tx_state = message.tx_env.state @@ -189,17 +191,20 @@ def validate_authorization( if authority_nonce != auth.nonce: return None - return (authority, authority_account) + return (authority, authority_code) -def set_delegation(message: Message) -> Uint: +def set_delegation(message: Message) -> Tuple[Uint, Uint]: """ Set the delegation code for the authorities in the message. Refills `StateGasCosts.NEW_ACCOUNT` when the authority's account leaf already exists, and `StateGasCosts.AUTH_BASE` when its code - slot already holds a delegation indicator. The total is returned - so block accounting can subtract it from `tx_state_gas`. + slot already holds a delegation indicator. When the authority leaf + already exists, the worst-case `GasCosts.ACCOUNT_WRITE` charged in + the intrinsic cost is also refunded to the regular-gas refund + counter. The totals are returned so block accounting can subtract + the state refill from `tx_state_gas` and apply the regular refund. Parameters ---------- @@ -210,41 +215,62 @@ def set_delegation(message: Message) -> Uint: ------- state_refund : `Uint` Total state gas refunded across all processed authorizations. + regular_refund : `Uint` + Total regular gas (`ACCOUNT_WRITE`) refunded for authorities + whose account leaf already existed. """ tx_state = message.tx_env.state state_refund = Uint(0) + regular_refund = Uint(0) for auth in message.tx_env.authorizations: match validate_authorization(message, auth): case None: + refund = StateGasCosts.AUTH_BASE + StateGasCosts.NEW_ACCOUNT + message.state_gas_reservoir += refund + state_refund += refund + regular_refund += GasCosts.ACCOUNT_WRITE continue - case (authority, authority_account): + case (authority, authority_code): pass + refund = StateGas(Uint(0)) + if account_exists(tx_state, authority): - refund = StateGasCosts.NEW_ACCOUNT - message.state_gas_reservoir += refund - state_refund += refund - - # No new delegation indicator bytes are written: either the - # authority already has one (overwrite in place / clear) or - # this auth clears against an authority with no prior code. - if ( - authority_account.code_hash != EMPTY_CODE_HASH - or auth.address == NULL_ADDRESS - ): - refund = StateGasCosts.AUTH_BASE - message.state_gas_reservoir += refund - state_refund += refund + refund += StateGasCosts.NEW_ACCOUNT + # The new-account ACCOUNT_WRITE charged at intrinsic time is + # not needed: refund it to the regular refund counter. + regular_refund += GasCosts.ACCOUNT_WRITE + + pre_state_authority_account = get_pre_state_account( + tx_state, authority + ) + pre_state_authority_code = get_code( + tx_state, pre_state_authority_account.code_hash + ) + + delegated_before_tx = is_valid_delegation(pre_state_authority_code) + delegated_now = is_valid_delegation(authority_code) if auth.address == NULL_ADDRESS: + refund += StateGasCosts.AUTH_BASE + + if delegated_now and not delegated_before_tx: + refund += StateGasCosts.AUTH_BASE + code_to_set = b"" else: code_to_set = EOA_DELEGATION_MARKER + auth.address + if delegated_now or delegated_before_tx: + refund += StateGasCosts.AUTH_BASE + set_code(tx_state, authority, code_to_set) increment_nonce(tx_state, authority) + message.state_gas_reservoir += refund + state_refund += refund + if message.code_address is None: raise InvalidBlock("Invalid type 4 transaction: no target") @@ -253,4 +279,4 @@ def set_delegation(message: Message) -> Uint: get_account(tx_state, message.code_address).code_hash, ) - return state_refund + return state_refund, regular_refund diff --git a/src/ethereum/forks/amsterdam/vm/gas.py b/src/ethereum/forks/amsterdam/vm/gas.py index 156d7db6ec8..91a5810b7d2 100644 --- a/src/ethereum/forks/amsterdam/vm/gas.py +++ b/src/ethereum/forks/amsterdam/vm/gas.py @@ -67,23 +67,21 @@ class GasCosts: # Access WARM_ACCESS: Final[Uint] = Uint(100) - COLD_ACCOUNT_ACCESS: Final[Uint] = Uint(2600) - COLD_STORAGE_ACCESS: Final[Uint] = Uint(2100) + COLD_ACCOUNT_ACCESS: Final[Uint] = Uint(3000) + COLD_STORAGE_ACCESS: Final[Uint] = Uint(3000) # Storage - COLD_STORAGE_WRITE: Final[Uint] = Uint(5000) + STORAGE_WRITE: Final[Uint] = Uint(10000) # Call - CALL_VALUE: Final[Uint] = Uint(9000) + CALL_VALUE: Final[Uint] = Uint(10300) # ACCOUNT_WRITE + CALL_STIPEND CALL_STIPEND: Final[Uint] = Uint(2300) + ACCOUNT_WRITE: Final[Uint] = Uint(8000) # Contract Creation CODE_DEPOSIT_PER_BYTE: Final[Uint] = Uint(200) CODE_INIT_PER_WORD: Final[Uint] = Uint(2) - REGULAR_GAS_CREATE: Final[Uint] = Uint(9000) - - # Authorization - PER_AUTH_BASE_COST: Final[Uint] = Uint(7500) + CREATE_ACCESS: Final[Uint] = ACCOUNT_WRITE + COLD_STORAGE_ACCESS # Utility ZERO: Final[Uint] = Uint(0) @@ -91,7 +89,9 @@ class GasCosts: FAST_STEP: Final[Uint] = Uint(5) # Refunds - REFUND_STORAGE_CLEAR: Final[int] = 4800 + REFUND_STORAGE_CLEAR: Final[int] = int( + (STORAGE_WRITE + COLD_STORAGE_ACCESS) * Uint(4800) // Uint(5000) + ) # Precompiles PRECOMPILE_ECRECOVER: Final[Uint] = Uint(3000) @@ -128,12 +128,23 @@ class GasCosts: BLOCK_ACCESS_LIST_ITEM: Final[Uint] = Uint(2000) # Transactions - TX_BASE: Final[Uint] = Uint(21000) + TX_BASE: Final[Uint] = Uint(12000) TX_CREATE: Final[Uint] = Uint(32000) + TX_VALUE_COST: Final[Uint] = Uint(4244) + TRANSFER_LOG_COST: Final[Uint] = Uint(1756) TX_DATA_TOKEN_STANDARD: Final[Uint] = Uint(4) TX_DATA_TOKEN_FLOOR: Final[Uint] = Uint(16) - TX_ACCESS_LIST_ADDRESS: Final[Uint] = Uint(2400) - TX_ACCESS_LIST_STORAGE_KEY: Final[Uint] = Uint(1900) + TX_ACCESS_LIST_ADDRESS: Final[Uint] = COLD_ACCOUNT_ACCESS + TX_ACCESS_LIST_STORAGE_KEY: Final[Uint] = COLD_STORAGE_ACCESS + + # Authorization + AUTH_TUPLE_BYTES: Final[Uint] = Uint(101) + REGULAR_PER_AUTH_BASE_COST: Final[Uint] = ( + AUTH_TUPLE_BYTES * TX_DATA_TOKEN_FLOOR + + PRECOMPILE_ECRECOVER + + COLD_ACCOUNT_ACCESS + + Uint(2) * WARM_ACCESS + ) # Block LIMIT_ADJUSTMENT_FACTOR: Final[Uint] = Uint(1024) @@ -199,6 +210,8 @@ class GasCosts: OPCODE_DUPN: Final[Uint] = VERY_LOW OPCODE_SWAPN: Final[Uint] = VERY_LOW OPCODE_EXCHANGE: Final[Uint] = VERY_LOW + OPCODE_TLOAD: Final[Uint] = Uint(100) + OPCODE_TSTORE: Final[Uint] = Uint(100) # Dynamic Opcode Components OPCODE_RETURNDATACOPY_BASE: Final[Uint] = VERY_LOW diff --git a/src/ethereum/forks/amsterdam/vm/instructions/environment.py b/src/ethereum/forks/amsterdam/vm/instructions/environment.py index 431cd3ba4c6..8a7e9ec1486 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/environment.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/environment.py @@ -341,10 +341,12 @@ def extcodesize(evm: Evm) -> None: # GAS if address in evm.accessed_addresses: - charge_gas(evm, GasCosts.WARM_ACCESS) + access_gas_cost = GasCosts.WARM_ACCESS else: evm.accessed_addresses.add(address) - charge_gas(evm, GasCosts.COLD_ACCOUNT_ACCESS) + access_gas_cost = GasCosts.COLD_ACCOUNT_ACCESS + access_gas_cost += GasCosts.WARM_ACCESS # Code reading cost (EIP-8038) + charge_gas(evm, access_gas_cost) # OPERATION tx_state = evm.message.tx_env.state @@ -386,6 +388,7 @@ def extcodecopy(evm: Evm) -> None: else: evm.accessed_addresses.add(address) access_gas_cost = GasCosts.COLD_ACCOUNT_ACCESS + access_gas_cost += GasCosts.WARM_ACCESS # Code reading cost (EIP-8038) total_gas_cost = access_gas_cost + copy_gas_cost + extend_memory.cost diff --git a/src/ethereum/forks/amsterdam/vm/instructions/storage.py b/src/ethereum/forks/amsterdam/vm/instructions/storage.py index 4e864b8ec71..91aec91163d 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/storage.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/storage.py @@ -93,17 +93,17 @@ def sstore(evm: Evm) -> None: gas_cost = Uint(0) state_gas = StateGas(Uint(0)) + # Access cost: cold or warm, always charged. if (evm.message.current_target, key) not in evm.accessed_storage_keys: evm.accessed_storage_keys.add((evm.message.current_target, key)) gas_cost += GasCosts.COLD_STORAGE_ACCESS - - if original_value == current_value and current_value != new_value: - # charge regular cost for the operation, even when we - # already charge state gas for state creation - gas_cost += GasCosts.COLD_STORAGE_WRITE - GasCosts.COLD_STORAGE_ACCESS else: gas_cost += GasCosts.WARM_ACCESS + # Write cost: charged on the first change to the slot this transaction. + if original_value == current_value and current_value != new_value: + gas_cost += GasCosts.STORAGE_WRITE + # Refund Counter Calculation if current_value != new_value: if original_value != 0 and current_value != 0 and new_value == 0: @@ -115,12 +115,9 @@ def sstore(evm: Evm) -> None: evm.refund_counter -= GasCosts.REFUND_STORAGE_CLEAR if original_value == new_value: - # Storage slot being restored to its original value - evm.refund_counter += int( - GasCosts.COLD_STORAGE_WRITE - - GasCosts.COLD_STORAGE_ACCESS - - GasCosts.WARM_ACCESS - ) + # Slot restored to its original value: refund the STORAGE_WRITE + # charged on the first-time change earlier this transaction. + evm.refund_counter += int(GasCosts.STORAGE_WRITE) if original_value == current_value and current_value != new_value: if original_value == 0: @@ -157,7 +154,7 @@ def tload(evm: Evm) -> None: key = pop(evm.stack).to_be_bytes32() # GAS - charge_gas(evm, GasCosts.WARM_ACCESS) + charge_gas(evm, GasCosts.OPCODE_TLOAD) # OPERATION value = get_transient_storage( @@ -187,7 +184,7 @@ def tstore(evm: Evm) -> None: new_value = pop(evm.stack) # GAS - charge_gas(evm, GasCosts.WARM_ACCESS) + charge_gas(evm, GasCosts.OPCODE_TSTORE) set_transient_storage( evm.message.tx_env.state, evm.message.current_target, diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 185bc277fd5..a3587e4f238 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -194,7 +194,7 @@ def create(evm: Evm) -> None: init_code_gas = init_code_cost(Uint(memory_size)) charge_gas( evm, - GasCosts.REGULAR_GAS_CREATE + extend_memory.cost + init_code_gas, + GasCosts.CREATE_ACCESS + extend_memory.cost + init_code_gas, ) # OPERATION @@ -248,7 +248,7 @@ def create2(evm: Evm) -> None: init_code_gas = init_code_cost(Uint(memory_size)) charge_gas( evm, - GasCosts.REGULAR_GAS_CREATE + GasCosts.CREATE_ACCESS + GasCosts.OPCODE_KECCAK256_PER_WORD * call_data_words + extend_memory.cost + init_code_gas, @@ -668,16 +668,18 @@ def selfdestruct(evm: Evm) -> None: evm.accessed_addresses.add(beneficiary) state_gas = StateGas(Uint(0)) + account_write_gas = Uint(0) if ( not is_account_alive(tx_state, beneficiary) and get_account(tx_state, evm.message.current_target).balance != 0 ): state_gas = StateGasCosts.NEW_ACCOUNT + account_write_gas = GasCosts.ACCOUNT_WRITE # Charge regular gas before state gas so that a regular-gas OOG # does not consume state gas that would inflate the parent's # reservoir on frame failure. - charge_gas(evm, gas_cost) + charge_gas(evm, gas_cost + account_write_gas) charge_state_gas(evm, state_gas) originator = evm.message.current_target diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index 5df1dccc8a0..921873a06bd 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -154,12 +154,13 @@ def process_message_call(message: Message) -> MessageCallOutput: ) else: if message.tx_env.authorizations != (): - state_refund += set_delegation(message) + auth_state_refund, auth_regular_refund = set_delegation(message) + state_refund += auth_state_refund + refund_counter += U256(auth_regular_refund) delegated_address = get_delegated_code_address(message.code) if delegated_address is not None: message.disable_precompiles = True - message.accessed_addresses.add(delegated_address) message.code = get_code( tx_state, get_account(tx_state, delegated_address).code_hash, @@ -309,20 +310,36 @@ def process_message(message: Message) -> Evm: snapshot = copy_tx_state(tx_state) - if message.should_transfer_value and message.value != 0: - move_ether( - tx_state, - message.caller, - message.current_target, - message.value, - ) - if message.caller != message.current_target: - emit_transfer_log( - evm, message.caller, message.current_target, message.value - ) - # Execute message code and handle errors try: + if message.depth == Uint(0) and message.target != Bytes0(b""): + recipient = message.current_target + if message.value > U256(0) and not is_account_alive( + tx_state, recipient + ): + charge_state_gas(evm, StateGasCosts.NEW_ACCOUNT) + recipient_code = get_code( + tx_state, get_account(tx_state, recipient).code_hash + ) + delegated_address = get_delegated_code_address(recipient_code) + if delegated_address is not None: + charge_gas(evm, GasCosts.COLD_ACCOUNT_ACCESS) + evm.accessed_addresses.add(delegated_address) + + if message.should_transfer_value and message.value != 0: + move_ether( + tx_state, + message.caller, + message.current_target, + message.value, + ) + if message.caller != message.current_target: + emit_transfer_log( + evm, + message.caller, + message.current_target, + message.value, + ) if evm.message.code_address in PRE_COMPILED_CONTRACTS: if not message.disable_precompiles: evm_trace(evm, PrecompileStart(evm.message.code_address)) diff --git a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/__init__.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/__init__.py new file mode 100644 index 00000000000..85942b2be4c --- /dev/null +++ b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/__init__.py @@ -0,0 +1 @@ +"""Tests for [EIP-2780: Resource-based intrinsic transaction gas](https://eips.ethereum.org/EIPS/eip-2780).""" diff --git a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/helpers.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/helpers.py new file mode 100644 index 00000000000..4c90175c2a5 --- /dev/null +++ b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/helpers.py @@ -0,0 +1,43 @@ +"""Shared helpers for EIP-2780 tests.""" + +from execution_testing import Address, Alloc, Op, RecipientType + +from ...prague.eip7702_set_code_tx.spec import Spec as Spec7702 + +EOA_INITIAL_BALANCE = 100 + +RECIPIENT_TYPES_NON_CREATE = [ + RecipientType.EOA, + RecipientType.CONTRACT, + RecipientType.EMPTY_ACCOUNT, + RecipientType.SELF, + RecipientType.DELEGATION_7702, +] + + +def setup_target( + pre: Alloc, recipient_type: RecipientType, sender: Address +) -> Address: + """ + Allocate a target account matching the given recipient type. + + ``EOA`` targets are pre-funded to ``EOA_INITIAL_BALANCE`` so that + post-state balance assertions distinguish a successful value + transfer from a no-op. + """ + match recipient_type: + case RecipientType.EOA: + return pre.fund_eoa(amount=EOA_INITIAL_BALANCE) + case RecipientType.CONTRACT: + return pre.deploy_contract(code=Op.STOP) + case RecipientType.EMPTY_ACCOUNT: + return pre.nonexistent_account() + case RecipientType.SELF: + return sender + case RecipientType.DELEGATION_7702: + delegated_to = pre.deploy_contract(code=Op.STOP) + return pre.deploy_contract( + code=Spec7702.delegation_designation(delegated_to) + ) + case _: + raise ValueError(f"Unsupported recipient type {recipient_type}") diff --git a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/spec.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/spec.py new file mode 100644 index 00000000000..e6fcb6bb528 --- /dev/null +++ b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/spec.py @@ -0,0 +1,17 @@ +"""Reference spec for [EIP-2780: Resource-based intrinsic transaction gas.](https://eips.ethereum.org/EIPS/eip-2780).""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ReferenceSpec: + """Reference specification.""" + + git_path: str + version: str + + +ref_spec_2780 = ReferenceSpec( + git_path="EIPS/eip-2780.md", + version="992074053f12f24fed9e6d6bf6099d3a44707dca", +) diff --git a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_calldata_floor.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_calldata_floor.py new file mode 100644 index 00000000000..0907cf64599 --- /dev/null +++ b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_calldata_floor.py @@ -0,0 +1,149 @@ +"""EIP-2780 interaction with the EIP-7623/7976 calldata floor.""" + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + Bytes, + Fork, + RecipientType, + StateTestFiller, + Transaction, + TransactionException, +) + +from ...prague.eip7623_increase_calldata_cost.helpers import ( + find_floor_cost_threshold, +) +from .helpers import EOA_INITIAL_BALANCE +from .spec import ref_spec_2780 + +REFERENCE_SPEC_GIT_PATH = ref_spec_2780.git_path +REFERENCE_SPEC_VERSION = ref_spec_2780.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + + +def _floor_dominating_calldata(fork: Fork) -> Bytes: + """ + Return zero-byte calldata sized so its calldata floor strictly + exceeds the decomposed value-transfer intrinsic for a non-create + call to an existing EOA. + + Reuses the shared EIP-7623 ``find_floor_cost_threshold`` binary + search against this transaction shape, then steps one byte past the + threshold (the last size where the floor does not yet dominate) so + the floor strictly binds. + """ + intrinsic_calc = fork.transaction_intrinsic_cost_calculator() + floor_calc = fork.transaction_data_floor_cost_calculator() + + def intrinsic(byte_count: int) -> int: + return intrinsic_calc( + calldata=b"\x00" * byte_count, + sends_value=True, + recipient_type=RecipientType.EOA, + return_cost_deducted_prior_execution=True, + ) + + def floor(byte_count: int) -> int: + return floor_calc(data=b"\x00" * byte_count) + + threshold = find_floor_cost_threshold( + floor_data_gas_cost_calculator=floor, + intrinsic_gas_cost_calculator=intrinsic, + ) + byte_count = threshold + 1 + + assert floor(byte_count) > intrinsic(byte_count) + return Bytes(b"\x00" * byte_count) + + +@pytest.mark.parametrize( + "gas_modifier", + [ + pytest.param(0, id="at_floor"), + pytest.param( + -1, + id="below_floor", + marks=pytest.mark.exception_test, + ), + ], +) +@pytest.mark.parametrize( + "value", + [ + pytest.param(0, id="zero_value"), + pytest.param(1, id="non-zero_value"), + ], +) +def test_calldata_floor( + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, + gas_modifier: int, + value: int, +) -> None: + """ + A data-heavy transaction to an existing EOA whose calldata floor + exceeds the decomposed value-transfer intrinsic. + + - ``at_floor``: with a gas limit exactly at the floor, ``gas_used`` + pins to the floor, so the value-transfer charges + (``TRANSFER_LOG_COST + TX_VALUE_COST``) folded into the intrinsic + ``value == 1`` and only the moved wei differs. + - ``below_floor``: a gas limit one short of the floor still covers + the (smaller) decomposed intrinsic, so the floor -- built on the + EIP-2780-lowered ``TX_BASE`` -- is the only thing that can reject + it, with ``INTRINSIC_GAS_BELOW_FLOOR_GAS_COST``. + """ + sender_initial_balance = 10**18 + sender = pre.fund_eoa(sender_initial_balance) + target = pre.fund_eoa(amount=EOA_INITIAL_BALANCE) + + calldata = _floor_dominating_calldata(fork) + calldata_floor = fork.transaction_data_floor_cost_calculator()( + data=calldata, + ) + gas_price = 1_000_000_000 + + post: dict[Address, Account] = {} + gas_limit = calldata_floor + gas_modifier + # Even at the reduced limit the decomposed intrinsic is still + # covered, so the calldata floor is the sole gate on the + # transaction. + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + calldata=calldata, + sends_value=bool(value), + recipient_type=RecipientType.EOA, + return_cost_deducted_prior_execution=True, + ) + assert intrinsic_gas <= gas_limit, ( + "gas_limit must still cover the decomposed intrinsic so the " + "outcome is pinned to the calldata floor" + ) + + tx = Transaction( + sender=sender, + to=target, + value=value, + data=calldata, + gas_limit=gas_limit, + gas_price=gas_price, + error=( + TransactionException.INTRINSIC_GAS_BELOW_FLOOR_GAS_COST + if gas_modifier < 0 + else None + ), + ) + if gas_modifier == 0: + sender_final_balance = ( + sender_initial_balance - value - calldata_floor * gas_price + ) + post = { + sender: Account(nonce=1, balance=sender_final_balance), + target: Account(balance=EOA_INITIAL_BALANCE + value), + } + + state_test(pre=pre, tx=tx, post=post) diff --git a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_fork_transition.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_fork_transition.py new file mode 100644 index 00000000000..d94031fe34b --- /dev/null +++ b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_fork_transition.py @@ -0,0 +1,154 @@ +""" +Fork-transition tests for EIP-2780. + +EIP-2780 reshapes the intrinsic transaction cost at the Amsterdam fork +boundary. These tests send identical transactions in a pre-fork block +and a post-fork block (straddling the transition timestamp) and assert +that the per-transaction gas paid changes by the EIP-2780 amount only +once the fork activates. + +For these shapes the post-fork intrinsic decomposes from the flat +pre-fork ``TX_BASE`` of 21_000 as follows: + +- A plain call to an existing account drops to ``TX_BASE`` (12_000) + plus the new ``COLD_ACCOUNT_ACCESS`` recipient charge; adding value + re-raises it to exactly 21_000 (the value-transfer cost is invariant + across the fork by design). +- A self-transfer is fully carved out post-fork: it pays only the + lowered ``TX_BASE`` with no recipient or value-transfer charge, + regardless of value, the largest reduction. +""" + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + Block, + BlockchainTestFiller, + RecipientType, + Transaction, + TransitionFork, +) + +from .helpers import EOA_INITIAL_BALANCE +from .spec import ref_spec_2780 + +REFERENCE_SPEC_GIT_PATH = ref_spec_2780.git_path +REFERENCE_SPEC_VERSION = ref_spec_2780.version + +pytestmark = pytest.mark.valid_at_transition_to("Amsterdam") + +# Transition forks switch at timestamp 15_000. +PRE_FORK_TIMESTAMP = 14_999 +POST_FORK_TIMESTAMP = 15_000 + + +@pytest.mark.parametrize( + "self_transfer", + [ + pytest.param(False, id="plain_call"), + pytest.param(True, id="self_transfer"), + ], +) +@pytest.mark.parametrize( + "value", + [ + pytest.param(0, id="zero_value"), + pytest.param(1, id="non-zero_value"), + ], +) +def test_intrinsic_reduction_across_amsterdam_transition( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: TransitionFork, + self_transfer: bool, + value: int, +) -> None: + """ + Pin the EIP-2780 intrinsic change across the Amsterdam boundary. + + The same transaction shape is sent in a pre-fork block (Osaka + rules, flat 21_000 intrinsic) and a post-fork block (Amsterdam + rules, decomposed intrinsic). Each block uses a distinct sender so + its post-tx balance pins the fork-appropriate intrinsic; the + recipient is an existing EOA (or the sender itself for + ``self_transfer``), so neither block runs EVM bytecode and + ``gas_used`` equals the intrinsic exactly. + + The per-fork intrinsic returned by the calculator is also checked + against a hand-derived decomposition built from each fork's gas + constants, so a calculator regression fails here with a clear + message rather than only as a downstream balance mismatch. + """ + gas_price = 1_000_000_000 + recipient_type = RecipientType.SELF if self_transfer else RecipientType.EOA + + pre_fork = fork.fork_at(timestamp=PRE_FORK_TIMESTAMP) + post_fork = fork.fork_at(timestamp=POST_FORK_TIMESTAMP) + + # Pre-fork: flat ``TX_BASE`` regardless of recipient kind or value. + expected_pre = pre_fork.gas_costs().TX_BASE + # Post-fork: EIP-2780 decomposition. Self-transfers are fully + # carved out; other recipients pay the recipient access charge plus + # the value-transfer charges when value is moved. + post_gas_costs = post_fork.gas_costs() + expected_post = post_gas_costs.TX_BASE + if not self_transfer: + expected_post += post_gas_costs.COLD_ACCOUNT_ACCESS + if value: + expected_post += ( + post_gas_costs.TRANSFER_LOG_COST + post_gas_costs.TX_VALUE_COST + ) + + timestamps = [PRE_FORK_TIMESTAMP, POST_FORK_TIMESTAMP] + expected_intrinsics = [expected_pre, expected_post] + blocks = [] + post: dict[Address, Account] = {} + + for timestamp, expected_intrinsic in zip( + timestamps, expected_intrinsics, strict=True + ): + sub_fork = fork.fork_at(timestamp=timestamp) + intrinsic_gas = sub_fork.transaction_intrinsic_cost_calculator()( + sends_value=bool(value), + recipient_type=recipient_type, + return_cost_deducted_prior_execution=True, + ) + assert intrinsic_gas == expected_intrinsic, ( + f"intrinsic at timestamp {timestamp} ({sub_fork}) is " + f"{intrinsic_gas}, expected {expected_intrinsic}" + ) + + sender_initial_balance = 10**18 + sender = pre.fund_eoa(sender_initial_balance) + if self_transfer: + target = sender + else: + target = pre.fund_eoa(amount=EOA_INITIAL_BALANCE) + + # No EVM bytecode runs (recipient is an EOA or the sender), so + # gas_used == intrinsic_gas; the gas limit is pinned to exactly + # the intrinsic, leaving no buffer. + tx = Transaction( + sender=sender, + to=target, + value=value, + gas_limit=intrinsic_gas, + gas_price=gas_price, + ) + blocks.append(Block(timestamp=timestamp, txs=[tx])) + + # A self-transfer returns the value to the sender (net zero); + # a plain call moves ``value`` to the distinct recipient. + sender_value_delta = 0 if self_transfer else value + sender_final_balance = ( + sender_initial_balance + - sender_value_delta + - intrinsic_gas * gas_price + ) + post[sender] = Account(nonce=1, balance=sender_final_balance) + if not self_transfer: + post[target] = Account(balance=EOA_INITIAL_BALANCE + value) + + blockchain_test(pre=pre, blocks=blocks, post=post) diff --git a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_intrinsic_gas_boundary.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_intrinsic_gas_boundary.py new file mode 100644 index 00000000000..e62a93c8999 --- /dev/null +++ b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_intrinsic_gas_boundary.py @@ -0,0 +1,115 @@ +""" +Gas-limit boundary tests for EIP-2780. + +Pin transactions one gas below the intrinsic charge layer to verify the +transaction is rejected by the pre-execution intrinsic gas check at +that boundary. Top-frame boundary OOGs are covered by the dedicated +top-frame charge tests in ``test_top_frame_charges.py``. +""" + +import pytest +from execution_testing import ( + Alloc, + Fork, + Op, + RecipientType, + StateTestFiller, + Transaction, + TransactionException, +) + +from .helpers import RECIPIENT_TYPES_NON_CREATE, setup_target +from .spec import ref_spec_2780 + +REFERENCE_SPEC_GIT_PATH = ref_spec_2780.git_path +REFERENCE_SPEC_VERSION = ref_spec_2780.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + + +@pytest.mark.exception_test +@pytest.mark.parametrize("recipient_type", RECIPIENT_TYPES_NON_CREATE) +@pytest.mark.parametrize( + "value", + [ + pytest.param(0, id="zero_value"), + pytest.param(1, id="non-zero_value"), + ], +) +def test_intrinsic_gas_floor_boundary( + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, + recipient_type: RecipientType, + value: int, +) -> None: + """ + Reject when ``gas_limit = intrinsic_gas - 1``. + + The transaction never enters the EVM; it is rejected by the + pre-execution intrinsic gas check. + """ + sender = pre.fund_eoa(10**18) + target = setup_target(pre, recipient_type, sender) + + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + sends_value=bool(value), + recipient_type=recipient_type, + return_cost_deducted_prior_execution=True, + ) + + tx = Transaction( + sender=sender, + to=target, + value=value, + gas_limit=intrinsic_gas - 1, + gas_price=1_000_000_000, + error=TransactionException.INTRINSIC_GAS_TOO_LOW, + ) + + state_test(pre=pre, tx=tx, post={}) + + +@pytest.mark.exception_test +@pytest.mark.parametrize( + "value", + [ + pytest.param(0, id="zero_value"), + pytest.param(1, id="non-zero_value"), + ], +) +def test_intrinsic_gas_floor_boundary_contract_creation( + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, + value: int, +) -> None: + """ + Reject a contract-creation transaction when + ``gas_limit = intrinsic_gas - 1``. + + A creation tx's intrinsic includes the ``NEW_ACCOUNT`` state gas, so + the pre-execution check rejects against the combined + ``regular + state`` intrinsic. The init code never runs. + """ + sender = pre.fund_eoa(10**18) + init_code = Op.STOP + + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + calldata=init_code, + contract_creation=True, + sends_value=bool(value), + return_cost_deducted_prior_execution=True, + ) + + tx = Transaction( + sender=sender, + to=None, + value=value, + data=init_code, + gas_limit=intrinsic_gas - 1, + gas_price=1_000_000_000, + error=TransactionException.INTRINSIC_GAS_TOO_LOW, + ) + + state_test(pre=pre, tx=tx, post={}) diff --git a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_top_frame_charges.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_top_frame_charges.py new file mode 100644 index 00000000000..2af1538f9c2 --- /dev/null +++ b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_top_frame_charges.py @@ -0,0 +1,393 @@ +""" +Dedicated tests for the EIP-2780 top-frame charge layer. + +The top-frame layer applies *after* intrinsic gas is deducted but +*before* the EVM dispatches at the transaction's outermost frame. Two +charges may fire there, depending on the recipient: + +- ``NEW_ACCOUNT`` (state gas) when the recipient is empty and the + transaction transfers value. +- ``COLD_ACCOUNT_ACCESS`` (regular gas) when the recipient holds an + EIP-7702 delegation. + +Each test parametrizes over the interesting outcomes for that charge: +running out of gas at the boundary, succeeding through the charge and +into the EVM, and (for the regular charge) succeeding through the +charge but reverting from the delegated code. +""" + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + Block, + BlockchainTestFiller, + Fork, + Header, + Op, + RecipientType, + StateTestFiller, + Transaction, +) + +from ...prague.eip7702_set_code_tx.spec import Spec as Spec7702 +from .spec import ref_spec_2780 + +REFERENCE_SPEC_GIT_PATH = ref_spec_2780.git_path +REFERENCE_SPEC_VERSION = ref_spec_2780.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + + +@pytest.mark.parametrize("outcome", ["oog", "success"]) +def test_top_frame_state_charge( + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, + outcome: str, +) -> None: + """ + Recipient is empty and the transaction transfers a non-zero value, + so the top-frame fires the ``NEW_ACCOUNT`` state-gas charge. + + - ``oog``: gas limit is one short of covering the state charge. + The transaction passes the intrinsic check, enters + ``process_message``, and out-of-gases on + ``charge_state_gas(NEW_ACCOUNT)`` before any EVM bytecode runs. + The sender pays the full ``gas_limit`` and no value is + transferred. + - ``success``: gas limit covers the state charge. The value + transfer brings the recipient into existence and the recipient + ends the transaction holding the transferred balance. + """ + sender_initial_balance = 10**18 + sender = pre.fund_eoa(sender_initial_balance) + target = pre.fund_eoa(amount=0) + + value = 1 + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + sends_value=True, + recipient_type=RecipientType.EMPTY_ACCOUNT, + return_cost_deducted_prior_execution=True, + ) + top_frame_state_gas = fork.transaction_top_frame_state_gas( + sends_value=True, + recipient_type=RecipientType.EMPTY_ACCOUNT, + ) + assert top_frame_state_gas > 0, ( + "top-frame state gas must be non-zero for this scenario" + ) + + gas_price = 1_000_000_000 + if outcome == "oog": + gas_limit = intrinsic_gas + top_frame_state_gas - 1 + sender_final_balance = sender_initial_balance - gas_limit * gas_price + expected_target: Account | None = None + else: + total_gas_cost = intrinsic_gas + top_frame_state_gas + gas_limit = total_gas_cost + 1000 + sender_final_balance = ( + sender_initial_balance - value - total_gas_cost * gas_price + ) + expected_target = Account(balance=value) + + tx = Transaction( + sender=sender, + to=target, + value=value, + gas_limit=gas_limit, + gas_price=gas_price, + ) + + post = { + sender: Account(nonce=1, balance=sender_final_balance), + target: expected_target, + } + + state_test(pre=pre, tx=tx, post=post) + + +def test_top_frame_state_charge_empty_precompile( + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, +) -> None: + """ + An empty precompile recipient is still empty per EIP-161, so a + value-moving transaction to it must pay the top-frame + ``NEW_ACCOUNT`` state-gas charge. + + The gas limit is one short of covering that state charge. Without + the charge, the transaction would reach the identity precompile and + transfer value, which makes this a direct regression test for a + precompile carve-out. + """ + sender_initial_balance = 10**18 + sender = pre.fund_eoa(sender_initial_balance) + identity_precompile = Address(0x04) + + value = 1 + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + sends_value=True, + recipient_type=RecipientType.PRECOMPILE, + return_cost_deducted_prior_execution=True, + ) + top_frame_state_gas = fork.transaction_top_frame_state_gas( + sends_value=True, + recipient_type=RecipientType.EMPTY_ACCOUNT, + ) + assert top_frame_state_gas > 0, ( + "top-frame state gas must be non-zero for empty recipients" + ) + + gas_price = 1_000_000_000 + gas_limit = intrinsic_gas + top_frame_state_gas - 1 + tx = Transaction( + sender=sender, + to=identity_precompile, + value=value, + gas_limit=gas_limit, + gas_price=gas_price, + ) + + post = { + sender: Account( + nonce=1, + balance=sender_initial_balance - gas_limit * gas_price, + ), + identity_precompile: None, + } + + state_test(pre=pre, tx=tx, post=post) + + +def test_top_frame_new_account_charged_as_state_gas( + fork: Fork, + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + The top-frame ``NEW_ACCOUNT`` charge for a value transfer to an + empty recipient is *state* gas, not regular gas. This pins the + dimension via the block header ``gas_used``, which the spec + computes as ``max(block_regular_gas, block_state_gas)``. + + Correctly attributed, the ``NEW_ACCOUNT`` state gas dominates the + small regular intrinsic, so ``gas_used == NEW_ACCOUNT``. A + regression mis-classifying the charge as regular gas would instead + yield ``intrinsic_regular + NEW_ACCOUNT``. + + ``state_test``-based balance assertions (e.g. + ``test_top_frame_state_charge``) only observe the *sum* of the two + dimensions, so they cannot distinguish this; a block-level + ``gas_used`` assertion is required. + """ + sender = pre.fund_eoa(10**18) + target = pre.fund_eoa(amount=0) + value = 1 + + intrinsic_regular = fork.transaction_intrinsic_cost_calculator()( + sends_value=True, + recipient_type=RecipientType.EMPTY_ACCOUNT, + return_cost_deducted_prior_execution=True, + ) + new_account_state_gas = fork.transaction_top_frame_state_gas( + sends_value=True, + recipient_type=RecipientType.EMPTY_ACCOUNT, + ) + # The state charge must dominate the regular intrinsic for the + # header ``gas_used`` to distinguish a state vs regular + # mis-classification. + assert new_account_state_gas > intrinsic_regular, ( + "test only distinguishes the dimension when NEW_ACCOUNT " + f"({new_account_state_gas}) dominates the regular intrinsic " + f"({intrinsic_regular})" + ) + + # No EVM bytecode runs (empty recipient), so the only regular gas + # is the intrinsic and the only state gas is the top-frame + # ``NEW_ACCOUNT`` charge. + expected_gas_used = max(intrinsic_regular, new_account_state_gas) + + gas_price = 1_000_000_000 + tx = Transaction( + sender=sender, + to=target, + value=value, + gas_limit=intrinsic_regular + new_account_state_gas + 1000, + gas_price=gas_price, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + header_verify=Header(gas_used=expected_gas_used), + ), + ], + post={ + sender: Account(nonce=1), + target: Account(balance=value), + }, + ) + + +@pytest.mark.pre_alloc_mutable +def test_top_frame_new_account_skipped_for_nonce_only_recipient( + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, +) -> None: + """ + A recipient that is alive only by its nonce (``nonce=1``, zero + balance, no code) is not empty per EIP-161, so a value transfer to + it does *not* incur the top-frame ``NEW_ACCOUNT`` charge. This pins + that the gate keys on ``is_account_alive``, not ``balance == 0``. + + Such an account is reachable on-chain: any EOA that has sent a + transaction (nonce bumped) and been fully drained sits at + ``nonce>0, balance=0, no code``. + + The gas limit is pinned to exactly the intrinsic, leaving no room + for any extra charge: an implementation that wrongly charged + ``NEW_ACCOUNT`` (keying on the zero balance) would out-of-gas + rather than succeed. The recipient has no code, so no EVM runs and + the intrinsic is fully consumed with nothing to refund. + """ + sender_initial_balance = 10**18 + sender = pre.fund_eoa(sender_initial_balance) + # Alive via nonce only: not empty per EIP-161 because nonce != 0. + target = pre.fund_eoa(amount=0, nonce=1) + value = 1 + + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + sends_value=True, + recipient_type=RecipientType.EOA, + return_cost_deducted_prior_execution=True, + ) + top_frame_state_gas = fork.transaction_top_frame_state_gas( + sends_value=True, + recipient_type=RecipientType.EOA, + ) + assert top_frame_state_gas == 0, ( + "a nonce-only-alive recipient must not incur the NEW_ACCOUNT charge" + ) + + gas_price = 1_000_000_000 + gas_limit = intrinsic_gas + tx = Transaction( + sender=sender, + to=target, + value=value, + gas_limit=gas_limit, + gas_price=gas_price, + ) + + sender_final_balance = ( + sender_initial_balance - value - intrinsic_gas * gas_price + ) + post = { + sender: Account(nonce=1, balance=sender_final_balance), + target: Account(nonce=1, balance=value), + } + + state_test(pre=pre, tx=tx, post=post) + + +@pytest.mark.parametrize("outcome", ["oog", "success", "evm_reverts"]) +@pytest.mark.parametrize( + "value", + [ + pytest.param(0, id="zero_value"), + pytest.param(1, id="non-zero_value"), + ], +) +def test_top_frame_regular_charge( + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, + outcome: str, + value: int, +) -> None: + """ + Recipient is an existing EIP-7702 delegation, so the top-frame + fires the ``COLD_ACCOUNT_ACCESS`` regular-gas charge regardless of + whether the transaction transfers value. + + - ``oog``: gas limit is one short of covering the regular charge + (plus the value-transfer charge when ``value > 0``). The + transaction OOGs at ``charge_gas(COLD_ACCOUNT_ACCESS)`` before + the delegated code runs. The sender pays the full ``gas_limit`` + and the recipient keeps its pre-tx state. + - ``success``: gas limit covers the regular charge; the delegated + code is a ``STOP`` and the transaction lands the value transfer. + - ``evm_reverts``: the delegated code reverts immediately. The + top-frame charge is consumed before dispatch and the two + ``PUSH`` opcodes that feed the ``REVERT`` are paid before the + revert; the value transfer is rolled back, the unused EVM + budget is returned, and the intrinsic and top-frame gas remain + paid. + """ + sender_initial_balance = 10**18 + sender = pre.fund_eoa(sender_initial_balance) + + revert_code = Op.REVERT(0, 0) + if outcome == "evm_reverts": + delegated_to = pre.deploy_contract(code=revert_code) + else: + delegated_to = pre.deploy_contract(code=Op.STOP) + target_code = Spec7702.delegation_designation(delegated_to) + target = pre.deploy_contract(code=target_code) + + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + sends_value=bool(value), + recipient_type=RecipientType.DELEGATION_7702, + return_cost_deducted_prior_execution=True, + ) + top_frame_gas = fork.transaction_top_frame_gas_calculator()( + sends_value=bool(value), + recipient_type=RecipientType.DELEGATION_7702, + ) + assert top_frame_gas > 0, ( + "top-frame regular gas must be non-zero for this scenario" + ) + + gas_price = 1_000_000_000 + if outcome == "oog": + gas_limit = intrinsic_gas + top_frame_gas - 1 + sender_final_balance = sender_initial_balance - gas_limit * gas_price + target_balance = 0 + elif outcome == "success": + total_gas_cost = intrinsic_gas + top_frame_gas + gas_limit = total_gas_cost + 1000 + sender_final_balance = ( + sender_initial_balance - value - total_gas_cost * gas_price + ) + target_balance = value + else: + # Two ``PUSH`` opcodes feed ``REVERT`` before it halts. + revert_exec_gas = revert_code.gas_cost(fork) + gas_used = intrinsic_gas + top_frame_gas + revert_exec_gas + gas_limit = gas_used + 1000 + # Value transfer is rolled back, so the sender keeps the + # would-be transferred value. The intrinsic, top-frame, and + # pre-revert EVM gas stay paid. + sender_final_balance = sender_initial_balance - gas_used * gas_price + target_balance = 0 + + tx = Transaction( + sender=sender, + to=target, + value=value, + gas_limit=gas_limit, + gas_price=gas_price, + ) + + post = { + sender: Account(nonce=1, balance=sender_final_balance), + target: Account(balance=target_balance, code=target_code), + } + + state_test(pre=pre, tx=tx, post=post) diff --git a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_value_moving_transactions.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_value_moving_transactions.py new file mode 100644 index 00000000000..0a7533cbe79 --- /dev/null +++ b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_value_moving_transactions.py @@ -0,0 +1,414 @@ +""" +Tests for EIP-2780 Reduce Transaction Intrinsic Cost. + +Test gas costs with EIP-2780 for value-moving transactions to: +- EOAs, +- contracts, +- empty accounts, +- the sender itself, +- delegated EOAs, +- newly created contracts, and +- precompiles. +""" + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + Fork, + Initcode, + Op, + RecipientType, + StateTestFiller, + Transaction, + TransactionReceipt, + compute_create_address, +) + +from ..eip7708_eth_transfer_logs.spec import transfer_log +from .helpers import ( + EOA_INITIAL_BALANCE, + RECIPIENT_TYPES_NON_CREATE, + setup_target, +) +from .spec import ref_spec_2780 + +REFERENCE_SPEC_GIT_PATH = ref_spec_2780.git_path +REFERENCE_SPEC_VERSION = ref_spec_2780.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + + +@pytest.mark.parametrize("recipient_type", RECIPIENT_TYPES_NON_CREATE) +@pytest.mark.parametrize( + "value", + [ + pytest.param(0, id="zero_value"), + pytest.param(1, id="non-zero_value"), + ], +) +def test_value_moving_transactions( + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, + recipient_type: RecipientType, + value: int, +) -> None: + """ + Ensure value-moving transactions charge gas correctly across every + non-create recipient type. + + Self-transfers are carved out: the sender pays only the recipient + -access-free intrinsic and the value is moved to itself, so the + sender's post-tx balance reflects only gas. Pre-existing 7702 + delegations on the recipient surface as an extra top-frame + ``COLD_ACCOUNT_ACCESS``; empty recipients trigger the top-frame + ``NEW_ACCOUNT`` state charge when value is transferred. + + The EIP-7708 transfer log is asserted to fire exactly when + ``TRANSFER_LOG_COST`` is charged: for a non-self value transfer, + and never for a self-transfer (carve-out) or a zero-value tx. + """ + sender_initial_balance = 10**18 + sender = pre.fund_eoa(sender_initial_balance) + target = setup_target(pre, recipient_type, sender) + + target_initial_balance = ( + EOA_INITIAL_BALANCE if recipient_type == RecipientType.EOA else 0 + ) + + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + sends_value=bool(value), + recipient_type=recipient_type, + return_cost_deducted_prior_execution=True, + ) + top_frame_gas = fork.transaction_top_frame_gas_calculator()( + sends_value=bool(value), + recipient_type=recipient_type, + ) + top_frame_state_gas = fork.transaction_top_frame_state_gas( + sends_value=bool(value), + recipient_type=recipient_type, + ) + # Under the default zero state-gas reservoir, top-frame state gas + # spills entirely into regular gas. + total_gas_cost = intrinsic_gas + top_frame_gas + top_frame_state_gas + + tx_gas_limit = total_gas_cost + 1000 # add a small buffer + gas_price = 1_000_000_000 + + is_self_transfer = recipient_type == RecipientType.SELF + + # A transfer log is emitted iff value moves to a distinct account, + # which is exactly when the intrinsic includes ``TRANSFER_LOG_COST``. + # ``logs=[]`` asserts no log fires for the carved-out cases. + if value > 0 and not is_self_transfer: + expected_logs = [transfer_log(sender, target, value)] + else: + expected_logs = [] + + tx = Transaction( + sender=sender, + to=target, + value=value, + gas_limit=tx_gas_limit, + gas_price=gas_price, + expected_receipt=TransactionReceipt(logs=expected_logs), + ) + + sender_value_delta = 0 if is_self_transfer else value + sender_final_balance = ( + sender_initial_balance + - sender_value_delta + - total_gas_cost * gas_price + ) + + post: dict[Address, Account | None] = { + sender: Account(nonce=1, balance=sender_final_balance), + } + if not is_self_transfer: + if recipient_type == RecipientType.EMPTY_ACCOUNT and value == 0: + post[target] = None + else: + post[target] = Account(balance=target_initial_balance + value) + + state_test(pre=pre, tx=tx, post=post) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(0, id="zero_value"), + pytest.param(1, id="non-zero_value"), + ], +) +@pytest.mark.parametrize( + "tx_reverts", + [ + pytest.param(False, id="success"), + pytest.param(True, id="init_reverts"), + ], +) +def test_value_contract_creation_tx( + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, + tx_reverts: bool, + value: int, +) -> None: + """ + Test value moving contract creation transactions. + + When the init code succeeds, the contract is deployed with the + transferred value and the receipt's ``gas_used`` equals the + intrinsic plus the execution gas. + + When the init code reverts, the deploy is rolled back: no code is + set, the value transfer is reversed, and the intrinsic + ``NEW_ACCOUNT`` state-gas charge is refilled to the reservoir. + Under the default zero state-gas reservoir, the refill cancels + the spilled-to-regular portion of the intrinsic exactly, so the + sender pays only the regular portion of the intrinsic plus the + few EVM gas units spent before the revert. + """ + sender_initial_balance = 10**18 + sender = pre.fund_eoa(sender_initial_balance) + + code_to_deploy = Op.STOP + if tx_reverts: + # ``PUSH1 0 PUSH1 0 REVERT`` -- aborts immediately, so no + # code is deployed. + call_data = Op.REVERT(0, 0) + else: + call_data = Initcode(deploy_code=code_to_deploy) + execution_gas = call_data.gas_cost(fork) + + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + calldata=call_data, + contract_creation=True, + sends_value=bool(value), + return_cost_deducted_prior_execution=True, + ) + + if tx_reverts: + # The ``NEW_ACCOUNT`` state portion of the intrinsic is + # refilled to the reservoir on revert, so it does not appear + # on the receipt. + new_account_refund = fork.transaction_intrinsic_state_gas( + contract_creation=True, + ) + gas_used = intrinsic_gas + execution_gas - new_account_refund + # Value transfer rolled back. + sender_value_delta = 0 + expected_target = None + else: + gas_used = intrinsic_gas + execution_gas + sender_value_delta = value + expected_target = Account(code=code_to_deploy, balance=value) + + expected_target_address = compute_create_address(address=sender, nonce=0) + + if value > 0 and not tx_reverts: + expected_logs = [transfer_log(sender, expected_target_address, value)] + else: + expected_logs = [] + + gas_price = 1_000_000_000 + gas_limit = intrinsic_gas + execution_gas + 1000 + + tx = Transaction( + sender=sender, + to=None, + value=value, + data=call_data, + gas_limit=gas_limit, + gas_price=gas_price, + expected_receipt=TransactionReceipt(logs=expected_logs), + ) + + sender_final_balance = ( + sender_initial_balance - sender_value_delta - gas_used * gas_price + ) + + post = { + sender: Account(nonce=1, balance=sender_final_balance), + expected_target_address: expected_target, + } + + state_test(pre=pre, tx=tx, post=post) + + +def _precompile_calldata(precompile: Address) -> bytes: + """Return minimal valid calldata for the given precompile address.""" + addr_int = int.from_bytes(precompile, "big") + + if addr_int == 0x0A: + # Valid point evaluation input from mainnet tx: + # https://etherscan.io/tx/0xcb3dc8f3b14f1cda0c16a619a112102a8ec70dce1b3f1b28272227cf8d5fbb0e + return ( + bytes.fromhex( + # versioned_hash (32) + "018156B94FE9735E573BAB36DAD05D60FEB720D424CCD20AAF719343C31E4246" + ) + + bytes.fromhex( + # z (32) + "019123BCB9D06356701F7BE08B4494625B87A7B02EDC566126FB81F6306E915F" + ) + + bytes.fromhex( + # y (32) + "6C2EB1E94C2532935B8465351BA1BD88EABE2B3FA1AADFF7D1CD816E8315BD38" + ) + + bytes.fromhex( + # kzg_commitment (48) + "A9546D41993E10DF2A7429B8490394EA9EE62807BAE6F326D1044A51581306F58D4B9DFD5931E044688855280FF3799E" + ) + + bytes.fromhex( + # kzg_proof (48) + "A2EA83D9391E0EE42E0C650ACC7A1F842A7D385189485DDB4FD54ADE3D9FD50D608167DCA6C776AAD4B8AD5C20691BFE" + ) + ) + + precompile_min_input = { + 0x01: 128, # ECRECOVER + 0x02: 0, # SHA256 (accepts empty) + 0x03: 0, # RIPEMD160 (accepts empty) + 0x04: 0, # IDENTITY (accepts empty) + 0x05: 96, # MODEXP + 0x06: 128, # BN256ADD + 0x07: 96, # BN256MUL + 0x08: 0, # BN256PAIRING (empty is valid) + 0x09: 213, # BLAKE2F + 0x0B: 256, # BLS12_G1_ADD + 0x0C: 160, # BLS12_G1_MSM + 0x0D: 512, # BLS12_G2_ADD + 0x0E: 288, # BLS12_G2_MSM + 0x0F: 384, # BLS12_PAIRING + 0x10: 64, # BLS12_MAP_FP_TO_G1 + 0x11: 128, # BLS12_MAP_FP2_TO_G2 + 0x100: 160, # P256VERIFY + } + + input_size = precompile_min_input.get(addr_int, 0) + return bytes([0x00] * input_size if input_size > 0 else []) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(0, id="zero_value"), + pytest.param(1, id="non-zero_value"), + ], +) +@pytest.mark.parametrize( + "pre_funded", + [ + pytest.param(True, id="pre_funded"), + pytest.param(False, id="not_funded"), + ], +) +@pytest.mark.with_all_precompiles +def test_value_move_to_precompiles( + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, + precompile: Address, + pre_funded: bool, + value: int, +) -> None: + """ + Ensure value moving transactions to precompiles charge gas correctly. + + Precompile recipients pay the same ``COLD_ACCOUNT_ACCESS`` at + intrinsic time as any other non-self target -- access lists do + not warm transaction-level accounts. A value transfer to a + precompile additionally pays the transfer-log and value-transfer + charges. + + The top-frame ``NEW_ACCOUNT`` state charge keys solely on EIP-161 + emptiness; a precompile address is not special-cased. The + ``pre_funded`` parameter exercises both pre-tx states: + + - ``not_funded``: the precompile address is empty per EIP-161, so a + value transfer creates it and pays ``NEW_ACCOUNT`` -- exactly + like any other empty recipient. + - ``pre_funded``: the precompile already holds a balance and is + therefore alive, so no ``NEW_ACCOUNT`` charge applies. + """ + sender_initial_balance = 10**18 + sender = pre.fund_eoa(sender_initial_balance) + + pre_funded_amount = 0 + if pre_funded: + pre_funded_amount = 1 + pre.fund_address(precompile, amount=pre_funded_amount) + + tx_data = _precompile_calldata(precompile) + + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + calldata=tx_data, + sends_value=bool(value), + recipient_type=RecipientType.PRECOMPILE, + return_cost_deducted_prior_execution=True, + ) + # A value transfer to an empty (not pre-funded) precompile fires the + # top-frame ``NEW_ACCOUNT`` state charge, modelled via + # ``EMPTY_ACCOUNT``; a pre-funded precompile is alive and exempt. + state_recipient_type = ( + RecipientType.PRECOMPILE if pre_funded else RecipientType.EMPTY_ACCOUNT + ) + top_frame_state_gas = fork.transaction_top_frame_state_gas( + sends_value=bool(value), + recipient_type=state_recipient_type, + ) + + if value > 0: + expected_logs = [transfer_log(sender, precompile, value)] + else: + expected_logs = [] + + gas_price = 1_000_000_000 + + tx = Transaction( + sender=sender, + to=precompile, + value=value, + data=tx_data, + gas_price=gas_price, + expected_receipt=TransactionReceipt(logs=expected_logs), + ) + + # Exact sender balance is generally not checked because precompile + # execution gas varies across the matrix. For identity with empty + # calldata, the execution gas is deterministic, so pin the exact + # balance to make the empty-precompile ``NEW_ACCOUNT`` charge a + # source-level assertion. + final_precompile_balance = pre_funded_amount + value + expected_precompile: Account | None + if final_precompile_balance > 0: + expected_precompile = Account(balance=final_precompile_balance) + else: + expected_precompile = None + expected_sender = Account(nonce=1) + if precompile == Address(0x04): + gas_costs = fork.gas_costs() + precompile_execution_gas = ( + gas_costs.PRECOMPILE_IDENTITY_BASE + + gas_costs.PRECOMPILE_IDENTITY_PER_WORD + * ((len(tx_data) + 31) // 32) + ) + total_gas_cost = ( + intrinsic_gas + top_frame_state_gas + precompile_execution_gas + ) + expected_sender = Account( + nonce=1, + balance=( + sender_initial_balance - value - total_gas_cost * gas_price + ), + ) + post = { + sender: expected_sender, + precompile: expected_precompile, + } + + state_test(pre=pre, tx=tx, post=post) diff --git a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_value_moving_with_tx_delegation.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_value_moving_with_tx_delegation.py new file mode 100644 index 00000000000..8722d39e1df --- /dev/null +++ b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_value_moving_with_tx_delegation.py @@ -0,0 +1,339 @@ +""" +Tests for EIP-2780 x EIP-7702 interaction. + +When a type-4 transaction's authorization list installs a delegation on +``tx.to``, ``set_delegation`` runs before the top-frame check fires. +That ordering changes which top-frame charges apply: + +- ``COLD_ACCOUNT_ACCESS`` for the delegated recipient still fires; the + spec charges the access uniformly whenever the recipient holds a + delegation prefix at top-frame time, regardless of who installed it. +- ``NEW_ACCOUNT`` for a value transfer to an otherwise-empty recipient + is suppressed implicitly: ``set_delegation`` writes the delegation + code and increments the nonce, so ``is_account_alive`` returns + ``True`` by the time the top-frame check evaluates it. + +A complementary set of scenarios installs the delegation on the +*sender* (self-sponsored authorization). The authorization's nonce +must equal the sender's nonce *after* the transaction's nonce +increment. +""" + +import pytest +from execution_testing import ( + Account, + Alloc, + AuthorizationTuple, + Fork, + Op, + RecipientType, + StateTestFiller, + Transaction, +) + +from ...prague.eip7702_set_code_tx.spec import Spec as Spec7702 +from .spec import ref_spec_2780 + +REFERENCE_SPEC_GIT_PATH = ref_spec_2780.git_path +REFERENCE_SPEC_VERSION = ref_spec_2780.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(0, id="zero_value"), + pytest.param(1, id="non-zero_value"), + ], +) +def test_tx_installs_delegation_on_funded_recipient( + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, + value: int, +) -> None: + """ + Scenario 1: ``tx.to`` is a funded EOA with no prior delegation. + The type-4 transaction's authorization installs delegation on + ``tx.to``. The top-frame ``COLD_ACCOUNT_ACCESS`` charge for the + now-delegated recipient still fires. + + The pre-existing authority account also produces a + ``REFUND_AUTH_PER_EXISTING_ACCOUNT`` state refund. + """ + gsc = fork.gas_costs() + sender_initial_balance = 10**18 + sender = pre.fund_eoa(sender_initial_balance) + + target_initial_balance = 100 + target = pre.fund_eoa(amount=target_initial_balance) + delegated_to = pre.deploy_contract(code=Op.STOP) + + auth = AuthorizationTuple( + address=delegated_to, + nonce=0, + signer=target, + ) + + # Intrinsic sees the recipient in its pre-tx form (funded EOA); + # the delegation is materialized later by ``set_delegation`` and + # only surfaces at the top-frame check. + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + sends_value=bool(value), + recipient_type=RecipientType.EOA, + authorization_list_or_count=[auth], + return_cost_deducted_prior_execution=True, + ) + top_frame_gas = fork.transaction_top_frame_gas_calculator()( + sends_value=bool(value), + recipient_type=RecipientType.DELEGATION_7702, + ) + # The full intrinsic is deducted upfront. For each existing + # authority, ``set_delegation`` refunds ``NEW_ACCOUNT`` into the + # state gas reservoir (uncapped) and ``ACCOUNT_WRITE`` into the + # regular refund counter (capped at ``gas_used // 5`` by EIP-3529). + total_gas_cost = intrinsic_gas + top_frame_gas + state_refund = gsc.REFUND_AUTH_PER_EXISTING_ACCOUNT + gas_used_pre_regular_refund = total_gas_cost - state_refund + regular_refund = min(gsc.ACCOUNT_WRITE, gas_used_pre_regular_refund // 5) + gas_used = gas_used_pre_regular_refund - regular_refund + + tx_gas_limit = total_gas_cost + 1000 + gas_price = 1_000_000_000 + + tx = Transaction( + sender=sender, + to=target, + value=value, + authorization_list=[auth], + gas_limit=tx_gas_limit, + max_fee_per_gas=gas_price, + max_priority_fee_per_gas=gas_price, + ) + + sender_final_balance = ( + sender_initial_balance - value - (gas_used * gas_price) + ) + + post = { + sender: Account(nonce=1, balance=sender_final_balance), + target: Account( + nonce=1, + balance=target_initial_balance + value, + code=Spec7702.delegation_designation(delegated_to), + ), + } + + state_test(pre=pre, tx=tx, post=post) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(0, id="zero_value"), + pytest.param(1, id="non-zero_value"), + ], +) +def test_tx_installs_delegation_on_empty_recipient( + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, + value: int, +) -> None: + """ + Scenario 2: ``tx.to`` is a non-existent (empty) account. The type-4 + transaction's authorization installs delegation on ``tx.to``. + + ``set_delegation`` runs before the top-frame check and makes the + recipient alive, so the ``NEW_ACCOUNT`` state-gas charge that a + value transfer to an empty recipient would otherwise incur is + implicitly suppressed. The ``COLD_ACCOUNT_ACCESS`` charge for the + now-delegated recipient still fires. + """ + sender_initial_balance = 10**18 + sender = pre.fund_eoa(sender_initial_balance) + + target = pre.fund_eoa(amount=0) + delegated_to = pre.deploy_contract(code=Op.STOP) + + auth = AuthorizationTuple( + address=delegated_to, + nonce=0, + signer=target, + ) + + # Intrinsic sees the recipient in its pre-tx form (empty); the + # delegation is materialized later by ``set_delegation`` and only + # surfaces at the top-frame check. + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + sends_value=bool(value), + recipient_type=RecipientType.EMPTY_ACCOUNT, + authorization_list_or_count=[auth], + return_cost_deducted_prior_execution=True, + ) + top_frame_gas = fork.transaction_top_frame_gas_calculator()( + sends_value=bool(value), + recipient_type=RecipientType.DELEGATION_7702, + ) + # Authority does not pre-exist, so no auth refund applies. + total_gas_cost = intrinsic_gas + top_frame_gas + + tx_gas_limit = total_gas_cost + 1000 + gas_price = 1_000_000_000 + + tx = Transaction( + sender=sender, + to=target, + value=value, + authorization_list=[auth], + gas_limit=tx_gas_limit, + max_fee_per_gas=gas_price, + max_priority_fee_per_gas=gas_price, + ) + + sender_final_balance = ( + sender_initial_balance - value - (total_gas_cost * gas_price) + ) + + post = { + sender: Account(nonce=1, balance=sender_final_balance), + target: Account( + nonce=1, + balance=value, + code=Spec7702.delegation_designation(delegated_to), + ), + } + + state_test(pre=pre, tx=tx, post=post) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(0, id="zero_value"), + pytest.param(1, id="non-zero_value"), + ], +) +@pytest.mark.parametrize( + "call_target", + [ + pytest.param("self", id="calls_self"), + pytest.param("other_eoa", id="calls_other"), + ], +) +def test_tx_installs_delegation_on_sender( + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, + call_target: str, + value: int, +) -> None: + """ + Self-sponsored type-4 transaction: the sender signs an + authorization installing delegation on itself, and the + authorization's nonce equals the sender's nonce *after* the + transaction-side increment (``1``). After ``set_delegation`` the + sender holds delegation code and its nonce reaches ``2``. + + Parametrized over the call target: + + - ``calls_self``: ``tx.to == sender``. The intrinsic self-transfer + carve-out suppresses the recipient access and value-transfer + charges; the top-frame fires ``COLD_ACCOUNT_ACCESS`` because + ``set_delegation`` has installed delegation code on the sender + by then. The transaction then dispatches into the sender's + delegated code. + - ``calls_other``: ``tx.to`` is a separate funded EOA. The + intrinsic charges include ``COLD_ACCOUNT_ACCESS`` for the + recipient (and the value-transfer charges when ``value > 0``). + The top-frame fires nothing because the recipient is a plain + EOA. The sender's delegation is installed and persists past the + transaction without ever being invoked. + """ + gsc = fork.gas_costs() + sender_initial_balance = 10**18 + sender = pre.fund_eoa(sender_initial_balance) + + delegated_to = pre.deploy_contract(code=Op.STOP) + + auth = AuthorizationTuple( + address=delegated_to, + nonce=1, + signer=sender, + ) + + target_initial_balance = 0 + if call_target == "self": + target = sender + # Intrinsic carve-out fires (SELF); top-frame fires + # ``COLD_ACCOUNT_ACCESS`` because the sender is delegated by + # the time the check runs. + intrinsic_recipient_type = RecipientType.SELF + top_frame_recipient_type = RecipientType.DELEGATION_7702 + else: + target_initial_balance = 100 + target = pre.fund_eoa(amount=target_initial_balance) + # Recipient is a plain EOA, so no carve-out and no top-frame + # charge. + intrinsic_recipient_type = RecipientType.EOA + top_frame_recipient_type = RecipientType.EOA + + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + sends_value=bool(value), + recipient_type=intrinsic_recipient_type, + authorization_list_or_count=[auth], + return_cost_deducted_prior_execution=True, + ) + top_frame_gas = fork.transaction_top_frame_gas_calculator()( + sends_value=bool(value), + recipient_type=top_frame_recipient_type, + ) + + # Sender is the existing authority, so ``set_delegation`` refunds + # ``NEW_ACCOUNT`` to the state-gas reservoir and ``ACCOUNT_WRITE`` + # to the regular refund counter (the latter capped at + # ``gas_used // 5`` by EIP-3529). + total_gas_cost = intrinsic_gas + top_frame_gas + state_refund = gsc.REFUND_AUTH_PER_EXISTING_ACCOUNT + gas_used_pre_regular_refund = total_gas_cost - state_refund + regular_refund = min(gsc.ACCOUNT_WRITE, gas_used_pre_regular_refund // 5) + gas_used = gas_used_pre_regular_refund - regular_refund + + tx_gas_limit = total_gas_cost + 1000 + gas_price = 1_000_000_000 + + tx = Transaction( + sender=sender, + to=target, + value=value, + authorization_list=[auth], + gas_limit=tx_gas_limit, + max_fee_per_gas=gas_price, + max_priority_fee_per_gas=gas_price, + ) + + if call_target == "self": + # Value moves sender -> sender, net zero on balance. + sender_final_balance = sender_initial_balance - gas_used * gas_price + post = { + sender: Account( + nonce=2, + balance=sender_final_balance, + code=Spec7702.delegation_designation(delegated_to), + ), + } + else: + sender_final_balance = ( + sender_initial_balance - value - gas_used * gas_price + ) + post = { + sender: Account( + nonce=2, + balance=sender_final_balance, + code=Spec7702.delegation_designation(delegated_to), + ), + target: Account(balance=target_initial_balance + value), + } + + state_test(pre=pre, tx=tx, post=post) diff --git a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_warmth_invariants.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_warmth_invariants.py new file mode 100644 index 00000000000..5c819beabd3 --- /dev/null +++ b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_warmth_invariants.py @@ -0,0 +1,543 @@ +""" +EIP-2780 invariants for transaction-level account charges. + +The recipient and any EIP-7702 delegation target referenced at the +top-level transaction frame always pay the cold access rate, even when +the address is otherwise warm, identical to the sender, or refers to +itself: + +- The access list does not warm transaction-level accounts. Listing + ``tx.to`` (or a delegation target) pays the access-list cost but + does not waive the cold charge. +- The block coinbase is pre-warmed by the protocol before transaction + execution, but tx-level cold charges still fire when ``tx.to`` or a + delegation target happens to be the coinbase. +- Precompile addresses still pay the cold charge. +- Self-referential delegations (delegation target equal to the + sender, the recipient itself, or a precompile) all pay the cold + charge; the dispatched EVM frame then runs whatever code lives at + the target, including the degenerate cases of empty code (EOA, + precompile address) or a delegation prefix that itself decodes as + the ``INVALID`` opcode. +""" + +import pytest +from execution_testing import ( + AccessList, + Account, + Address, + Alloc, + Environment, + Fork, + Op, + RecipientType, + StateTestFiller, + Transaction, +) + +from ...prague.eip7702_set_code_tx.spec import Spec as Spec7702 +from .spec import ref_spec_2780 + +REFERENCE_SPEC_GIT_PATH = ref_spec_2780.git_path +REFERENCE_SPEC_VERSION = ref_spec_2780.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(0, id="zero_value"), + pytest.param(1, id="non-zero_value"), + ], +) +def test_intrinsic_charges_recipient_in_access_list( + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, + value: int, +) -> None: + """ + Recipient is listed in the access list. The intrinsic charge still + includes ``COLD_ACCOUNT_ACCESS`` for the recipient on top of the + access-list cost itself. + """ + sender_initial_balance = 10**18 + sender = pre.fund_eoa(sender_initial_balance) + + target_initial_balance = 100 + target = pre.fund_eoa(amount=target_initial_balance) + access_list = [AccessList(address=target, storage_keys=[])] + + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + access_list=access_list, + sends_value=bool(value), + recipient_type=RecipientType.EOA, + return_cost_deducted_prior_execution=True, + ) + + gas_price = 1_000_000_000 + gas_limit = intrinsic_gas + 1000 + + tx = Transaction( + ty=1, + sender=sender, + to=target, + value=value, + access_list=access_list, + gas_limit=gas_limit, + gas_price=gas_price, + ) + + sender_final_balance = ( + sender_initial_balance - value - intrinsic_gas * gas_price + ) + + post = { + sender: Account(nonce=1, balance=sender_final_balance), + target: Account(balance=target_initial_balance + value), + } + + state_test(pre=pre, tx=tx, post=post) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(0, id="zero_value"), + pytest.param(1, id="non-zero_value"), + ], +) +def test_intrinsic_charges_recipient_is_coinbase( + env: Environment, + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, + value: int, +) -> None: + """ + Recipient is the block coinbase, which is implicitly warm before + transaction execution. The intrinsic charge still includes + ``COLD_ACCOUNT_ACCESS`` for the recipient. + """ + sender_initial_balance = 10**18 + sender = pre.fund_eoa(sender_initial_balance) + target = Address(env.fee_recipient) + # Pre-fund coinbase so it is alive at top-frame check time; this + # isolates the test to the intrinsic charge invariant and avoids + # the orthogonal ``NEW_ACCOUNT`` top-frame state charge that would + # otherwise fire for value transfer to an empty recipient. + pre.fund_address(target, amount=1) + + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + sends_value=bool(value), + recipient_type=RecipientType.EOA, + return_cost_deducted_prior_execution=True, + ) + + gas_price = 1_000_000_000 + gas_limit = intrinsic_gas + 1000 + + tx = Transaction( + sender=sender, + to=target, + value=value, + gas_limit=gas_limit, + gas_price=gas_price, + ) + + # Coinbase also receives miner fees, so its post-tx balance is not + # asserted exactly; verifying the sender balance is sufficient to + # pin the intrinsic charge. + sender_final_balance = ( + sender_initial_balance - value - intrinsic_gas * gas_price + ) + + post = { + sender: Account(nonce=1, balance=sender_final_balance), + } + + state_test(pre=pre, tx=tx, post=post) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(0, id="zero_value"), + pytest.param(1, id="non-zero_value"), + ], +) +def test_top_frame_charges_delegation_in_access_list( + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, + value: int, +) -> None: + """ + Recipient holds a pre-existing EIP-7702 delegation; the delegation + target is listed in the access list. The top-frame still charges + ``COLD_ACCOUNT_ACCESS`` for the delegation target on top of the + access-list cost itself. + """ + sender_initial_balance = 10**18 + sender = pre.fund_eoa(sender_initial_balance) + + delegated_to = pre.deploy_contract(code=Op.STOP) + target_code = Spec7702.delegation_designation(delegated_to) + target = pre.deploy_contract(code=target_code) + access_list = [AccessList(address=delegated_to, storage_keys=[])] + + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + access_list=access_list, + sends_value=bool(value), + recipient_type=RecipientType.DELEGATION_7702, + return_cost_deducted_prior_execution=True, + ) + top_frame_gas = fork.transaction_top_frame_gas_calculator()( + sends_value=bool(value), + recipient_type=RecipientType.DELEGATION_7702, + ) + + total_gas_cost = intrinsic_gas + top_frame_gas + gas_price = 1_000_000_000 + gas_limit = total_gas_cost + 1000 + + tx = Transaction( + ty=1, + sender=sender, + to=target, + value=value, + access_list=access_list, + gas_limit=gas_limit, + gas_price=gas_price, + ) + + sender_final_balance = ( + sender_initial_balance - value - total_gas_cost * gas_price + ) + + post = { + sender: Account(nonce=1, balance=sender_final_balance), + target: Account(balance=value, code=target_code), + } + + state_test(pre=pre, tx=tx, post=post) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(0, id="zero_value"), + pytest.param(1, id="non-zero_value"), + ], +) +def test_top_frame_charges_delegation_is_coinbase( + env: Environment, + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, + value: int, +) -> None: + """ + Recipient holds a pre-existing EIP-7702 delegation whose target is + the block coinbase. Coinbase is implicitly warm before execution; + the top-frame still charges ``COLD_ACCOUNT_ACCESS`` for the + delegation target. + """ + sender_initial_balance = 10**18 + sender = pre.fund_eoa(sender_initial_balance) + + delegated_to = Address(env.fee_recipient) + target_code = Spec7702.delegation_designation(delegated_to) + target = pre.deploy_contract(code=target_code) + + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + sends_value=bool(value), + recipient_type=RecipientType.DELEGATION_7702, + return_cost_deducted_prior_execution=True, + ) + top_frame_gas = fork.transaction_top_frame_gas_calculator()( + sends_value=bool(value), + recipient_type=RecipientType.DELEGATION_7702, + ) + + total_gas_cost = intrinsic_gas + top_frame_gas + gas_price = 1_000_000_000 + gas_limit = total_gas_cost + 1000 + + tx = Transaction( + sender=sender, + to=target, + value=value, + gas_limit=gas_limit, + gas_price=gas_price, + ) + + # Coinbase also receives miner fees, so its post-tx balance is not + # asserted exactly. + sender_final_balance = ( + sender_initial_balance - value - total_gas_cost * gas_price + ) + + post = { + sender: Account(nonce=1, balance=sender_final_balance), + target: Account(balance=value, code=target_code), + } + + state_test(pre=pre, tx=tx, post=post) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(0, id="zero_value"), + pytest.param(1, id="non-zero_value"), + ], +) +def test_sender_is_coinbase( + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, + value: int, +) -> None: + """ + Sender is the block coinbase. The intrinsic charge is unchanged + by sender identity, but the priority-fee payment loops back to + the sender, so the net gas cost reduces to ``gas_used * + base_fee_per_gas``. + + The coinbase override is wired via a custom ``Environment`` whose + ``fee_recipient`` matches the sender's address. + """ + sender_initial_balance = 10**18 + sender = pre.fund_eoa(sender_initial_balance) + + target_initial_balance = 100 + target = pre.fund_eoa(amount=target_initial_balance) + + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + sends_value=bool(value), + recipient_type=RecipientType.EOA, + return_cost_deducted_prior_execution=True, + ) + + base_fee = 7 + gas_price = 1_000_000_000 + gas_limit = intrinsic_gas + 1000 + # Sender pays the full gas fee upfront and is credited the + # priority fee back as the coinbase: net cost is + # ``gas_used * base_fee``. + sender_final_balance = ( + sender_initial_balance - value - intrinsic_gas * base_fee + ) + + tx = Transaction( + sender=sender, + to=target, + value=value, + gas_limit=gas_limit, + gas_price=gas_price, + ) + + post = { + sender: Account(nonce=1, balance=sender_final_balance), + target: Account(balance=target_initial_balance + value), + } + + state_test( + pre=pre, + tx=tx, + post=post, + env=Environment(fee_recipient=sender, base_fee_per_gas=base_fee), + ) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(0, id="zero_value"), + pytest.param(1, id="non-zero_value"), + ], +) +def test_top_frame_charges_delegation_is_sender( + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, + value: int, +) -> None: + """ + Recipient holds a pre-existing EIP-7702 delegation whose target is + the sender (``tx.origin``). The top-frame still charges + ``COLD_ACCOUNT_ACCESS`` for the delegation target; the dispatched + EVM frame finds the sender's empty EOA code and exits immediately. + """ + sender_initial_balance = 10**18 + sender = pre.fund_eoa(sender_initial_balance) + + delegated_to = sender + target_code = Spec7702.delegation_designation(delegated_to) + target = pre.deploy_contract(code=target_code) + + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + sends_value=bool(value), + recipient_type=RecipientType.DELEGATION_7702, + return_cost_deducted_prior_execution=True, + ) + top_frame_gas = fork.transaction_top_frame_gas_calculator()( + sends_value=bool(value), + recipient_type=RecipientType.DELEGATION_7702, + ) + + total_gas_cost = intrinsic_gas + top_frame_gas + gas_price = 1_000_000_000 + gas_limit = total_gas_cost + 1000 + + tx = Transaction( + sender=sender, + to=target, + value=value, + gas_limit=gas_limit, + gas_price=gas_price, + ) + + sender_final_balance = ( + sender_initial_balance - value - total_gas_cost * gas_price + ) + + post = { + sender: Account(nonce=1, balance=sender_final_balance), + target: Account(balance=value, code=target_code), + } + + state_test(pre=pre, tx=tx, post=post) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(0, id="zero_value"), + pytest.param(1, id="non-zero_value"), + ], +) +def test_top_frame_charges_delegation_is_recipient( + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, + value: int, +) -> None: + """ + Recipient holds a pre-existing EIP-7702 delegation pointing back + at itself. The top-frame charges ``COLD_ACCOUNT_ACCESS`` for the + delegation target (the recipient itself), and then the dispatched + EVM frame runs the recipient's code -- which *is* the delegation + prefix ``0xef 01 00 ``. The leading ``0xef`` decodes as the + ``INVALID`` opcode, consuming the remaining EVM budget. The + intrinsic and top-frame gas remain paid; the value transfer is + rolled back. + """ + sender_initial_balance = 10**18 + sender = pre.fund_eoa(sender_initial_balance) + + # Pre-allocate an EOA that delegates to itself. The 1-wei balance + # keeps the account alive at top-frame check time so the + # ``NEW_ACCOUNT`` charge does not fire. + target = pre.fund_eoa(amount=1, delegation="Self") + target_code = Spec7702.delegation_designation(target) + + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + sends_value=bool(value), + recipient_type=RecipientType.DELEGATION_7702, + return_cost_deducted_prior_execution=True, + ) + top_frame_gas = fork.transaction_top_frame_gas_calculator()( + sends_value=bool(value), + recipient_type=RecipientType.DELEGATION_7702, + ) + + # The dispatched frame burns the entire EVM budget on the + # ``INVALID`` opcode and the value transfer is rolled back, so the + # sender pays the full ``gas_limit``. + gas_price = 1_000_000_000 + gas_limit = intrinsic_gas + top_frame_gas + 50_000 + + tx = Transaction( + sender=sender, + to=target, + value=value, + gas_limit=gas_limit, + gas_price=gas_price, + ) + + sender_final_balance = sender_initial_balance - gas_limit * gas_price + + post = { + sender: Account(nonce=1, balance=sender_final_balance), + # Value transfer rolled back by the ``INVALID``; the pre-tx + # 1-wei balance is preserved. + target: Account(balance=1, code=target_code), + } + + state_test(pre=pre, tx=tx, post=post) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(0, id="zero_value"), + pytest.param(1, id="non-zero_value"), + ], +) +def test_top_frame_charges_delegation_is_precompile( + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, + value: int, +) -> None: + """ + Recipient holds a pre-existing EIP-7702 delegation pointing at a + precompile address (``IDENTITY``, ``0x04``). The top-frame charges + ``COLD_ACCOUNT_ACCESS``; the dispatched EVM frame sets + ``disable_precompiles = True`` for delegated calls, so the + precompile body does not run. The code lookup at the precompile + address returns the empty byte string and the frame exits + immediately. + """ + sender_initial_balance = 10**18 + sender = pre.fund_eoa(sender_initial_balance) + + delegated_to = Address(0x04) + target_code = Spec7702.delegation_designation(delegated_to) + target = pre.deploy_contract(code=target_code) + + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + sends_value=bool(value), + recipient_type=RecipientType.DELEGATION_7702, + return_cost_deducted_prior_execution=True, + ) + top_frame_gas = fork.transaction_top_frame_gas_calculator()( + sends_value=bool(value), + recipient_type=RecipientType.DELEGATION_7702, + ) + + total_gas_cost = intrinsic_gas + top_frame_gas + gas_price = 1_000_000_000 + gas_limit = total_gas_cost + 1000 + + tx = Transaction( + sender=sender, + to=target, + value=value, + gas_limit=gas_limit, + gas_price=gas_price, + ) + + sender_final_balance = ( + sender_initial_balance - value - total_gas_cost * gas_price + ) + + post = { + sender: Account(nonce=1, balance=sender_final_balance), + target: Account(balance=value, code=target_code), + } + + state_test(pre=pre, tx=tx, post=post) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_eip_mainnet.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_eip_mainnet.py index 73edc070e00..f14be0735dd 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_eip_mainnet.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_eip_mainnet.py @@ -9,6 +9,7 @@ Alloc, Fork, Op, + RecipientType, StateTestFiller, Transaction, TransactionReceipt, @@ -25,17 +26,29 @@ def test_simple_transfer_mainnet( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test that a simple ETH transfer emits a transfer log on mainnet.""" sender = pre.fund_eoa() recipient = pre.nonexistent_account() + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + sends_value=True, + recipient_type=RecipientType.EMPTY_ACCOUNT, + return_cost_deducted_prior_execution=True, + ) + top_frame_state_gas = fork.transaction_top_frame_state_gas( + sends_value=True, + recipient_type=RecipientType.EMPTY_ACCOUNT, + ) + gas_limit = intrinsic_gas + top_frame_state_gas + tx = Transaction( ty=0x02, sender=sender, to=recipient, value=1, - gas_limit=21_000, + gas_limit=gas_limit, expected_receipt=TransactionReceipt( logs=[transfer_log(sender, recipient, 1)] ), diff --git a/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/test_gas_accounting.py b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/test_gas_accounting.py index cc9fcee86ea..b521689d295 100644 --- a/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/test_gas_accounting.py +++ b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/test_gas_accounting.py @@ -105,6 +105,10 @@ def build_refund_tx( auth_state_refund = ( gsc.REFUND_AUTH_PER_EXISTING_ACCOUNT * refunds_count ) + # The worst-case `ACCOUNT_WRITE` charged at intrinsic + # time is refunded via the refund counter for existing + # authorities, even if the transaction reverts. + refund_counter += gsc.ACCOUNT_WRITE * refunds_count case _: raise ValueError( f"Unknown refund type: {refund_type} (Test needs update)" diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py index bbf3877e673..e34f8cb13e2 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py @@ -28,6 +28,7 @@ Header, Initcode, Op, + RecipientType, StateTestFiller, Transaction, TransactionException, @@ -97,7 +98,14 @@ def test_bal_balance_changes( calldata=b"", contract_creation=False, access_list=[], + sends_value=True, + recipient_type=RecipientType.EMPTY_ACCOUNT, ) + top_frame_state_gas = fork.transaction_top_frame_state_gas( + sends_value=True, + recipient_type=RecipientType.EMPTY_ACCOUNT, + ) + total_gas_cost = intrinsic_gas_cost + top_frame_state_gas # Hard-coded gas price allows to calculate the tx final price gas_price = 1_000_000_000 tx_value = 100 @@ -115,7 +123,7 @@ def test_bal_balance_changes( # Account for both the value sent and gas cost (gas_price * gas_used) alice_final_balance = ( - alice_initial_balance - tx_value - (intrinsic_gas_cost * gas_price) + alice_initial_balance - tx_value - (total_gas_cost * gas_price) ) block = Block( @@ -495,8 +503,15 @@ def test_bal_block_rewards( calldata=b"", contract_creation=False, access_list=[], + recipient_type=RecipientType.EMPTY_ACCOUNT, + sends_value=True, + ) + top_frame_state_gas = fork.transaction_top_frame_state_gas( + sends_value=True, + recipient_type=RecipientType.EMPTY_ACCOUNT, ) - tx_gas_limit = intrinsic_gas + 1000 # add a small buffer + expected_gas_used = intrinsic_gas + top_frame_state_gas + tx_gas_limit = expected_gas_used + 1000 # add a small buffer gas_price = 0xA tx_value = 100 extra_balance = 1000 @@ -516,7 +531,7 @@ def test_bal_block_rewards( # EIP-1559 fee calculation: # - Total gas cost - total_gas_cost = intrinsic_gas * gas_price + total_gas_cost = expected_gas_used * gas_price # - Tip portion genesis_env = Environment(base_fee_per_gas=0x7) @@ -525,7 +540,7 @@ def test_bal_block_rewards( parent_gas_used=0, parent_gas_limit=genesis_env.gas_limit, ) - tip_to_charlie = (gas_price - base_fee_per_gas) * intrinsic_gas + tip_to_charlie = (gas_price - base_fee_per_gas) * expected_gas_used alice_final_balance = alice_initial_balance - tx_value - total_gas_cost @@ -903,7 +918,9 @@ def test_bal_self_transfer( alice = pre.fund_eoa(amount=start_balance) intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() - intrinsic_gas_cost = intrinsic_gas_calculator() + intrinsic_gas_cost = intrinsic_gas_calculator( + recipient_type=RecipientType.SELF + ) tx = Transaction( sender=alice, @@ -947,7 +964,9 @@ def test_bal_zero_value_transfer( bob = pre.fund_eoa(amount=100) intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() - intrinsic_gas_cost = intrinsic_gas_calculator() + intrinsic_gas_cost = intrinsic_gas_calculator( + recipient_type=RecipientType.EOA + ) tx = Transaction( sender=alice, @@ -1538,8 +1557,14 @@ def test_bal_coinbase_zero_tip( calldata=b"", contract_creation=False, access_list=[], + recipient_type=RecipientType.EMPTY_ACCOUNT, + sends_value=True, ) - tx_gas_limit = intrinsic_gas + 1000 + top_frame_state_gas = fork.transaction_top_frame_state_gas( + sends_value=True, + recipient_type=RecipientType.EMPTY_ACCOUNT, + ) + tx_gas_limit = intrinsic_gas + top_frame_state_gas + 1000 # Calculate base fee genesis_env = Environment(base_fee_per_gas=0x7) @@ -1562,7 +1587,9 @@ def test_bal_coinbase_zero_tip( ) alice_final_balance = ( - alice_initial_balance - tx_value - (intrinsic_gas * base_fee_per_gas) + alice_initial_balance + - tx_value + - ((intrinsic_gas + top_frame_state_gas) * base_fee_per_gas) ) block = Block( @@ -2022,11 +2049,21 @@ def test_bal_multiple_balance_changes_same_account( charlie = pre.fund_eoa(amount=0) intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() - tx_intrinsic_gas = intrinsic_gas_calculator(calldata=b"", access_list=[]) + tx_intrinsic_gas = intrinsic_gas_calculator( + calldata=b"", + access_list=[], + recipient_type=RecipientType.EMPTY_ACCOUNT, + sends_value=True, + ) + top_frame_state_gas = fork.transaction_top_frame_state_gas( + sends_value=True, + recipient_type=RecipientType.EMPTY_ACCOUNT, + ) # bob receives funds in tx0, then spends everything in tx1 gas_price = 10 - tx1_gas_cost = tx_intrinsic_gas * gas_price + expected_gas_used = tx_intrinsic_gas + top_frame_state_gas + tx1_gas_cost = expected_gas_used * gas_price spend_amount = 100 funding_amount = tx1_gas_cost + spend_amount @@ -2034,7 +2071,7 @@ def test_bal_multiple_balance_changes_same_account( sender=alice, to=bob, value=funding_amount, - gas_limit=tx_intrinsic_gas, + gas_limit=expected_gas_used, gas_price=gas_price, ) @@ -2042,7 +2079,7 @@ def test_bal_multiple_balance_changes_same_account( sender=bob, to=charlie, value=spend_amount, - gas_limit=tx_intrinsic_gas, + gas_limit=expected_gas_used, gas_price=gas_price, ) @@ -2949,6 +2986,8 @@ def test_bal_cross_tx_funding_chain( target = pre.deploy_contract(code=target_code) intrinsic_calc = fork.transaction_intrinsic_cost_calculator() + # Last hop (eunice -> target) is a plain CONTRACT call with no + # value, so the default intrinsic applies. intrinsic_gas = intrinsic_calc() eunice_exact_gas = intrinsic_gas + target_code.gas_cost(fork) eunice_gas_limit = ( @@ -2957,7 +2996,21 @@ def test_bal_cross_tx_funding_chain( else eunice_exact_gas ) eunice_upfront = eunice_gas_limit * gas_price - transfer_cost = intrinsic_gas * gas_price + # Forwarding hops (alice -> bob, ..., dan -> eunice) transfer value + # to recipients that begin empty, so each pays the value-transfer + # intrinsic surcharges plus the top-frame ``NEW_ACCOUNT`` state + # charge that fires under EIP-2780. With the default zero + # state-gas reservoir the latter spills entirely into regular gas. + forwarding_intrinsic = intrinsic_calc( + sends_value=True, + recipient_type=RecipientType.EMPTY_ACCOUNT, + ) + forwarding_top_frame_state = fork.transaction_top_frame_state_gas( + sends_value=True, + recipient_type=RecipientType.EMPTY_ACCOUNT, + ) + forwarding_gas = forwarding_intrinsic + forwarding_top_frame_state + transfer_cost = forwarding_gas * gas_price # Each sender (including alice) starts with or receives exactly what # the next forward + its own gas demands; everyone ends at zero in @@ -2990,28 +3043,28 @@ def test_bal_cross_tx_funding_chain( sender=alice, to=bob, value=alice_value, - gas_limit=intrinsic_gas, + gas_limit=forwarding_gas, gas_price=gas_price, ), Transaction( sender=bob, to=charlie, value=bob_value, - gas_limit=intrinsic_gas, + gas_limit=forwarding_gas, gas_price=gas_price, ), Transaction( sender=charlie, to=dan, value=charlie_value, - gas_limit=intrinsic_gas, + gas_limit=forwarding_gas, gas_price=gas_price, ), Transaction( sender=dan, to=eunice, value=dan_value, - gas_limit=intrinsic_gas, + gas_limit=forwarding_gas, gas_price=gas_price, ), Transaction( @@ -3628,7 +3681,11 @@ def test_bal_gas_limit_boundary( if with_tx: alice = pre.fund_eoa() - bob = pre.fund_eoa(amount=0) + # Fund bob with 1 wei so the recipient is alive at top-frame + # check time; this avoids the EIP-2780 ``NEW_ACCOUNT`` state + # charge that would otherwise inflate the tx's gas needs past + # the BAL-sized ``block_gas_limit``. + bob = pre.fund_eoa(amount=1) # alice (sender) + bob (recipient) + coinbase (EIP-3651 warm). extra_items += 3 txs.append( @@ -3644,10 +3701,10 @@ def test_bal_gas_limit_boundary( ) expected_accounts[bob] = BalAccountExpectation( balance_changes=[ - BalBalanceChange(block_access_index=1, post_balance=1) + BalBalanceChange(block_access_index=1, post_balance=2) ], ) - post[bob] = Account(balance=1) + post[bob] = Account(balance=2) if with_cl_withdrawal: charlie = pre.fund_eoa(amount=0) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip4895.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip4895.py index a78d67763dc..ce091534195 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip4895.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip4895.py @@ -21,6 +21,7 @@ Header, Initcode, Op, + RecipientType, Transaction, Withdrawal, compute_create_address, @@ -730,7 +731,15 @@ def test_bal_withdrawal_to_coinbase( coinbase = pre.fund_eoa(amount=0) intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() - intrinsic_gas = intrinsic_gas_calculator() + intrinsic_gas = intrinsic_gas_calculator( + recipient_type=RecipientType.EMPTY_ACCOUNT, + sends_value=True, + ) + top_frame_state_gas = fork.transaction_top_frame_state_gas( + sends_value=True, + recipient_type=RecipientType.EMPTY_ACCOUNT, + ) + total_intrinsic_gas = intrinsic_gas + top_frame_state_gas # Calculate tip to coinbase genesis_env = Environment(base_fee_per_gas=0x7) @@ -755,11 +764,11 @@ def test_bal_withdrawal_to_coinbase( sender=alice, to=bob, value=tx_value, - gas_limit=intrinsic_gas, + gas_limit=total_intrinsic_gas, **tx_kwargs, ) - tip_to_coinbase = priority_fee * intrinsic_gas + tip_to_coinbase = priority_fee * total_intrinsic_gas withdrawal_amount = 10 coinbase_final_balance = tip_to_coinbase + (withdrawal_amount * GWEI) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_invalid.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_invalid.py index 33585b2a3cc..c788617a287 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_invalid.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_invalid.py @@ -28,6 +28,7 @@ Header, Initcode, Op, + RecipientType, Storage, Transaction, Withdrawal, @@ -1464,14 +1465,21 @@ def test_bal_invalid_missing_coinbase( calldata=b"", contract_creation=False, access_list=[], + recipient_type=RecipientType.EMPTY_ACCOUNT, + sends_value=True, ) + top_frame_state_gas = fork.transaction_top_frame_state_gas( + sends_value=True, + recipient_type=RecipientType.EMPTY_ACCOUNT, + ) + total_intrinsic_gas = intrinsic_gas + top_frame_state_gas gas_price = 0xA tx = Transaction( sender=alice, to=bob, value=100, - gas_limit=intrinsic_gas + 1000, + gas_limit=total_intrinsic_gas + 1000, gas_price=gas_price, ) @@ -1481,7 +1489,7 @@ def test_bal_invalid_missing_coinbase( parent_gas_used=0, parent_gas_limit=genesis_env.gas_limit, ) - tip = (gas_price - base_fee_per_gas) * intrinsic_gas + tip = (gas_price - base_fee_per_gas) * total_intrinsic_gas blockchain_test( pre=pre, @@ -1546,14 +1554,21 @@ def test_bal_invalid_coinbase_balance_value( calldata=b"", contract_creation=False, access_list=[], + recipient_type=RecipientType.EMPTY_ACCOUNT, + sends_value=True, + ) + top_frame_state_gas = fork.transaction_top_frame_state_gas( + sends_value=True, + recipient_type=RecipientType.EMPTY_ACCOUNT, ) + total_intrinsic_gas = intrinsic_gas + top_frame_state_gas gas_price = 0xA tx = Transaction( sender=alice, to=bob, value=100, - gas_limit=intrinsic_gas + 1000, + gas_limit=total_intrinsic_gas + 1000, gas_price=gas_price, ) @@ -1563,7 +1578,7 @@ def test_bal_invalid_coinbase_balance_value( parent_gas_used=0, parent_gas_limit=genesis_env.gas_limit, ) - tip = (gas_price - base_fee_per_gas) * intrinsic_gas + tip = (gas_price - base_fee_per_gas) * total_intrinsic_gas blockchain_test( pre=pre, diff --git a/tests/amsterdam/eip7976_increase_calldata_floor_cost/test_additional_coverage.py b/tests/amsterdam/eip7976_increase_calldata_floor_cost/test_additional_coverage.py index c62550daa9d..11ef0937dd3 100644 --- a/tests/amsterdam/eip7976_increase_calldata_floor_cost/test_additional_coverage.py +++ b/tests/amsterdam/eip7976_increase_calldata_floor_cost/test_additional_coverage.py @@ -144,6 +144,11 @@ def test_token_calculation_verification( expected_intrinsic_cost = gas_costs.TX_BASE + ( expected_standard_tokens * gas_costs.TX_DATA_TOKEN_STANDARD ) + if fork.is_eip_enabled(2780): + # EIP-2780 surfaces an explicit recipient-access charge for + # non-self, non-create transactions; the ``to`` fixture + # defaults to a deployed contract, so the charge applies. + expected_intrinsic_cost += gas_costs.COLD_ACCOUNT_ACCESS assert intrinsic_cost_before_execution == expected_intrinsic_cost, ( f"Intrinsic cost mismatch for {description}: " f"{intrinsic_cost_before_execution} != {expected_intrinsic_cost} " diff --git a/tests/amsterdam/eip7976_increase_calldata_floor_cost/test_floor_boundary_exact_balance.py b/tests/amsterdam/eip7976_increase_calldata_floor_cost/test_floor_boundary_exact_balance.py index b913e674f4d..4561cf54626 100644 --- a/tests/amsterdam/eip7976_increase_calldata_floor_cost/test_floor_boundary_exact_balance.py +++ b/tests/amsterdam/eip7976_increase_calldata_floor_cost/test_floor_boundary_exact_balance.py @@ -25,7 +25,7 @@ @pytest.mark.parametrize( "zero_bytes", [ - pytest.param(100, id="100_zero_bytes"), + pytest.param(200, id="200_zero_bytes"), pytest.param(1000, id="1000_zero_bytes"), ], ) diff --git a/tests/amsterdam/eip7976_increase_calldata_floor_cost/test_refunds.py b/tests/amsterdam/eip7976_increase_calldata_floor_cost/test_refunds.py index 7463c6b66ef..e21d8c5a77e 100644 --- a/tests/amsterdam/eip7976_increase_calldata_floor_cost/test_refunds.py +++ b/tests/amsterdam/eip7976_increase_calldata_floor_cost/test_refunds.py @@ -97,11 +97,15 @@ def max_refund(fork: Fork, refund_type: RefundTypes) -> int: if refund_type == RefundTypes.STORAGE_CLEAR else 0 ) - if ( - not fork.is_eip_enabled(8037) - and refund_type == RefundTypes.AUTHORIZATION_EXISTING_AUTHORITY - ): - max_refund += gas_costs.REFUND_AUTH_PER_EXISTING_ACCOUNT + if refund_type == RefundTypes.AUTHORIZATION_EXISTING_AUTHORITY: + if fork.is_eip_enabled(8037): + # The worst-case `ACCOUNT_WRITE` charged at intrinsic time + # is refunded via the refund counter when the authority's + # account leaf already exists; the state-gas portion is + # refilled separately and is not subject to the cap. + max_refund += gas_costs.ACCOUNT_WRITE + else: + max_refund += gas_costs.REFUND_AUTH_PER_EXISTING_ACCOUNT return max_refund @@ -160,10 +164,11 @@ def intrinsic_gas_data_floor_minimum_delta() -> int: would always be the below the execution gas cost even after the refund is applied. - This value has been set as of Amsterdam and should be adjusted if the gas - costs change. + This value has been set as of Amsterdam (with the provisional + state-access repricing) and should be adjusted if the gas costs + change. """ - return 250 + return 11_000 @pytest.fixture diff --git a/tests/amsterdam/eip7981_increase_access_list_cost/test_floor_boundary_exact_balance.py b/tests/amsterdam/eip7981_increase_access_list_cost/test_floor_boundary_exact_balance.py index 10c15ec10a3..69fcff2d79a 100644 --- a/tests/amsterdam/eip7981_increase_access_list_cost/test_floor_boundary_exact_balance.py +++ b/tests/amsterdam/eip7981_increase_access_list_cost/test_floor_boundary_exact_balance.py @@ -31,7 +31,11 @@ @pytest.mark.parametrize( "nonzero_bytes", [ - pytest.param(1000, id="1000_nonzero_bytes"), + # Must be large enough that the floor midpoint chosen below + # stays above the access-list intrinsic cost (asserted in the + # test body): each nonzero byte adds 64 gas to the floor but + # only 16 to the intrinsic cost. + pytest.param(1700, id="1700_nonzero_bytes"), pytest.param(2000, id="2000_nonzero_bytes"), ], ) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/spec.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/spec.py index c5fbeb36071..e807c34ebd4 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/spec.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/spec.py @@ -47,7 +47,10 @@ class Spec: STATE_BYTES_PER_STORAGE_SET = 64 STATE_BYTES_PER_AUTH_BASE = 23 - # Regular gas constants (EIP-8037 replaces old combined costs) - REGULAR_GAS_CREATE = 9000 - PER_AUTH_BASE_COST = 7500 - GAS_COLD_STORAGE_WRITE = 5000 + # Regular gas constants. EIP-8037 separated state from regular gas; + # EIP-8038 then repriced them. + REGULAR_GAS_CREATE = 11000 + # Total regular intrinsic per EIP-7702 authorization: + # ACCOUNT_WRITE (8000) + REGULAR_PER_AUTH_BASE_COST (7816). + PER_AUTH_BASE_COST = 15816 + GAS_COLD_STORAGE_WRITE = 13000 diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_block_2d_gas_accounting.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_block_2d_gas_accounting.py index 962e26fbdec..42775006b8c 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_block_2d_gas_accounting.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_block_2d_gas_accounting.py @@ -41,9 +41,9 @@ def sstore_tx_gas(fork: Fork, num_sstores: int = 1) -> tuple[int, int]: """Return (regular, state) gas for a tx with N cold SSTOREs.""" intrinsic_gas = fork.transaction_intrinsic_cost_calculator()() - evm_total = num_sstores * Op.SSTORE(0, 1).gas_cost(fork) + evm_total = num_sstores * Op.SSTORE(0, 1).regular_cost(fork) state = num_sstores * Op.SSTORE(new_value=1).state_cost(fork) - return intrinsic_gas + evm_total - state, state + return intrinsic_gas + evm_total, state def sstore_txs( @@ -625,7 +625,9 @@ def test_tx_inclusion_at_regular_gas_block_limit_small( """ gas_limit_cap = fork.transaction_gas_limit_cap() assert gas_limit_cap is not None - intrinsic_gas = fork.transaction_intrinsic_cost_calculator()() + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + sends_value=True, + ) filler_tx_count = (fork.minimum_block_gas_limit() // intrinsic_gas) + 1 block_gas_limit = intrinsic_gas * (filler_tx_count + 1) @@ -636,6 +638,7 @@ def test_tx_inclusion_at_regular_gas_block_limit_small( Transaction( to=dest_contract, gas_limit=intrinsic_gas, + value=1, sender=filler_sender, ) for _ in range(filler_tx_count) @@ -647,6 +650,7 @@ def test_tx_inclusion_at_regular_gas_block_limit_small( excess_tx = Transaction( to=dest_contract, gas_limit=excess_tx_gas_limit, + value=1, sender=pre.fund_eoa(), error=error, ) @@ -789,18 +793,26 @@ def test_receipt_cumulative_differs_from_header_gas_used( @pytest.mark.parametrize("dominant_dimension", ["state", "regular"]) +@pytest.mark.parametrize( + "single_tx", + [ + pytest.param(True, id="single_tx"), + pytest.param(False, id="multiple_txs"), + ], +) @pytest.mark.valid_from("EIP8037") def test_base_fee_per_gas_follows_dominant_dimension( blockchain_test: BlockchainTestFiller, pre: Alloc, fork: Fork, dominant_dimension: str, + single_tx: bool, ) -> None: """ Verify the child block's base fee follows the bottleneck dimension. Block 1 exceeds the gas target on one dimension only: state, via - SSTORE-set txs that spill, or regular, via STOP txs. Its header + SSTORE-set txs that spill, or regular, via STOP/MSTORE txs. Its header gas_used = max(regular, state) is then set by that dimension alone, which lifts empty block 2's base fee under the EIP-1559 update. """ @@ -811,30 +823,53 @@ def test_base_fee_per_gas_follows_dominant_dimension( txs: list[Transaction] = [] post: dict = {} + num_sstores = 0 if dominant_dimension == "state": - num_txs = 5 - tx_regular, tx_state = sstore_tx_gas(fork) + if single_tx: + num_txs = 1 + num_sstores = target // sstore_tx_gas(fork, num_sstores=1)[1] + 1 + tx_regular, tx_state = sstore_tx_gas(fork, num_sstores=num_sstores) + else: + num_sstores = 1 + tx_regular, tx_state = sstore_tx_gas(fork, num_sstores=num_sstores) + while tx_regular >= tx_state: + num_sstores += 1 + tx_regular, tx_state = sstore_tx_gas( + fork, num_sstores=num_sstores + ) + num_txs = target // tx_state + 1 block_regular = num_txs * tx_regular block_state = num_txs * tx_state tx_gas_limit = tx_regular + tx_state assert block_state > target > block_regular else: - num_txs = 15 - tx_gas_limit = fork.transaction_intrinsic_cost_calculator()() + if single_tx: + num_txs = 1 + # Just consume all gas + regular_contract = pre.deploy_contract( + code=Op.MSTORE(offset=2**256 - 1, value=1) + Op.STOP + ) + tx_gas_limit = target + 1 + else: + tx_gas_limit = fork.transaction_intrinsic_cost_calculator()() + # Enough STOP txs that regular gas alone clears the target. + regular_contract = pre.deploy_contract(code=Op.STOP) + num_txs = target // tx_gas_limit + 1 block_regular = num_txs * tx_gas_limit block_state = 0 - stop_contract = pre.deploy_contract(code=Op.STOP) assert block_regular > target > block_state for _ in range(num_txs): if dominant_dimension == "state": storage = Storage() - contract = pre.deploy_contract( - code=Op.SSTORE(storage.store_next(1), 1) + Op.STOP, - ) + code = Bytecode() + for _ in range(num_sstores): + code += Op.SSTORE(storage.store_next(1), 1) + code += Op.STOP + contract = pre.deploy_contract(code=code) post[contract] = Account(storage=storage) else: - contract = stop_contract + contract = regular_contract txs.append( Transaction( to=contract, @@ -846,6 +881,10 @@ def test_base_fee_per_gas_follows_dominant_dimension( ) block_1_gas_used = max(block_regular, block_state) + assert block_1_gas_used < gas_limit, ( + "test needs update: gas_limit reached by usage, simply raise the " + "anchored gas_limit value" + ) base_fee_calc = fork.base_fee_per_gas_calculator() block_1_base_fee = base_fee_calc( parent_base_fee_per_gas=genesis_base_fee, 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 a30fdb28391..62f3a91eeca 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 @@ -92,8 +92,9 @@ def test_delegatecall_child_spill_not_double_charged( """ Test DELEGATECALL child state gas paid from `gas_left` is not recharged. - With gas below the Amsterdam tx gas cap, the top-level frame starts with - no state gas reservoir and the child pays for SSTOREs by spilling from + With the gas limit pinned to the Amsterdam tx gas cap and no requested + reservoir (`state_gas_reservoir=0`), the top-level frame starts with no + state gas reservoir and the child pays for SSTOREs by spilling from `gas_left`. The parent frame must not charge the same state growth again at frame end. """ @@ -115,7 +116,7 @@ def test_delegatecall_child_spill_not_double_charged( tx = Transaction( to=caller, - gas_limit=700_000, + state_gas_reservoir=0, sender=pre.fund_eoa(), ) 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 8cea2d84600..b9b5bde06f5 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 @@ -657,8 +657,8 @@ def test_code_deposit_oog_preserves_parent_reservoir( init_code = Op.RETURN(0, deploy_size) # Limited regular gas forwarded to the factory. After CREATE - # takes 63/64, the factory retains ~15 K for its SSTOREs. - child_gas = 1_000_000 + # takes 63/64, the factory retains ~23 K for its SSTOREs. + child_gas = 1_500_000 factory_storage = Storage() factory = pre.deploy_contract( @@ -790,8 +790,9 @@ def test_parent_state_gas_after_child_failure( # Factory bytecode shape costs, derived from fork.gas_costs(): # pre-CREATE: PUSH32 + PUSH1 + MSTORE (with 1-word expansion) # + 3 PUSHes for CREATE inputs - # post-CREATE: PUSH key + SSTORE (no-op) + 2 PUSHes + SSTORE - # (zero-to-nonzero regular) + # post-CREATE: PUSH key + SSTORE (cold no-op: access cost only) + # + 2 PUSHes + SSTORE (cold zero-to-nonzero: + # access + write, the compound COLD_STORAGE_WRITE) factory_pre_create_regular = ( gas_costs.VERY_LOW * 2 + gas_costs.OPCODE_MSTORE_BASE @@ -801,7 +802,6 @@ def test_parent_state_gas_after_child_failure( factory_post_create_regular = ( gas_costs.VERY_LOW + gas_costs.COLD_STORAGE_ACCESS - + gas_costs.WARM_ACCESS + gas_costs.VERY_LOW * 2 + gas_costs.COLD_STORAGE_WRITE ) @@ -2465,18 +2465,20 @@ def test_selfdestruct_in_create_tx_initcode( create_state_gas = fork.create_state_gas(code_size=0) beneficiary = 0xDEAD - initcode = Op.SELFDESTRUCT(beneficiary) + # `account_new` folds the beneficiary's `ACCOUNT_WRITE` regular + # cost and account-creation state gas into `gas_cost`. + initcode = Op.SELFDESTRUCT(beneficiary, account_new=True) sender = pre.fund_eoa() intrinsic_calc = fork.transaction_intrinsic_cost_calculator() intrinsic_total = intrinsic_calc( - calldata=bytes(initcode), contract_creation=True + calldata=bytes(initcode), contract_creation=True, sends_value=True ) expected_state = create_state_gas + gas_costs.NEW_ACCOUNT initcode_gas = initcode.gas_cost(fork) - gas_limit = intrinsic_total + initcode_gas + gas_costs.NEW_ACCOUNT + 1000 + gas_limit = intrinsic_total + initcode_gas + 1000 tx = Transaction( sender=sender, diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_multi_block.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_multi_block.py index 37ad180f529..04a109b5cd8 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_multi_block.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_multi_block.py @@ -58,6 +58,10 @@ def test_exact_coinbase_fee_simple_sstore( # Gas breakdown for tx 1 (SSTORE zero-to-nonzero, no calldata): # PUSH1(1) + PUSH1(0) + SSTORE(cold, zero-to-nonzero) + STOP intrinsic_regular = gas_costs.TX_BASE + if fork.is_eip_enabled(2780): + # EIP-2780 surfaces an explicit recipient-access charge for + # non-self, non-create transactions on top of ``TX_BASE``. + intrinsic_regular += gas_costs.COLD_ACCOUNT_ACCESS evm_regular = ( 2 * gas_costs.VERY_LOW # PUSH1 + PUSH1 + gas_costs.COLD_STORAGE_WRITE # SSTORE cold zero-to-nonzero diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_selfdestruct.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_selfdestruct.py index 82c465cd67a..ff0fec86530 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_selfdestruct.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_selfdestruct.py @@ -227,8 +227,9 @@ def test_selfdestruct_state_gas_refilled_on_ancestor_revert( The inner frame spills the NEW_ACCOUNT charge and self-destructs successfully, then the caller reverts: the beneficiary creation - rolls back and the spilled charge is refilled, so only regular gas - is billed. + rolls back and the spilled state charge is refilled. The EIP-8038 + regular account-write charge for the attempted empty-account value + transfer remains billed. """ beneficiary = 0xDEAD inner_code = Op.SELFDESTRUCT(beneficiary) @@ -240,6 +241,7 @@ def test_selfdestruct_state_gas_refilled_on_ancestor_revert( fork.transaction_intrinsic_cost_calculator()() + caller_code.gas_cost(fork) + inner_code.gas_cost(fork) + + fork.gas_costs().ACCOUNT_WRITE ) tx = Transaction(to=caller, sender=pre.fund_eoa()) @@ -714,34 +716,30 @@ def test_selfdestruct_via_delegatecall_chain_no_refund( @pytest.mark.valid_from("EIP8037") -def test_selfdestruct_new_beneficiary_no_regular_account_creation_cost( +def test_selfdestruct_new_beneficiary_account_write_cost( state_test: StateTestFiller, pre: Alloc, fork: Fork, ) -> None: """ - Verify SELFDESTRUCT to a new beneficiary does not charge a - regular account-creation cost on top of state gas. + Verify SELFDESTRUCT to a new beneficiary charges `ACCOUNT_WRITE` + regular gas plus the account-creation state gas, and not the + legacy combined regular account-creation cost. """ - gas_costs = fork.gas_costs() - new_account_state_gas = gas_costs.NEW_ACCOUNT - beneficiary = pre.fund_eoa(amount=0) - victim_code = Op.SELFDESTRUCT(beneficiary) + victim_code = Op.SELFDESTRUCT(beneficiary, account_new=True) victim = pre.deploy_contract(code=victim_code, balance=1) - # Tight budget: slack is less than the old pre-Amsterdam regular - # account-creation cost, so any extra regular draw would OOG. + # Tight budget: slack is less than the legacy 25,000 regular + # account-creation cost minus `ACCOUNT_WRITE`, so any regular draw + # beyond `ACCOUNT_WRITE` would OOG. The opcode metadata folds the + # `ACCOUNT_WRITE` regular cost and the account-creation state gas + # into `gas_cost`. intrinsic = fork.transaction_intrinsic_cost_calculator()() tx = Transaction( to=victim, - gas_limit=( - intrinsic - + victim_code.gas_cost(fork) - + new_account_state_gas - + 20_000 - ), + gas_limit=(intrinsic + victim_code.gas_cost(fork) + 4_000), sender=pre.fund_eoa(), ) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_set_code.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_set_code.py index bf18b2ba4a0..434de6023df 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_set_code.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_set_code.py @@ -386,6 +386,10 @@ def test_auth_refund_block_gas_accounting( `RESET_DELEGATION_ADDRESS`; same full refill, since the refill keys off the *pre-state* code slot, not what we're writing. + When the authority's account leaf already exists, the worst-case + `ACCOUNT_WRITE` charged at intrinsic time is additionally refunded + via the regular refund counter, subject to the refund cap. + Verified via header `gas_used`, receipt `cumulative_gas_used`, and the authority post-state (catches a silently-skipped auth). """ @@ -397,6 +401,7 @@ def test_auth_refund_block_gas_accounting( ) intrinsic_regular = total_intrinsic - intrinsic_state_gas new_account_refund = fork.gas_costs().REFUND_AUTH_PER_EXISTING_ACCOUNT + account_write = fork.gas_costs().ACCOUNT_WRITE # Per-auth intrinsic state gas covers NEW_ACCOUNT + AUTH_BASE; the # AUTH_BASE portion is what's left after stripping NEW_ACCOUNT. auth_base_refund = intrinsic_state_gas - new_account_refund @@ -411,17 +416,20 @@ def test_auth_refund_block_gas_accounting( signer = pre.fund_eoa(amount=0) pre_nonce = 0 auth_refund = auth_base_refund if authorize_to_null else 0 + refund_counter = 0 elif signer_pre_state == "existing_leaf": signer = pre.fund_eoa() pre_nonce = 0 auth_refund = new_account_refund + ( auth_base_refund if authorize_to_null else 0 ) + refund_counter = account_write elif signer_pre_state == "existing_delegation": # `fund_eoa(delegation=...)` sets the authority's nonce to 1. signer = pre.fund_eoa(delegation=contract_old) pre_nonce = 1 auth_refund = new_account_refund + auth_base_refund + refund_counter = account_write else: raise ValueError(f"unknown signer_pre_state: {signer_pre_state!r}") @@ -450,7 +458,14 @@ def test_auth_refund_block_gas_accounting( intrinsic_regular, intrinsic_state_gas - auth_refund, ) - receipt_cumulative_gas_used = total_intrinsic - auth_refund + # The state refill is not subject to the refund cap; the regular + # `ACCOUNT_WRITE` refund is. + gas_used_before_refund = total_intrinsic - auth_refund + regular_refund = min( + gas_used_before_refund // fork.max_refund_quotient(), + refund_counter, + ) + receipt_cumulative_gas_used = gas_used_before_refund - regular_refund tx = Transaction( to=contract_new, @@ -1409,9 +1424,12 @@ def test_auth_sender_billing_after_failure( on top-level failure. For existing accounts, set_delegation refunds new-account state - gas to the reservoir. On REVERT, the restored reservoir reduces - the sender's bill via the billing formula. The sender pays less - than in the new-account case by exactly the refund amount. + gas to the reservoir and the worst-case `ACCOUNT_WRITE` to the + regular refund counter; both survive the top-level REVERT since + delegations are applied before execution. On REVERT, the restored + reservoir and the capped regular refund reduce the sender's bill + via the billing formula. The sender pays less than in the + new-account case. """ auth_intrinsic_state = fork.transaction_intrinsic_state_gas( authorization_count=1, @@ -1431,7 +1449,13 @@ def test_auth_sender_billing_after_failure( revert_gas = (Op.REVERT(0, 0)).gas_cost(fork) auth_refund = new_account_refund if authority_exists else 0 - expected_cumulative = intrinsic_total + revert_gas - auth_refund + refund_counter = fork.gas_costs().ACCOUNT_WRITE if authority_exists else 0 + gas_used_before_refund = intrinsic_total + revert_gas - auth_refund + regular_refund = min( + gas_used_before_refund // fork.max_refund_quotient(), + refund_counter, + ) + expected_cumulative = gas_used_before_refund - regular_refund expected_gas_used = max( intrinsic_regular + revert_gas, auth_intrinsic_state - auth_refund, @@ -1532,3 +1556,318 @@ def test_auth_refund_reservoir_cannot_fund_regular_gas( gas_used=max(gas_limit - intrinsic_state, state_used), ), ) + + +@pytest.mark.parametrize( + "invalidity", + [ + pytest.param("nonce_mismatch", id="nonce_mismatch"), + pytest.param("nonce_at_u64_max", id="nonce_at_u64_max"), + pytest.param("chain_id_mismatch", id="chain_id_mismatch"), + ], +) +@pytest.mark.valid_from("EIP8037") +def test_invalid_auth_rule1_refill_by_reason( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + invalidity: str, +) -> None: + """ + Verify an invalid authorization refills its full intrinsic state gas. + + A rejected authorization is skipped during processing. Its whole + state portion of NEW_ACCOUNT plus AUTH_BASE refills the reservoir + and one ACCOUNT_WRITE refunds to the refund counter. The regular + per authorization base cost stays charged and the authority is + never created. Swept over the reasons an authorization is rejected. + """ + per_auth_state = fork.transaction_intrinsic_state_gas( + authorization_count=1, + ) + total_intrinsic = fork.transaction_intrinsic_cost_calculator()( + authorization_list_or_count=1, + ) + intrinsic_regular = total_intrinsic - per_auth_state + account_write = fork.gas_costs().ACCOUNT_WRITE + + target = pre.deploy_contract(code=Op.STOP) + signer = pre.fund_eoa(amount=0) + + if invalidity == "nonce_mismatch": + auth = AuthorizationTuple(address=target, nonce=99, signer=signer) + elif invalidity == "nonce_at_u64_max": + auth = AuthorizationTuple( + address=target, + nonce=2**64 - 1, + signer=signer, + ) + elif invalidity == "chain_id_mismatch": + auth = AuthorizationTuple( + address=target, + nonce=0, + chain_id=9999, + signer=signer, + ) + else: + raise ValueError(f"unknown invalidity: {invalidity!r}") + + # The skipped auth refills its whole state portion to the reservoir + # so the net state charge is zero, and one ACCOUNT_WRITE returns to + # the capped refund counter. + auth_refund = per_auth_state + refund_counter = account_write + + header_gas_used = max(intrinsic_regular, per_auth_state - auth_refund) + gas_used_before_refund = total_intrinsic - auth_refund + regular_refund = min( + gas_used_before_refund // fork.max_refund_quotient(), + refund_counter, + ) + receipt_cumulative_gas_used = gas_used_before_refund - regular_refund + + tx = Transaction( + to=target, + state_gas_reservoir=per_auth_state, + authorization_list=[auth], + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=receipt_cumulative_gas_used, + ), + ) + + state_test( + pre=pre, + post={signer: Account.NONEXISTENT}, + tx=tx, + blockchain_test_header_verify=Header(gas_used=header_gas_used), + ) + + +@pytest.mark.valid_from("EIP8037") +def test_same_tx_create_then_clear_double_auth_base_refill( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Verify the create then clear double AUTH_BASE refill in one tx. + + A fresh authority is delegated by the first authorization then + cleared by the second within one transaction. The clear refills + AUTH_BASE twice. Once because the clear writes no indicator bytes. + Once because the delegation it removes was created earlier in this + same transaction. Net AUTH_BASE charged is zero and only the + NEW_ACCOUNT leaf cost remains. + """ + per_auth_state = fork.transaction_intrinsic_state_gas( + authorization_count=1, + ) + intrinsic_state = fork.transaction_intrinsic_state_gas( + authorization_count=2, + ) + total_intrinsic = fork.transaction_intrinsic_cost_calculator()( + authorization_list_or_count=2, + ) + intrinsic_regular = total_intrinsic - intrinsic_state + new_account_refund = fork.gas_costs().REFUND_AUTH_PER_EXISTING_ACCOUNT + account_write = fork.gas_costs().ACCOUNT_WRITE + auth_base_refund = per_auth_state - new_account_refund + + contract_a = pre.deploy_contract(code=Op.STOP) + target = pre.deploy_contract(code=Op.STOP) + + signer = pre.fund_eoa(amount=0) + authorization_list = [ + AuthorizationTuple(address=contract_a, nonce=0, signer=signer), + AuthorizationTuple( + address=Spec7702.RESET_DELEGATION_ADDRESS, + nonce=1, + signer=signer, + ), + ] + + # The first auth creates the leaf and writes the indicator with no + # refill. The second auth refills NEW_ACCOUNT, AUTH_BASE twice, and + # one ACCOUNT_WRITE. + auth_refund = new_account_refund + 2 * auth_base_refund + refund_counter = account_write + + header_gas_used = max(intrinsic_regular, intrinsic_state - auth_refund) + gas_used_before_refund = total_intrinsic - auth_refund + regular_refund = min( + gas_used_before_refund // fork.max_refund_quotient(), + refund_counter, + ) + receipt_cumulative_gas_used = gas_used_before_refund - regular_refund + + tx = Transaction( + to=target, + state_gas_reservoir=intrinsic_state, + authorization_list=authorization_list, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=receipt_cumulative_gas_used, + ), + ) + + state_test( + pre=pre, + post={signer: Account(nonce=2, code=b"")}, + tx=tx, + blockchain_test_header_verify=Header(gas_used=header_gas_used), + ) + + +@pytest.mark.valid_from("EIP8037") +def test_same_tx_clear_then_reset_pre_delegated( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Verify clear then reset of a pre delegated authority in one tx. + + An authority delegated before the transaction is cleared by the + first authorization then set to a new target by the second. The + reset refills AUTH_BASE through the pre delegated term even though + the current code was empty at that point. Net AUTH_BASE charged is + zero because the authority started and ended delegated. + """ + per_auth_state = fork.transaction_intrinsic_state_gas( + authorization_count=1, + ) + intrinsic_state = fork.transaction_intrinsic_state_gas( + authorization_count=2, + ) + total_intrinsic = fork.transaction_intrinsic_cost_calculator()( + authorization_list_or_count=2, + ) + intrinsic_regular = total_intrinsic - intrinsic_state + new_account_refund = fork.gas_costs().REFUND_AUTH_PER_EXISTING_ACCOUNT + account_write = fork.gas_costs().ACCOUNT_WRITE + auth_base_refund = per_auth_state - new_account_refund + + contract_a = pre.deploy_contract(code=Op.STOP) + contract_b = pre.deploy_contract(code=Op.STOP) + target = pre.deploy_contract(code=Op.STOP) + + signer = pre.fund_eoa(delegation=contract_a) + authorization_list = [ + AuthorizationTuple( + address=Spec7702.RESET_DELEGATION_ADDRESS, + nonce=1, + signer=signer, + ), + AuthorizationTuple(address=contract_b, nonce=2, signer=signer), + ] + + # Both auths refill NEW_ACCOUNT and one AUTH_BASE each. The leaf + # already exists so each also refunds one ACCOUNT_WRITE. + auth_refund = 2 * (new_account_refund + auth_base_refund) + refund_counter = 2 * account_write + + header_gas_used = max(intrinsic_regular, intrinsic_state - auth_refund) + gas_used_before_refund = total_intrinsic - auth_refund + regular_refund = min( + gas_used_before_refund // fork.max_refund_quotient(), + refund_counter, + ) + receipt_cumulative_gas_used = gas_used_before_refund - regular_refund + + tx = Transaction( + to=target, + state_gas_reservoir=intrinsic_state, + authorization_list=authorization_list, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=receipt_cumulative_gas_used, + ), + ) + + state_test( + pre=pre, + post={ + signer: Account( + nonce=3, + code=Spec7702.delegation_designation(contract_b), + ), + }, + tx=tx, + blockchain_test_header_verify=Header(gas_used=header_gas_used), + ) + + +@pytest.mark.valid_from("EIP8037") +def test_same_authority_increasing_nonce_net_once( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Verify the per authority once invariant across valid auths. + + The same fresh authority is delegated by three authorizations with + increasing nonces in one transaction. The account leaf and its + delegation indicator are written once. NEW_ACCOUNT and AUTH_BASE are + each charged once across the batch while ACCOUNT_WRITE is refunded + for every auth after the leaf is created. + """ + num_auths = 3 + per_auth_state = fork.transaction_intrinsic_state_gas( + authorization_count=1, + ) + intrinsic_state = fork.transaction_intrinsic_state_gas( + authorization_count=num_auths, + ) + total_intrinsic = fork.transaction_intrinsic_cost_calculator()( + authorization_list_or_count=num_auths, + ) + intrinsic_regular = total_intrinsic - intrinsic_state + new_account_refund = fork.gas_costs().REFUND_AUTH_PER_EXISTING_ACCOUNT + account_write = fork.gas_costs().ACCOUNT_WRITE + auth_base_refund = per_auth_state - new_account_refund + + targets = [pre.deploy_contract(code=Op.STOP) for _ in range(num_auths)] + call_target = pre.deploy_contract(code=Op.STOP) + + signer = pre.fund_eoa(amount=0) + authorization_list = [ + AuthorizationTuple(address=targets[i], nonce=i, signer=signer) + for i in range(num_auths) + ] + + # The first auth creates the leaf with no refill. Each later auth + # refills NEW_ACCOUNT, one AUTH_BASE, and one ACCOUNT_WRITE. + auth_refund = (num_auths - 1) * (new_account_refund + auth_base_refund) + refund_counter = (num_auths - 1) * account_write + + header_gas_used = max(intrinsic_regular, intrinsic_state - auth_refund) + gas_used_before_refund = total_intrinsic - auth_refund + regular_refund = min( + gas_used_before_refund // fork.max_refund_quotient(), + refund_counter, + ) + receipt_cumulative_gas_used = gas_used_before_refund - regular_refund + + tx = Transaction( + to=call_target, + state_gas_reservoir=intrinsic_state, + authorization_list=authorization_list, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=receipt_cumulative_gas_used, + ), + ) + + state_test( + pre=pre, + post={ + signer: Account( + nonce=num_auths, + code=Spec7702.delegation_designation(targets[-1]), + ), + }, + tx=tx, + blockchain_test_header_verify=Header(gas_used=header_gas_used), + ) 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 50e404d149d..11ff400a16a 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 @@ -968,29 +968,36 @@ def test_sstore_restoration_ancestor_revert( probe = pre.deploy_contract(code=probe_code) caller_storage = Storage() - caller_code = Op.POP(call_opcode(gas=Op.GAS, address=middle)) + Op.SSTORE( + # The probe OOGs and returns 0, so the caller's outer SSTORE is a + # cold no-op (0 to 0) on a fresh slot, charging only + # COLD_STORAGE_ACCESS rather than the cold set `regular_cost` + # assumes by default. + caller_code = Op.POP( + call_opcode(gas=Op.GAS, address=middle) + ) + Op.SSTORE.with_metadata( + key_warm=False, + original_value=0, + current_value=0, + new_value=0, + )( 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 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 = ( + # No SSTORE-set persists (inner's set+clear cancel, middle reverts, + # the probe OOGs and reverts, and the caller's outer SSTORE is a + # no-op), so block state gas is zero and header gas_used (the max of + # regular and state) is just the regular total. The probe burns its + # full forwarded budget on the OOG; its CALL's cold-access surcharge + # is already counted in the caller's regular cost. + expected_gas_used = ( intrinsic_cost + caller_code.regular_cost(fork) + middle_code.regular_cost(fork) + inner_code.regular_cost(fork) - + probe_burned + + probe_gas ) - 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. tx = Transaction( diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/__init__.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/__init__.py new file mode 100644 index 00000000000..a84179b40a4 --- /dev/null +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/__init__.py @@ -0,0 +1,3 @@ +""" +Tests for [EIP-8038: State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038). +""" diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/spec.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/spec.py new file mode 100644 index 00000000000..66a7606fd8a --- /dev/null +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/spec.py @@ -0,0 +1,16 @@ +"""Defines the EIP-8038 reference specification.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ReferenceSpec: + """Defines the reference spec version and git path.""" + + git_path: str + version: str + + +ref_spec_8038 = ReferenceSpec( + "EIPS/eip-8038.md", "a8862ae6653a12a2989b64a50eca5334cfe8b3cb" +) diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_access_list_gas.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_access_list_gas.py new file mode 100644 index 00000000000..1337c77b230 --- /dev/null +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_access_list_gas.py @@ -0,0 +1,329 @@ +""" +Tests for [EIP-8038: State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038). + +Covers the EIP-8038 access-list repricing: + +* The intrinsic surcharge per access-list entry is + ``TX_ACCESS_LIST_ADDRESS`` (3000) per address and + ``TX_ACCESS_LIST_STORAGE_KEY`` (3000) per storage key, isolated from + the EIP-7981 calldata-floor tokens that the Amsterdam intrinsic + calculator also charges on access-list bytes. +* A storage slot named in the access list is *warm* on its first runtime + access (``SLOAD``/``SSTORE`` pays ``WARM_SLOAD`` rather than the cold + cost). +* Warmth is scoped to ``(address, slot)``: listing slot ``s`` of account + ``A`` does not warm slot ``s`` of account ``B``. +""" + +from typing import List + +import pytest +from execution_testing import ( + AccessList, + Account, + Address, + Alloc, + Bytecode, + CodeGasMeasure, + Environment, + Fork, + Op, + StateTestFiller, + Transaction, +) +from execution_testing.checklists import EIPChecklist + +from .spec import ref_spec_8038 + +REFERENCE_SPEC_GIT_PATH = ref_spec_8038.git_path +REFERENCE_SPEC_VERSION = ref_spec_8038.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + + +def _access_list_floor_token_gas( + access_list: List[AccessList], fork: Fork +) -> int: + """ + Return the EIP-7981 calldata-floor-token gas the Amsterdam intrinsic + calculator charges for an access list. + + Every byte of each address (20) and storage key (32) is four floor + tokens, each priced at ``TX_DATA_TOKEN_FLOOR``. Subtracting this from + the measured intrinsic delta isolates the pure EIP-8038 per-entry + surcharge. + """ + total_bytes = 0 + for access in access_list: + total_bytes += len(access.address) + total_bytes += 32 * len(access.storage_keys) + return total_bytes * 4 * fork.gas_costs().TX_DATA_TOKEN_FLOOR + + +def _make_access_list( + n_addr: int, n_keys_each: int, *, duplicate: bool = False +) -> List[AccessList]: + """Build an access list of ``n_addr`` entries, each with keys.""" + entries: List[AccessList] = [] + for i in range(n_addr): + address = Address(0x1000) if duplicate else Address(0x1000 + i) + keys = [bytes([j]) * 32 for j in range(n_keys_each)] + entries.append(AccessList(address=address, storage_keys=keys)) + return entries + + +# (n_addr, n_keys_each, duplicate, id) +ACCESS_LIST_SHAPES = [ + pytest.param(0, 0, False, id="empty"), + pytest.param(1, 0, False, id="single_addr"), + pytest.param(1, 3, False, id="one_addr_three_keys"), + pytest.param(2, 0, False, id="two_addr"), + pytest.param(2, 1, True, id="duplicate_addr"), +] + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize("n_addr,n_keys_each,duplicate", ACCESS_LIST_SHAPES) +def test_access_list_intrinsic_surcharge( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + n_addr: int, + n_keys_each: int, + duplicate: bool, +) -> None: + """ + Assert the per-entry intrinsic access-list surcharge. + + The intrinsic-cost delta from adding the access list, minus the + EIP-7981 floor-token contribution, must equal + ``n_addr * TX_ACCESS_LIST_ADDRESS + n_keys * TX_ACCESS_LIST_STORAGE_KEY``. + A simple value-less transaction then exercises the access list end to + end. + """ + gas_costs = fork.gas_costs() + intrinsic = fork.transaction_intrinsic_cost_calculator() + + access_list = _make_access_list(n_addr, n_keys_each, duplicate=duplicate) + n_keys = n_addr * n_keys_each + + base = intrinsic(return_cost_deducted_prior_execution=True) + with_al = intrinsic( + access_list=access_list, + return_cost_deducted_prior_execution=True, + ) + surcharge = ( + with_al - base - _access_list_floor_token_gas(access_list, fork) + ) + expected = ( + n_addr * gas_costs.TX_ACCESS_LIST_ADDRESS + + n_keys * gas_costs.TX_ACCESS_LIST_STORAGE_KEY + ) + assert surcharge == expected + + contract = pre.deploy_contract(code=Op.STOP) + tx = Transaction( + to=contract, + sender=pre.fund_eoa(), + access_list=access_list if access_list else None, + ) + + state_test(pre=pre, post={}, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +def test_access_list_duplicate_address_key_intrinsic_and_warmth( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, +) -> None: + """ + A duplicated ``(address, storage_key)`` access-list entry is billed + twice intrinsically but warms the slot only once. + + The same ``(contract, slot)`` pair is listed twice. The intrinsic + surcharge (floor tokens isolated as in + ``test_access_list_intrinsic_surcharge``) bills both listings: + ``2 * TX_ACCESS_LIST_ADDRESS + 2 * TX_ACCESS_LIST_STORAGE_KEY``. At + runtime the slot is nonetheless warm on its first ``SLOAD`` + (``WARM_SLOAD``), since warmth is set-membership, not a counter. + """ + gas_costs = fork.gas_costs() + intrinsic = fork.transaction_intrinsic_cost_calculator() + slot = 0x42 + + # First runtime SLOAD of the listed slot stores the warm access cost. + measured_read = Op.SLOAD(slot) + overhead = measured_read.gas_cost(fork) - Op.SLOAD( + key_warm=False + ).gas_cost(fork) + contract = pre.deploy_contract( + code=CodeGasMeasure( + code=measured_read, + overhead_cost=overhead, + extra_stack_items=1, + sstore_key=1, + ), + storage={slot: 1}, + ) + + # Build the access list after deploying so the address is real, then + # list the identical (contract, slot) pair twice. + access_list = [ + AccessList(address=contract, storage_keys=[slot]), + AccessList(address=contract, storage_keys=[slot]), + ] + + base = intrinsic(return_cost_deducted_prior_execution=True) + with_al = intrinsic( + access_list=access_list, + return_cost_deducted_prior_execution=True, + ) + surcharge = ( + with_al - base - _access_list_floor_token_gas(access_list, fork) + ) + expected_surcharge = ( + 2 * gas_costs.TX_ACCESS_LIST_ADDRESS + + 2 * gas_costs.TX_ACCESS_LIST_STORAGE_KEY + ) + assert surcharge == expected_surcharge + + expected_gas = Op.SLOAD(key_warm=True).gas_cost(fork) + tx = Transaction( + to=contract, + sender=pre.fund_eoa(), + access_list=access_list, + ) + + # Slot 1 holds the measured warm cost; the read slot keeps its value. + post = {contract: Account(storage={1: expected_gas, slot: 1})} + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize("op", ["SLOAD", "SSTORE"], ids=["sload", "sstore"]) +def test_access_list_warms_storage_slot( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + op: str, +) -> None: + """ + A storage slot named in the access list is warm on first access. + + The first runtime ``SLOAD``/``SSTORE`` of an access-list slot pays + the warm cost: ``WARM_SLOAD`` for ``SLOAD``; for ``SSTORE`` an + overwrite of a non-zero original to a new non-zero value pays + ``WARM_SLOAD + STORAGE_WRITE``. + """ + gas_costs = fork.gas_costs() + very_low = gas_costs.VERY_LOW + slot = 0x42 + + if op == "SLOAD": + measured_code: Bytecode = Op.SLOAD(slot) + # Overhead is just the single PUSH (key); the stored value is the + # bare warm SLOAD access cost. + overhead_cost = 1 * very_low + extra_stack_items = 1 + expected_gas = Op.SLOAD(key_warm=True).gas_cost(fork) + else: + measured_code = Op.SSTORE(slot, 2) + # Overhead is the two PUSHes (key, value); the stored value is + # the bare warm SSTORE regular cost (overwrite of a non-zero + # original, no state gas). + overhead_cost = 2 * very_low + extra_stack_items = 0 + expected_gas = ( + Op.SSTORE.with_metadata( + key_warm=True, + original_value=1, + current_value=1, + new_value=2, + )(slot, 2).regular_cost(fork) + - 2 * very_low + ) + + code = CodeGasMeasure( + code=measured_code, + overhead_cost=overhead_cost, + extra_stack_items=extra_stack_items, + sstore_key=1, + ) + contract = pre.deploy_contract(code=code, storage={slot: 1}) + + tx = Transaction( + to=contract, + sender=pre.fund_eoa(), + access_list=[AccessList(address=contract, storage_keys=[slot])], + ) + + # Slot 1 holds the measured warm cost. The data slot ends at its + # original (SLOAD) or the written value (SSTORE). + final_slot_value = 1 if op == "SLOAD" else 2 + post = { + contract: Account(storage={1: expected_gas, slot: final_slot_value}) + } + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +def test_access_list_slot_warmth_is_address_scoped( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, +) -> None: + """ + Access-list slot warmth is scoped to ``(address, slot)``. + + Slot ``s`` of account ``A`` is listed in the access list. Reading + slot ``s`` of ``A`` is warm (``WARM_SLOAD``); reading the same slot + number of a different account ``B`` is cold (``COLD_STORAGE_ACCESS``). + """ + slot = 0x42 + warm_gas = Op.SLOAD(key_warm=True).gas_cost(fork) + cold_gas = Op.SLOAD(key_warm=False).gas_cost(fork) + + # Both accounts read their own slot ``s`` with the same wrapper, so the + # overhead that strips the operand PUSH is identical for each. + measured_read = Op.SLOAD(slot) + overhead = measured_read.gas_cost(fork) - cold_gas + + # B reads its own slot ``s`` (cold), storing the result in B's slot 1. + account_b = pre.deploy_contract( + code=CodeGasMeasure( + code=measured_read, + overhead_cost=overhead, + extra_stack_items=1, + sstore_key=1, + ), + storage={slot: 1}, + ) + + # A reads its own slot ``s`` (warm via the access list), then calls B. + account_a = pre.deploy_contract( + code=CodeGasMeasure( + code=measured_read, + overhead_cost=overhead, + extra_stack_items=1, + sstore_key=1, + ) + + Op.POP(Op.CALL(gas=200_000, address=account_b)), + storage={slot: 1}, + ) + + tx = Transaction( + to=account_a, + sender=pre.fund_eoa(), + # Only A's slot is listed; B's identical slot stays cold. + access_list=[AccessList(address=account_a, storage_keys=[slot])], + ) + + post = { + account_a: Account(storage={1: warm_gas, slot: 1}), + account_b: Account(storage={1: cold_gas, slot: 1}), + } + state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_call_gas.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_call_gas.py new file mode 100644 index 00000000000..37e543f4376 --- /dev/null +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_call_gas.py @@ -0,0 +1,768 @@ +""" +Tests for the EIP-8038 [State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038) +``CALL``-family regular-gas dimension. + +Under EIP-8038 the call opcodes are repriced in their *regular* gas +dimension: + +- account access costs ``COLD_ACCOUNT_ACCESS`` (3,000) cold or + ``WARM_ACCESS`` (100) warm; +- a positive value transfer adds ``CALL_VALUE`` (``ACCOUNT_WRITE`` + + ``CALL_STIPEND`` = 10,300), charged only by ``CALL``/``CALLCODE``; +- a value transfer to a *new* account additionally creates the account, + whose ``GAS_NEW_ACCOUNT`` charge is the EIP-8037 *state* dimension and + is asserted via the block header ``max(regular, state)`` accounting, + never as regular gas; +- an EIP-7702 delegated target is double-accessed (target leaf plus + delegation leaf), each access cold or warm independently. + +These tests assert the EIP-8038 *regular* dimension; the EIP-8037 +*state* dimension for value-to-new-account is covered in +``eip8037_state_creation_gas_cost_increase/test_state_gas_call.py`` and +is only re-derived here at the seam to feed header gas accounting. +""" + +import pytest +from execution_testing import ( + AccessList, + Account, + Address, + Alloc, + Bytecode, + CodeGasMeasure, + Environment, + Fork, + Header, + Op, + StateTestFiller, + Transaction, + TransactionReceipt, +) +from execution_testing.checklists import EIPChecklist + +from .spec import ref_spec_8038 + +REFERENCE_SPEC_GIT_PATH = ref_spec_8038.git_path +REFERENCE_SPEC_VERSION = ref_spec_8038.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + + +def _measure_call( + pre: Alloc, + fork: Fork, + measured_code: Bytecode, + own_cold_cost: Bytecode, + balance: int = 0, +) -> Address: + """ + Deploy a ``CodeGasMeasure`` contract around ``measured_code``. + + The overhead subtracts the call opcode's OWN cold cost (computed from + ``own_cold_cost``) so only the wrapping ``PUSH`` arguments remain in + the overhead; the measured value isolates the opcode's gas. The call + leaves exactly one stack item (its success flag). + """ + overhead_cost = measured_code.gas_cost(fork) - own_cold_cost.gas_cost(fork) + code_gas_measure = CodeGasMeasure( + code=measured_code, + overhead_cost=overhead_cost, + extra_stack_items=1, + ) + return pre.deploy_contract(code=code_gas_measure, balance=balance) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.with_all_call_opcodes() +@pytest.mark.parametrize("warm", [False, True], ids=["cold", "warm"]) +def test_call_access_gas( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + call_opcode: Op, + warm: bool, +) -> None: + """ + Measure the access cost of every call opcode with no value transfer. + + EIP-8038 charges ``COLD_ACCOUNT_ACCESS`` (3,000) cold and + ``WARM_ACCESS`` (100) warm for all four call opcodes. + """ + gas_costs = fork.gas_costs() + + target = pre.deploy_contract(Op.STOP) + + measured_code = call_opcode(gas=0, address=target) + cost_metadata = call_opcode(address_warm=warm) + measure_address = _measure_call( + pre, fork, measured_code, call_opcode(address_warm=False) + ) + + expected_gas = ( + gas_costs.WARM_ACCESS if warm else gas_costs.COLD_ACCOUNT_ACCESS + ) + # Cross-check the framework opcode model agrees with the formula. + assert expected_gas == cost_metadata.gas_cost(fork) + + access_list = ( + [AccessList(address=target, storage_keys=[])] if warm else None + ) + tx = Transaction( + to=measure_address, + sender=pre.fund_eoa(), + access_list=access_list, + ) + + post = {measure_address: Account(storage={0: expected_gas})} + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.with_all_call_opcodes() +@pytest.mark.parametrize("warm", [False, True], ids=["cold", "warm"]) +def test_call_value_alive_target_gas( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + call_opcode: Op, + warm: bool, +) -> None: + """ + Measure call cost with value transfer to an already-alive target. + + ``CALL``/``CALLCODE`` add ``CALL_VALUE`` (10,300) on top of the + access cost, where ``CALL_VALUE = ACCOUNT_WRITE + CALL_STIPEND``. + ``DELEGATECALL``/``STATICCALL`` never transfer value, so they pay + only the access cost regardless of any value argument. No new + account is created (the target is alive), so no state gas is charged. + + The ``CALL_STIPEND`` (2,300) is forwarded to the callee; with a + ``STOP`` callee it is unused and returned, so the gas *consumed* by + the caller is ``access + ACCOUNT_WRITE`` while the *charged* schedule + is ``access + CALL_VALUE``. Both are asserted. + """ + gas_costs = fork.gas_costs() + transfers_value = call_opcode in (Op.CALL, Op.CALLCODE) + # Verify the EIP-8038 decomposition of the value-transfer charge. + assert gas_costs.CALL_VALUE == gas_costs.ACCOUNT_WRITE + ( + gas_costs.CALL_STIPEND + ) + + # The measured-vs-charged duality below hinges on the callee being a + # pure `STOP`: it executes no opcodes, so the forwarded `CALL_STIPEND` + # is wholly unused and returned. Pin that the callee is exactly the + # single zero byte with no gas cost, and that the returned stipend is + # precisely `CALL_VALUE - ACCOUNT_WRITE`. + callee = Op.STOP + assert bytes(callee) == b"\x00" + assert callee.gas_cost(fork) == 0 + assert gas_costs.CALL_VALUE - gas_costs.ACCOUNT_WRITE == ( + gas_costs.CALL_STIPEND + ) + + # Alive target with balance so no account creation occurs. + target = pre.deploy_contract(callee, balance=1) + + # Build the runnable call carrying the runtime metadata so that + # `measured_code.gas_cost(fork)` accounts for the value transfer. + if transfers_value: + measured_code = call_opcode.with_metadata( + address_warm=False, value_transfer=True + )( + gas=0, + address=target, + value=1, + args_offset=0, + args_size=0, + ret_offset=0, + ret_size=0, + ) + cost_metadata = call_opcode(address_warm=warm, value_transfer=True) + own_cold = call_opcode(address_warm=False, value_transfer=True) + else: + measured_code = call_opcode(gas=0, address=target) + cost_metadata = call_opcode(address_warm=warm) + own_cold = call_opcode(address_warm=False) + + # The measure contract needs balance to actually send the value. + measure_address = _measure_call( + pre, fork, measured_code, own_cold, balance=1 + ) + + access_cost = ( + gas_costs.WARM_ACCESS if warm else gas_costs.COLD_ACCOUNT_ACCESS + ) + # Charged schedule: access + CALL_VALUE (verified via the opcode + # model). CALL gas is wholly regular under EIP-8038 (no state map). + charged_gas = access_cost + ( + gas_costs.CALL_VALUE if transfers_value else 0 + ) + assert charged_gas == cost_metadata.gas_cost(fork) + assert cost_metadata.state_cost(fork) == 0 + + # Consumed gas: the STOP callee returns the forwarded CALL_STIPEND, + # so the caller's measured consumption is access + ACCOUNT_WRITE for + # value transfers, and just access otherwise. + measured_gas = access_cost + ( + gas_costs.ACCOUNT_WRITE if transfers_value else 0 + ) + + access_list = ( + [AccessList(address=target, storage_keys=[])] if warm else None + ) + tx = Transaction( + to=measure_address, + sender=pre.fund_eoa(), + access_list=access_list, + ) + + post = {measure_address: Account(storage={0: measured_gas})} + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +def test_callcode_value_to_nonexistent_no_new_account( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Verify CALLCODE value to a non-existent target charges CALL_VALUE + but not GAS_NEW_ACCOUNT. + + ``CALLCODE`` runs the callee's code in the caller's own context, so + the value never leaves the caller and no beneficiary account is + created. The block ``gas_used`` therefore equals the regular tx + cost with ``CALL_VALUE`` but with no 183,600 state-gas component. + """ + gas_costs = fork.gas_costs() + intrinsic = fork.transaction_intrinsic_cost_calculator()() + + target = 0xDEAD # non-existent + + # CALLCODE with value to a cold, non-existent target. The metadata + # carries `value_transfer` so `caller_code.gas_cost(fork)` reflects + # the CALL_VALUE charge; it must NOT carry `account_new` since the + # value stays with the caller and no beneficiary leaf is created. + callcode = Op.CALLCODE.with_metadata( + address_warm=False, value_transfer=True + )( + gas=0, + address=target, + value=1, + args_offset=0, + args_size=0, + ret_offset=0, + ret_size=0, + ) + caller_code = Op.POP(callcode) + Op.STOP + caller = pre.deploy_contract(code=caller_code, balance=1) + + # CALLCODE-to-nonexistent regular charge: access + CALL_VALUE, no + # NEW_ACCOUNT (asserted via the metadata-only opcode model). + callcode_meta = Op.CALLCODE(address_warm=False, value_transfer=True) + assert callcode_meta.gas_cost(fork) == gas_costs.COLD_ACCOUNT_ACCESS + ( + gas_costs.CALL_VALUE + ) + # CALLCODE carries no state-gas (NEW_ACCOUNT) component. + assert callcode_meta.state_cost(fork) == 0 + + # Whole tx is regular gas; no NEW_ACCOUNT state component appears. + # The CALLCODE forwards CALL_STIPEND to the callee, which (running in + # the caller's own context with empty code) leaves it unused and + # returns it, so consumed gas is the charge minus the stipend. + expected_gas_used = ( + intrinsic + caller_code.gas_cost(fork) - gas_costs.CALL_STIPEND + ) + # Guard the no-state assertion: NEW_ACCOUNT would dominate if charged. + assert expected_gas_used < gas_costs.NEW_ACCOUNT + + tx = Transaction( + to=caller, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_gas_used + ), + ) + + state_test(pre=pre, post={caller: Account(balance=1)}, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +def test_call_value_to_new_account_seam( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Verify the CALL value-to-new-account regular/state seam. + + The EIP-8038 *regular* dimension is ``COLD_ACCOUNT_ACCESS`` + + ``CALL_VALUE`` = 13,300; the account creation charge + ``GAS_NEW_ACCOUNT`` (183,600) lands in the EIP-8037 *state* + dimension. The block header reflects ``max(regular, state)``, which + is dominated by the state charge. + """ + gas_costs = fork.gas_costs() + new_account_state_gas = gas_costs.NEW_ACCOUNT + intrinsic = fork.transaction_intrinsic_cost_calculator()() + + # Fresh, value-receiving target (state-empty, will be created). + target = pre.fund_eoa(amount=0) + + # Metadata-bearing CALL so `caller_code.gas_cost(fork)` folds the + # value transfer and account-creation charges; we then split off the + # NEW_ACCOUNT state component for the 2D header accounting. + call = Op.CALL.with_metadata( + address_warm=False, value_transfer=True, account_new=True + )( + gas=0, + address=target, + value=1, + args_offset=0, + args_size=0, + ret_offset=0, + ret_size=0, + ) + caller_code = Op.POP(call) + Op.STOP + caller = pre.deploy_contract(code=caller_code, balance=1) + + # Regular dimension: access + value (NOT new account, which is the + # state dimension). Asserted via the metadata-only opcode model. + call_meta = Op.CALL( + address_warm=False, value_transfer=True, account_new=True + ) + call_regular = call_meta.gas_cost(fork) - new_account_state_gas + assert call_regular == gas_costs.COLD_ACCOUNT_ACCESS + gas_costs.CALL_VALUE + assert call_regular == 13_300 + + # block_gas_used = max(block_regular, block_state). The CALL opcode + # has no state-gas map, so its NEW_ACCOUNT charge spills as regular + # gas in the bytecode total; strip it back out to isolate the + # regular axis and re-add NEW_ACCOUNT explicitly on the state axis. + tx_regular = intrinsic + caller_code.gas_cost(fork) - new_account_state_gas + tx_state = new_account_state_gas + expected_gas_used = max(tx_regular, tx_state) + # State must dominate here, proving the 183,600 hit the state axis. + assert expected_gas_used == new_account_state_gas + + tx = Transaction( + to=caller, + sender=pre.fund_eoa(), + state_gas_reservoir=new_account_state_gas, + ) + + state_test( + pre=pre, + post={target: Account(balance=1)}, + tx=tx, + blockchain_test_header_verify=Header(gas_used=expected_gas_used), + ) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.with_all_call_opcodes() +@pytest.mark.parametrize( + "target_warm", [False, True], ids=["target_cold", "target_warm"] +) +@pytest.mark.parametrize( + "delegate_warm", [False, True], ids=["delegate_cold", "delegate_warm"] +) +def test_call_to_delegated_target_double_access( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + call_opcode: Op, + target_warm: bool, + delegate_warm: bool, +) -> None: + """ + Measure a call to a 7702-delegated target: 2x2 double access. + + The spec applies the delegation surcharge to every call opcode + (``CALL``/``CALLCODE``/``DELEGATECALL``/``STATICCALL``), so each + reads two account leaves: the target's leaf and the delegation's + leaf. Each is charged independently as ``WARM_ACCESS`` (100) or + ``COLD_ACCOUNT_ACCESS`` (3,000) by warmth. ``DELEGATECALL`` and + ``STATICCALL`` carry no value but still pay the delegation + surcharge. + """ + gas_costs = fork.gas_costs() + + # Final code-bearing account that the delegation points at. + delegate = pre.deploy_contract(Op.STOP) + # EOA delegated (EIP-7702) to `delegate`. + target = pre.fund_eoa(amount=0, delegation=delegate) + + measured_code = call_opcode(gas=0, address=target) + cost_metadata = call_opcode( + address_warm=target_warm, + delegated_address=True, + delegated_address_warm=delegate_warm, + ) + measure_address = _measure_call( + pre, fork, measured_code, call_opcode(address_warm=False) + ) + + target_cost = ( + gas_costs.WARM_ACCESS if target_warm else gas_costs.COLD_ACCOUNT_ACCESS + ) + delegate_cost = ( + gas_costs.WARM_ACCESS + if delegate_warm + else gas_costs.COLD_ACCOUNT_ACCESS + ) + expected_gas = target_cost + delegate_cost + assert expected_gas == cost_metadata.gas_cost(fork) + + # Warm the target and/or the delegate leaf via the access list. + access_entries = [] + if target_warm: + access_entries.append(AccessList(address=target, storage_keys=[])) + if delegate_warm: + access_entries.append(AccessList(address=delegate, storage_keys=[])) + access_list = access_entries or None + + tx = Transaction( + to=measure_address, + sender=pre.fund_eoa(), + access_list=access_list, + ) + + post = {measure_address: Account(storage={0: expected_gas})} + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.OutOfGas() +@pytest.mark.with_all_call_opcodes() +@pytest.mark.parametrize( + "sufficient_gas", [True, False], ids=["sufficient", "insufficient"] +) +def test_call_exact_gas_oog( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + call_opcode: Op, + sufficient_gas: bool, +) -> None: + """ + Drive a cold call at exactly its gas (success) and one gas short (OOG). + + The caller forwards exactly enough gas for the inner call opcode (its + cold access cost plus the wrapping pushes). One gas short forces the + inner call to halt out-of-gas before executing, so the outer SSTORE + records 0; with the exact amount it records 1. + """ + target = pre.deploy_contract(Op.STOP) + + # Inner contract just performs the cold call to `target`. + inner_code = call_opcode(gas=0, address=target) + Op.STOP + inner = pre.deploy_contract(inner_code) + + # Exact regular gas for the inner frame: bytecode cost (which folds + # the cold call cost via the default metadata) under EIP-8038. + inner_gas_exact = inner_code.gas_cost(fork) + if not sufficient_gas: + inner_gas_exact -= 1 + + caller_code = Op.SSTORE(0, Op.CALL(gas=inner_gas_exact, address=inner)) + caller = pre.deploy_contract(caller_code) + + tx = Transaction(to=caller, sender=pre.fund_eoa()) + + post = {caller: Account(storage={0: 1 if sufficient_gas else 0})} + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.with_all_call_opcodes() +def test_call_self_is_warm( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + call_opcode: Op, +) -> None: + """ + Verify a self-call is warm: the executing account is pre-warmed. + + The current target is in the accessed-addresses set on message + entry, so a call to ``ADDRESS`` pays only ``WARM_ACCESS`` (100). + """ + gas_costs = fork.gas_costs() + + # `Op.ADDRESS` is the call's address argument, embedded inside the + # runnable call; the self address is in the accessed set on entry, so + # the call is warm. The overhead subtracts the call's own cold cost, + # leaving the ADDRESS push and the other arg pushes as overhead. + measured_code = call_opcode(gas=0, address=Op.ADDRESS) + measure_address = _measure_call( + pre, fork, measured_code, call_opcode(address_warm=False) + ) + + expected_gas = call_opcode(address_warm=True).gas_cost(fork) + assert expected_gas == gas_costs.WARM_ACCESS + + tx = Transaction(to=measure_address, sender=pre.fund_eoa()) + + post = {measure_address: Account(storage={0: expected_gas})} + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize( + "sufficient_gas", [True, False], ids=["sufficient", "insufficient"] +) +def test_call_forwarded_gas_63_64( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + sufficient_gas: bool, +) -> None: + """ + Verify the 63/64 forwarding budget is computed after the repriced + cold access charge. + + A wrapper performs a cold, zero-value ``CALL`` requesting maximum + gas. The spec charges the repriced ``COLD_ACCOUNT_ACCESS`` (3,000) + up front and only then forwards ``floor(63/64 * gas_left)`` to the + child. The wrapper is handed an exact budget so that, net of the + access charge, ``gas_left`` equals ``child_regular * 64 // 63``; + forwarding then yields exactly the child's regular need + (``child_regular``) and its cold ``SSTORE`` takes effect. With one + gas less the floor drops below ``child_regular`` and the child OOGs, + so the slot stays zero. This pins that the floor is taken over + ``gas_left`` already net of the post-8038 cold access cost (not + before it, and not double-charging it). + """ + gas_costs = fork.gas_costs() + sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) + + # Child: a single cold zero-to-nonzero SSTORE as proof of execution. + # Its regular need is the two operand pushes plus the cold storage + # write (the state portion is funded separately via the reservoir, + # which is passed to the child in full with no 63/64 rule). + child = pre.deploy_contract(Op.SSTORE(0, 1)) + child_regular = 2 * gas_costs.VERY_LOW + gas_costs.COLD_STORAGE_WRITE + + # Smallest budget whose 63/64 floor still reaches `child_regular`. + forward_budget = child_regular * 64 // 63 + if not sufficient_gas: + forward_budget -= 1 + + # Wrapper: cold zero-value CALL requesting max gas (so the forwarded + # amount is bound by `gas_left`, not by the request). ret_size=0 + # avoids any memory-expansion term. + wrapper = pre.deploy_contract( + Op.CALL( + gas=0xFFFFFFFF, + address=child, + value=0, + args_offset=0, + args_size=0, + ret_offset=0, + ret_size=0, + ) + ) + + # At the wrapper's CALL the access charge (`extra_gas`) is deducted + # first, leaving exactly `forward_budget` as `gas_left` for the 63/64 + # floor. The seven CALL operand pushes precede it. + wrapper_pushes = 7 * gas_costs.VERY_LOW + extra_gas = gas_costs.COLD_ACCOUNT_ACCESS # cold call, value 0 + wrapper_gas = wrapper_pushes + extra_gas + forward_budget + + # Outer caller hands the wrapper exactly `wrapper_gas`. + caller = pre.deploy_contract( + Op.POP(Op.CALL(gas=wrapper_gas, address=wrapper)) + ) + + tx = Transaction( + to=caller, + sender=pre.fund_eoa(), + state_gas_reservoir=sstore_state_gas, + ) + + # Child SSTORE lands only when the forwarded floor reaches its need. + post = {child: Account(storage={0: 1 if sufficient_gas else 0})} + state_test(pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +def test_account_warmth_reverts_on_subcall_revert( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, +) -> None: + """ + Account warmth acquired inside a reverted sub-call does not persist. + + An inner contract reads an address's ``BALANCE`` via + ``DELEGATECALL`` (so the warmed address belongs to the shared + accessed-addresses set) then ``REVERT``s. Back in the outer frame, + that same address's first ``BALANCE`` is cold again and is charged + ``COLD_ACCOUNT_ACCESS`` (3,000), proving the warm-address set is + rolled back on revert (mirrors the ``SLOAD`` warmth-revert case for + the account dimension). + """ + gas_costs = fork.gas_costs() + cold_gas = Op.BALANCE(address_warm=False).gas_cost(fork) + assert cold_gas == gas_costs.COLD_ACCOUNT_ACCESS + + # Address whose warmth we probe; left out of the access list so its + # first runtime touch is cold. + probed = pre.fund_eoa(amount=1) + + # Inner: warm `probed` by reading its balance, then revert. + inner = pre.deploy_contract( + code=Op.POP(Op.BALANCE(probed)) + Op.REVERT(0, 0), + ) + + # Outer: DELEGATECALL inner (which warms `probed` in the shared + # accessed set, then reverts, discarding that warmth), then measure + # its own first BALANCE of `probed`, which must be cold again. + measured_code = Op.BALANCE(probed) + overhead_cost = measured_code.gas_cost(fork) - Op.BALANCE( + address_warm=False + ).gas_cost(fork) + outer_code: Bytecode = Op.POP( + Op.DELEGATECALL(gas=100_000, address=inner) + ) + CodeGasMeasure( + code=measured_code, + overhead_cost=overhead_cost, + extra_stack_items=1, + ) + outer = pre.deploy_contract(code=outer_code) + + tx = Transaction(to=outer, sender=pre.fund_eoa()) + + # Slot 0 holds the measured (cold) BALANCE read. + post = {outer: Account(storage={0: cold_gas})} + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +def test_call_to_double_delegated_target_single_hop( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, +) -> None: + """ + Verify delegation resolution is single-hop: A -> B -> C charges two. + + ``target`` (A) is an EOA delegated to ``mid`` (B), which is itself + an EOA delegated to ``final`` (C), a code-bearing account. A cold + ``CALL`` to ``target`` reads exactly two account leaves -- the + target's and its delegation's -- and is charged + ``2 * COLD_ACCOUNT_ACCESS`` (6,000). The chain is not followed a + second hop, so ``final``'s leaf is not charged. Both the framework + opcode model and a runtime ``CodeGasMeasure`` confirm the value. + """ + gas_costs = fork.gas_costs() + + # A -> B -> C delegation chain. `mid` is an EOA whose code is the + # 7702 delegation designator pointing at `final`; `target` delegates + # to `mid` in turn. + final = pre.deploy_contract(Op.STOP) + mid = pre.fund_eoa(amount=0, delegation=final) + target = pre.fund_eoa(amount=0, delegation=mid) + + # Framework model: cold target leaf + cold delegation leaf, no third + # access for the second hop. + cost_metadata = Op.CALL( + address_warm=False, + delegated_address=True, + delegated_address_warm=False, + ) + expected_gas = 2 * gas_costs.COLD_ACCOUNT_ACCESS + assert expected_gas == cost_metadata.gas_cost(fork) + assert cost_metadata.state_cost(fork) == 0 + + measured_code = Op.CALL(gas=0, address=target) + measure_address = _measure_call( + pre, fork, measured_code, Op.CALL(address_warm=False) + ) + + tx = Transaction(to=measure_address, sender=pre.fund_eoa()) + + post = {measure_address: Account(storage={0: expected_gas})} + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.with_all_call_opcodes() +def test_call_precompile_is_warm( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + call_opcode: Op, +) -> None: + """ + Verify a call to a precompile is warm from the start. + + Precompiles are part of the accessed-addresses set from the start of + every transaction, so a call to one pays only ``WARM_ACCESS`` (100). + The identity precompile (address 4) is used as the target. + """ + gas_costs = fork.gas_costs() + + identity_precompile = Address(4) + + measured_code = call_opcode(gas=0, address=identity_precompile) + measure_address = _measure_call( + pre, fork, measured_code, call_opcode(address_warm=False) + ) + + expected_gas = call_opcode(address_warm=True).gas_cost(fork) + assert expected_gas == gas_costs.WARM_ACCESS + + tx = Transaction(to=measure_address, sender=pre.fund_eoa()) + + post = {measure_address: Account(storage={0: expected_gas})} + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize( + "value", [0, 1], ids=["no_value_no_stipend", "value_grants_stipend"] +) +def test_call_value_stipend_is_usable( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + value: int, +) -> None: + """ + The ``CALL`` value-transfer stipend (``CALL_STIPEND`` = 2,300) is + forwarded to the callee and usable for execution. + + The caller forwards ``gas=0``, so the callee receives only the stipend + (2,300) when a positive value is sent, and nothing otherwise. The + callee runs a small amount of work (well under 2,300 gas) then stops: + with the stipend the call succeeds (returns 1); without value (no + stipend, zero forwarded gas) the work runs out of gas and the call + fails (returns 0). This proves the stipend is not merely returned but + is spendable by the callee. + """ + # ~250 gas of cheap work: comfortably within the 2,300 stipend, far + # above the zero gas forwarded when no value (so no stipend) is sent. + work = (Op.PUSH1(0) + Op.POP) * 50 + Op.STOP + callee = pre.deploy_contract(code=work) + + caller = pre.deploy_contract( + code=Op.SSTORE(0, Op.CALL(0, callee, value, 0, 0, 0, 0)), + balance=1, + ) + + tx = Transaction(to=caller, sender=pre.fund_eoa()) + + # 1 when the stipend funded the callee's work, 0 when it ran out. + post = {caller: Account(storage={0: 1 if value else 0})} + state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_create_gas.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_create_gas.py new file mode 100644 index 00000000000..862b6fc77a7 --- /dev/null +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_create_gas.py @@ -0,0 +1,553 @@ +""" +Tests for the EIP-8038 [State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038) +``CREATE``/``CREATE2`` regular-gas dimension. + +Under EIP-8038 the contract-creation opcodes are repriced in their +*regular* gas dimension to ``CREATE_ACCESS`` (``ACCOUNT_WRITE`` + +``COLD_STORAGE_ACCESS`` = 11,000), on top of which the EIP-3860 init +code word cost (2 per word) and, for ``CREATE2`` only, an additional +keccak word cost (6 per word) are charged. The new-account creation +and per-byte code deposit charges are the EIP-8037 *state* dimension, +covered in +``eip8037_state_creation_gas_cost_increase/test_state_gas_create.py``. + +These tests isolate and assert the EIP-8038 *regular* dimension. At the +contract-creating-transaction boundary the state component is re-derived +only to feed the ``max(regular, state)`` block-header accounting. +""" + +from typing import List + +import pytest +from execution_testing import ( + AccessList, + Account, + Address, + Alloc, + Bytecode, + CodeGasMeasure, + Fork, + Hash, + Header, + Initcode, + Op, + StateTestFiller, + Storage, + Transaction, + TransactionException, + compute_create_address, +) +from execution_testing.checklists import EIPChecklist + +from .spec import ref_spec_8038 + +REFERENCE_SPEC_GIT_PATH = ref_spec_8038.git_path +REFERENCE_SPEC_VERSION = ref_spec_8038.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.with_all_create_opcodes() +@pytest.mark.parametrize( + "init_code_size", + [ + pytest.param(0, id="empty"), + pytest.param(32, id="one_word"), + pytest.param(33, id="two_words"), + pytest.param(96, id="three_words"), + ], +) +def test_create_regular_gas( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + create_opcode: Op, + init_code_size: int, +) -> None: + """ + Measure the regular gas of CREATE/CREATE2 and assert the schedule. + + The EIP-8038 *regular* dimension is ``CREATE_ACCESS`` (11,000) plus + the EIP-3860 init code word cost (2 per word) plus, for ``CREATE2`` + only, an additional keccak word cost (6 per word). The EIP-8037 + account-creation state gas is excluded by subtracting + ``create_state_gas(0)``. + """ + gas_costs = fork.gas_costs() + # The EIP-8038 CREATE regular base equals ACCOUNT_WRITE + + # COLD_STORAGE_ACCESS = 11,000. + assert gas_costs.OPCODE_CREATE_BASE == 11_000 + assert ( + gas_costs.OPCODE_CREATE_BASE + == gas_costs.ACCOUNT_WRITE + gas_costs.COLD_STORAGE_ACCESS + ) + + # Isolate the regular dimension: opcode total minus its account + # creation state gas (the only state component carried by the CREATE + # opcode itself; code deposit is charged on RETURN inside initcode). + create_meta = create_opcode(init_code_size=init_code_size) + regular_gas = create_meta.gas_cost(fork) - fork.create_state_gas( + code_size=0 + ) + # Equivalent isolation via the regular_cost helper. + assert regular_gas == create_meta.regular_cost(fork) + + init_code_words = (init_code_size + 31) // 32 + expected_regular = ( + gas_costs.OPCODE_CREATE_BASE + + gas_costs.CODE_INIT_PER_WORD * init_code_words + ) + if create_opcode == Op.CREATE2: + expected_regular += ( + gas_costs.OPCODE_KECCAK256_PER_WORD * init_code_words + ) + assert regular_gas == expected_regular + + # Runtime confirmation via CodeGasMeasure: a factory whose CREATE + # deploys empty code, so no code-deposit state gas is charged and the + # only state component is the account-creation gas funded from the + # reservoir. The initcode is brought into memory BEFORE the measured + # window, so the memory-expansion charge is excluded; the measured + # value is the CREATE opcode's regular cost exactly. The overhead + # subtracts the create-call argument pushes (the create leaves one + # stack item, its result). + # + # The initcode is all-zero bytes (`STOP`), so the child frame halts + # immediately consuming zero gas and deposits empty code. This keeps + # the measured value the CREATE opcode's own regular cost, with no + # child-execution gas folded in. `init_code_size` still drives the + # opcode's per-init-word charge. + padded_init = b"\x00" * init_code_size + + create_call = ( + Op.CREATE2(value=0, offset=0, size=init_code_size, salt=0) + if create_opcode == Op.CREATE2 + else Op.CREATE(value=0, offset=0, size=init_code_size) + ) + arg_pushes = (4 if create_opcode == Op.CREATE2 else 3) * gas_costs.VERY_LOW + + memory_setup = ( + Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE, new_memory_size=init_code_size) + if init_code_size + else Bytecode() + ) + storage = Storage() + measure = CodeGasMeasure( + code=create_call, + overhead_cost=arg_pushes, + extra_stack_items=1, + sstore_key=storage.store_next(regular_gas, "create_regular_gas"), + ) + factory = pre.deploy_contract(code=memory_setup + measure) + + tx = Transaction( + to=factory, + data=padded_init, + # Reservoir funds the account-creation state gas; leaving + # gas_limit unset keeps `Op.GAS` honest about gas_left. + state_gas_reservoir=fork.create_state_gas(code_size=0), + sender=pre.fund_eoa(), + ) + + post = {factory: Account(storage=storage)} + state_test(pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize( + "init_code_size", + [ + pytest.param(32, id="one_word"), + pytest.param(64, id="two_words"), + pytest.param(128, id="four_words"), + ], +) +def test_create2_keccak_word_delta( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + init_code_size: int, +) -> None: + """ + Verify CREATE2 costs exactly the keccak word surcharge over CREATE. + + ``CREATE2`` hashes the init code to derive the salted address, adding + ``OPCODE_KECCAK256_PER_WORD`` (6) per init-code word on top of the + regular cost shared with ``CREATE``. Both opcodes carry the identical + EIP-8038 ``CREATE_ACCESS`` base and EIP-3860 word cost. + + The regular-gas delta is asserted via the opcode model + (``create2_regular - create_regular`` equals the keccak word + surcharge). At runtime a factory then measures a single ``CREATE2`` + with ``CodeGasMeasure`` and stores its absolute regular cost: the + surcharge is established by the model assertion, and the runtime leg + confirms the absolute ``CREATE2`` regular cost. + """ + gas_costs = fork.gas_costs() + init_code_words = (init_code_size + 31) // 32 + keccak_surcharge = gas_costs.OPCODE_KECCAK256_PER_WORD * init_code_words + + create_regular = Op.CREATE(init_code_size=init_code_size).regular_cost( + fork + ) + create2_regular = Op.CREATE2(init_code_size=init_code_size).regular_cost( + fork + ) + assert create2_regular - create_regular == keccak_surcharge + + # Runtime confirmation. Init code is all-zero bytes (`STOP`), so the + # child frame halts immediately (zero gas) depositing empty code; the + # CREATE2 charges no code-deposit state gas and no child execution gas + # is folded into the measurement. The single CREATE2 regular cost is + # measured via CodeGasMeasure with a reservoir sized for its account + # creation state gas, keeping the GAS-measured `gas_left` free of + # state-gas spill. The opcode-model assertion above is the + # load-bearing keccak-delta check; this confirms the absolute value. + padded = b"\x00" * init_code_size + + push4 = 4 * gas_costs.VERY_LOW + storage = Storage() + measure_create2 = CodeGasMeasure( + code=Op.CREATE2(value=0, offset=0, size=init_code_size, salt=0), + overhead_cost=push4, + extra_stack_items=1, + sstore_key=storage.store_next(create2_regular, "create2_regular"), + ) + factory_code = ( + Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE, new_memory_size=init_code_size) + + measure_create2 + ) + factory = pre.deploy_contract(code=factory_code) + + tx = Transaction( + to=factory, + data=padded, + state_gas_reservoir=fork.create_state_gas(code_size=0), + sender=pre.fund_eoa(), + ) + + post = {factory: Account(storage=storage)} + state_test(pre=pre, post=post, tx=tx) + + +class TestCreateTxGasBoundary: + """ + Test the contract-creating-transaction gas boundary under EIP-8038. + + Four scenarios pin the boundary, mirroring EIP-3860's + ``TestContractCreationGasUsage`` but with the EIP-8037/8038 2D gas + split: + + 1. ``too_little_intrinsic_gas``: one below the total intrinsic; the + transaction is rejected (``INTRINSIC_GAS_TOO_LOW``). + 2. ``exact_intrinsic_gas``: exactly the intrinsic; the tx is valid + but the initcode runs out of execution gas. + 3. ``too_little_execution_gas``: one below the full execution gas; + creation fails but the tx is valid. + 4. ``exact_execution_gas``: exactly the full execution gas; creation + succeeds. + """ + + @pytest.fixture + def initcode(self) -> Initcode: + """Return a small initcode that deposits a multi-byte contract.""" + # Deploy 32 bytes (STOP + 31 padding) so code-deposit state gas + # is non-zero and the code-deposit branch is exercised. + return Initcode( + deploy_code=Op.STOP + Op.INVALID * 31, initcode_length=64 + ) + + @pytest.fixture + def tx_access_list(self) -> List[AccessList]: + """ + Return an access list to raise the intrinsic gas cost above the + EIP-7623 floor data cost, mirroring EIP-3860's fixture. + """ + return [ + AccessList(address=Address(i), storage_keys=[]) + for i in range(1, 642) + ] + + @pytest.fixture + def exact_intrinsic_gas( + self, + fork: Fork, + initcode: Initcode, + tx_access_list: List[AccessList], + ) -> int: + """Return the total (regular + state) intrinsic tx gas cost.""" + calc = fork.transaction_intrinsic_cost_calculator() + return calc( + calldata=initcode, + contract_creation=True, + access_list=tx_access_list, + ) + + @pytest.fixture + def exact_execution_gas( + self, fork: Fork, exact_intrinsic_gas: int, initcode: Initcode + ) -> int: + """ + Return the total execution gas: intrinsic plus the initcode + execution gas plus the code-deposit gas. + + ``deployment_gas`` is fork-aware: under EIP-8037 it splits the + deposit into the keccak word cost (regular) and the per-byte cost + (state), while on a fork without state-byte metering it is the + flat regular per-byte deposit cost. The single call is therefore + correct in either regime. + """ + execution = exact_intrinsic_gas + initcode.execution_gas(fork) + execution += initcode.deployment_gas(fork) + return execution + + @pytest.mark.parametrize( + "gas_test_case", + [ + pytest.param( + "too_little_intrinsic_gas", marks=pytest.mark.exception_test + ), + pytest.param("exact_intrinsic_gas"), + pytest.param("too_little_execution_gas"), + pytest.param("exact_execution_gas"), + ], + ) + @EIPChecklist.GasCostChanges.Test.OutOfGas() + def test_create_tx_gas_boundary( + self, + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + initcode: Initcode, + tx_access_list: List[AccessList], + exact_intrinsic_gas: int, + exact_execution_gas: int, + gas_test_case: str, + ) -> None: + """Drive a creation tx at each of the four gas boundary points.""" + sender = pre.fund_eoa() + create_address = compute_create_address(address=sender, nonce=0) + + if gas_test_case == "too_little_intrinsic_gas": + gas_limit = exact_intrinsic_gas - 1 + elif gas_test_case == "exact_intrinsic_gas": + gas_limit = exact_intrinsic_gas + elif gas_test_case == "too_little_execution_gas": + gas_limit = exact_execution_gas - 1 + else: + gas_limit = exact_execution_gas + + tx_error = ( + TransactionException.INTRINSIC_GAS_TOO_LOW + if gas_test_case == "too_little_intrinsic_gas" + else None + ) + + succeeds = gas_test_case == "exact_execution_gas" + post = { + create_address: ( + Account(code=initcode.deploy_code) + if succeeds + else Account.NONEXISTENT + ) + } + + tx = Transaction( + to=None, + data=initcode, + access_list=tx_access_list, + gas_limit=gas_limit, + error=tx_error, + sender=sender, + ) + + # 2D block accounting: gas_used = max(regular, state). The state + # axis carries the intrinsic NEW_ACCOUNT and (when the deposit + # succeeds) the per-byte code-deposit gas. + if tx_error is not None: + header_verify = None + else: + intrinsic_state = ( + fork.transaction_intrinsic_state_gas(contract_creation=True) + if hasattr(fork, "transaction_intrinsic_state_gas") + else 0 + ) + regular_used = gas_limit - intrinsic_state + state_used = intrinsic_state + if succeeds: + code_deposit_state = fork.code_deposit_state_gas( + code_size=len(initcode.deploy_code) + ) + state_used += code_deposit_state + regular_used -= code_deposit_state + header_verify = Header(gas_used=max(regular_used, state_used)) + + state_test( + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=header_verify, + ) + + +@pytest.mark.with_all_create_opcodes() +@pytest.mark.parametrize( + "abort_mode", + [ + pytest.param("insufficient_balance", id="insufficient_balance"), + pytest.param("nonce_overflow", id="nonce_overflow"), + pytest.param(None, id="no_error"), + ], +) +def test_aborted_create_does_not_warm_address( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + create_opcode: Op, + abort_mode: str | None, +) -> None: + """ + Verify a silently-aborted CREATE does not warm the target address. + + When CREATE aborts before spawning the child frame (insufficient + balance for the endowment, or nonce overflow), the would-be address + is never added to the accessed-addresses set. A subsequent + ``BALANCE`` of that address is therefore charged the full + ``COLD_ACCOUNT_ACCESS`` (3,000), not ``WARM_ACCESS`` (100). + """ + init_code = Op.STOP + init_code_bytes = bytes(init_code) + init_code_len = len(init_code) + + create_value = 1 + create_call = create_opcode( + value=create_value, offset=0, size=init_code_len + ) + + # After the aborted CREATE, measure the BALANCE access of the + # would-be address (passed via calldata). + # The address should only be warm when the CREATE/CREATE2 opcode + # successfully reached initcode execution stage. + address_warm = abort_mode is None + balance_code = Op.BALANCE(Op.CALLDATALOAD(0), address_warm=address_warm) + measure = CodeGasMeasure(code=balance_code, extra_stack_items=1) + + setup = Op.MSTORE( + 0, + int.from_bytes(init_code_bytes, "big") << (256 - 8 * init_code_len), + ) + factory_code = setup + Op.POP(create_call) + measure + + factory_nonce = 2**64 - 1 if abort_mode == "nonce_overflow" else 1 + factory_balance = create_value + if abort_mode == "insufficient_balance": + factory_balance -= 1 + factory = pre.deploy_contract( + code=factory_code, nonce=factory_nonce, balance=factory_balance + ) + + target_address = compute_create_address( + address=factory, + salt=0, + initcode=init_code_bytes, + nonce=factory_nonce, + opcode=create_opcode, + ) + + tx = Transaction( + to=factory, + data=Hash(target_address, left_padding=True), + sender=pre.fund_eoa(), + ) + + # The BALANCE must be cold: in case of error, the aborted CREATE never + # warmed the would-be address. + post = { + factory: Account(storage={0: balance_code.gas_cost(fork)}), + target_address: Account(nonce=1) + if abort_mode is None + else Account.NONEXISTENT, + } + state_test(pre=pre, post=post, tx=tx) + + +@pytest.mark.pre_alloc_mutable +def test_create2_to_occupied_address( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Verify ``CREATE2`` to an occupied address creates nothing and refunds. + + When ``CREATE2`` targets an address that is not deployable (here an + already-deployed contract, whose ``code_hash`` is non-empty), the + creation aborts after the account-access charge: the opcode pushes + ``0``, bumps the factory's nonce, charges the message gas to the + regular dimension, and refunds the ``NEW_ACCOUNT`` *state* gas so no + net account-creation charge lands. No child frame runs, so the + occupied contract's code and storage are left untouched. + """ + # Initcode the factory passes to CREATE2; were the target free it + # would deposit a single STOP. The salt is fixed so the collision + # address is deterministic from the factory address. + init_code = Op.STOP + init_code_bytes = bytes(init_code) + init_code_len = len(init_code_bytes) + salt = 0 + + # Factory CREATE2s the calldata initcode and stores the pushed result; + # a collision pushes 0. The initcode is copied into memory before the + # CREATE2 so the address derivation hashes exactly ``init_code_bytes``. + storage = Storage() + factory_code = Op.CALLDATACOPY( + 0, 0, Op.CALLDATASIZE, new_memory_size=init_code_len + ) + Op.SSTORE( + storage.store_next(0, "create2_collision_result"), + Op.CREATE2(value=0, offset=0, size=init_code_len, salt=salt), + ) + factory = pre.deploy_contract(code=factory_code) + + # The address CREATE2 would compute from this factory, salt, and + # initcode. ``compute_create_address`` with ``opcode=Op.CREATE2`` is + # the unified EEST helper for the CREATE2 derivation. + collision_address = compute_create_address( + address=factory, + salt=salt, + initcode=init_code_bytes, + opcode=Op.CREATE2, + ) + + # Pre-occupy the collision address with a contract carrying distinct + # code and storage so a successful (and therefore incorrect) creation + # would be detectable. A non-empty ``code_hash`` makes the account + # non-deployable (``account_deployable`` is False). + # + # `address=` hard-codes the occupant at the derived collision address; + # it requires `pre_alloc_mutable`. This is the only way to pre-seat the + # exact CREATE2 target, mirroring the EIP-7610 collision suite. + occupant_code = Op.SSTORE(0, 0x42) + Op.STOP + occupant_storage = Storage({0x1: 0xCAFE}) # type: ignore[dict-item] + pre.deploy_contract( + code=occupant_code, + storage=occupant_storage, + nonce=1, + address=collision_address, + ) + + tx = Transaction( + to=factory, + data=init_code_bytes, + sender=pre.fund_eoa(), + ) + + # Factory stored a 0 result; the occupant is untouched (its initcode + # never ran, so slot 0 stays unset and slot 1 keeps its seeded value). + post = { + factory: Account(storage=storage), + collision_address: Account( + code=occupant_code, storage=occupant_storage + ), + } + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_eip_mainnet.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_eip_mainnet.py new file mode 100644 index 00000000000..210879bf6f3 --- /dev/null +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_eip_mainnet.py @@ -0,0 +1,265 @@ +""" +Mainnet-marked happy-path smoke tests for +[EIP-8038: State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038). + +One minimal success per repriced dimension (no boundaries, no exact +magnitudes): a state slot is written, a value-bearing cold ``CALL`` +lands, an ``EXTCODESIZE`` runs, a ``CREATE`` deploys a contract, a +``SELFDESTRUCT`` funds a fresh account, a single ``7702`` authorization +installs a delegation, and a re-authorization of an already-delegated +authority applies the existing-authority refund. Gas limits are +deliberately generous so these prove the operation runs under the +EIP-8038 schedule without re-deriving any per-opcode cost (other files +own those matrices). +""" + +import pytest +from execution_testing import ( + Account, + Alloc, + AuthorizationTuple, + Fork, + Op, + StateTestFiller, + Storage, + Transaction, +) +from execution_testing.checklists import EIPChecklist + +from ...prague.eip7702_set_code_tx.spec import Spec as Spec7702 +from .spec import ref_spec_8038 + +REFERENCE_SPEC_GIT_PATH = ref_spec_8038.git_path +REFERENCE_SPEC_VERSION = ref_spec_8038.version + +pytestmark = [pytest.mark.valid_at("Amsterdam"), pytest.mark.mainnet] + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +def test_sstore_zero_to_nonzero( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + A zero-to-nonzero ``SSTORE`` pays the EIP-8038 storage write and + succeeds, leaving the slot set. + """ + storage = Storage() + contract = pre.deploy_contract(code=Op.SSTORE(storage.store_next(1), 1)) + + tx = Transaction( + to=contract, + gas_limit=1_000_000, + sender=pre.fund_eoa(), + ) + + post = {contract: Account(storage=storage)} + state_test(pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +def test_cold_call_with_value( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + A value-bearing cold ``CALL`` pays ``COLD_ACCOUNT_ACCESS`` plus + ``CALL_VALUE`` and succeeds; the caller records the ``CALL`` success + flag and the callee receives the forwarded value. + """ + callee = pre.deploy_contract(code=Op.STOP, balance=0) + + caller_storage = Storage() + caller = pre.deploy_contract( + code=( + Op.SSTORE( + caller_storage.store_next(1), + Op.CALL(gas=100_000, address=callee, value=1), + ) + ), + ) + + tx = Transaction( + to=caller, + gas_limit=1_000_000, + value=1, + sender=pre.fund_eoa(), + ) + + post = { + caller: Account(storage=caller_storage), + callee: Account(balance=1), + } + state_test(pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +def test_extcodesize( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + ``EXTCODESIZE`` pays the EIP-8038 account access plus the code-read + surcharge and succeeds, returning the target's non-zero code size. + """ + target = pre.deploy_contract(code=Op.STOP * 3) + + storage = Storage() + contract = pre.deploy_contract( + code=Op.SSTORE(storage.store_next(3), Op.EXTCODESIZE(target)), + ) + + tx = Transaction( + to=contract, + gas_limit=1_000_000, + sender=pre.fund_eoa(), + ) + + post = {contract: Account(storage=storage)} + state_test(pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +def test_create_deploys_contract( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + A factory ``CREATE``s a one-byte (``STOP``) contract under the + EIP-8038 schedule and succeeds; the factory records the ``CREATE`` + success flag in a slot. The transaction supplies the CREATE state + gas via the reservoir. + """ + init_code = Op.STOP + init_word = int.from_bytes(bytes(init_code), "big") << ( + 256 - 8 * len(init_code) + ) + + storage = Storage() + factory = pre.deploy_contract( + code=( + Op.MSTORE(0, init_word) + + Op.SSTORE( + storage.store_next(True), + Op.GT(Op.CREATE(0, 0, len(init_code)), 0), + ) + ), + ) + + tx = Transaction( + to=factory, + gas_limit=1_000_000, + state_gas_reservoir=fork.create_state_gas(code_size=0), + sender=pre.fund_eoa(), + ) + + post = {factory: Account(storage=storage)} + state_test(pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +def test_selfdestruct_funds_new_account( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + A balance-bearing contract ``SELFDESTRUCT``s to a fresh beneficiary + under the EIP-8038 schedule, forwarding its balance. The new-account + state gas is supplied via the reservoir; the beneficiary ends up + holding the transferred balance. + """ + beneficiary = pre.fund_eoa(amount=0) + + suicidal = pre.deploy_contract( + code=Op.SELFDESTRUCT(beneficiary), + balance=1, + ) + + tx = Transaction( + to=suicidal, + gas_limit=1_000_000, + state_gas_reservoir=fork.gas_costs().NEW_ACCOUNT, + sender=pre.fund_eoa(), + ) + + post = {beneficiary: Account(balance=1)} + state_test(pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +def test_auth_installs_delegation( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + A single valid ``7702`` authorization pays the EIP-8038 auth + intrinsic and installs a delegation designation on the authority. + """ + auth_signer = pre.fund_eoa() + set_code_to = pre.deploy_contract(code=Op.STOP) + + authorization_list = [ + AuthorizationTuple( + address=set_code_to, + nonce=0, + signer=auth_signer, + ), + ] + + tx = Transaction( + to=auth_signer, + gas_limit=1_000_000, + authorization_list=authorization_list, + sender=pre.fund_eoa(), + ) + + post = { + auth_signer: Account( + nonce=1, + code=Spec7702.delegation_designation(set_code_to), + ), + } + state_test(pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +def test_existing_authority_refund( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + Re-authorizing an already-delegated authority applies the + existing-authority refund and re-points the delegation; the tx + succeeds with the new designation installed. + """ + old_target = pre.deploy_contract(code=Op.STOP) + new_target = pre.deploy_contract(code=Op.STOP) + + # Authority already carries a delegation, so the new authorization + # triggers REFUND_AUTH_PER_EXISTING_ACCOUNT. + auth_signer = pre.fund_eoa(delegation=old_target) + + authorization_list = [ + AuthorizationTuple( + address=new_target, + nonce=1, + signer=auth_signer, + ), + ] + + tx = Transaction( + to=auth_signer, + gas_limit=1_000_000, + authorization_list=authorization_list, + sender=pre.fund_eoa(), + ) + + post = { + auth_signer: Account( + nonce=2, + code=Spec7702.delegation_designation(new_target), + ), + } + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_exact_balance_no_fallback.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_exact_balance_no_fallback.py new file mode 100644 index 00000000000..40376c8af55 --- /dev/null +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_exact_balance_no_fallback.py @@ -0,0 +1,222 @@ +""" +No-silent-fallback exact-balance tests for +[EIP-8038: State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038). + +Each test funds the sender with *exactly* ``gas_limit * gas_price`` and +sets ``gas_limit`` one gas below the spec-correct Amsterdam intrinsic for +a single repriced dimension. A spec-correct client therefore rejects the +transaction with ``INTRINSIC_GAS_TOO_LOW``; a client that silently fell +back to the pre-Amsterdam value for that one constant would have computed +a strictly smaller intrinsic (``new - per_unit_delta``) and could have +executed the transaction. Because the sender holds no surplus wei, there +is no room for such a fallback to hide. + +The pre-Amsterdam (old) per-component value is read from the parent +fork's schedule (``fork.parent()``); the spec-correct intrinsic is read +from the active fork's intrinsic calculator. Nothing is hardcoded; the +gap is asserted to be positive so the construction is only emitted when +the dimension genuinely got more expensive. +""" + +import pytest +from execution_testing import ( + AccessList, + Alloc, + AuthorizationTuple, + Fork, + StateTestFiller, + Transaction, + TransactionException, +) +from execution_testing.checklists import EIPChecklist + +from .spec import ref_spec_8038 + +REFERENCE_SPEC_GIT_PATH = ref_spec_8038.git_path +REFERENCE_SPEC_VERSION = ref_spec_8038.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + +GAS_PRICE = 10 + + +@EIPChecklist.GasCostChanges.Test.OutOfGas() +@pytest.mark.exception_test +@pytest.mark.parametrize( + "num_addresses,num_keys", + [ + pytest.param(1, 0, id="one_address"), + pytest.param(2, 0, id="two_addresses"), + pytest.param(1, 1, id="one_address_one_key"), + ], +) +def test_access_list_no_fallback( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + num_addresses: int, + num_keys: int, +) -> None: + """ + Reject an access-list transaction whose ``gas_limit`` is one gas + below the Amsterdam intrinsic. + + EIP-8038 raises ``TX_ACCESS_LIST_ADDRESS`` (2400 -> 3000) and + ``TX_ACCESS_LIST_STORAGE_KEY`` (1900 -> 3000). A client reusing the + old per-address/per-key constants would compute an intrinsic smaller + by ``num_addresses * addr_delta + num_keys * key_delta``; with the + sender funded to the wei, that fallback must not slip through. + """ + new_costs = fork.gas_costs() + old_costs = fork.parent_or_fail().gas_costs() + addr_delta = ( + new_costs.TX_ACCESS_LIST_ADDRESS - old_costs.TX_ACCESS_LIST_ADDRESS + ) + key_delta = ( + new_costs.TX_ACCESS_LIST_STORAGE_KEY + - old_costs.TX_ACCESS_LIST_STORAGE_KEY + ) + fallback_delta = num_addresses * addr_delta + num_keys * key_delta + assert fallback_delta > 0 + + # All storage keys live on the first listed address; the remaining + # addresses carry no keys. + storage_keys = list(range(num_keys)) + access_list = [ + AccessList( + address=pre.fund_eoa(amount=0), + storage_keys=storage_keys if index == 0 else [], + ) + for index in range(num_addresses) + ] + + intrinsic = fork.transaction_intrinsic_cost_calculator()( + access_list=access_list, + return_cost_deducted_prior_execution=True, + ) + # One gas below the spec-correct intrinsic: a fallback client using + # the old constants needs only `intrinsic - fallback_delta`. + gas_limit = intrinsic - 1 + assert intrinsic - fallback_delta <= gas_limit < intrinsic + + sender = pre.fund_eoa(amount=gas_limit * GAS_PRICE) + tx = Transaction( + sender=sender, + to=pre.fund_eoa(amount=0), + access_list=access_list, + gas_limit=gas_limit, + gas_price=GAS_PRICE, + error=TransactionException.INTRINSIC_GAS_TOO_LOW, + ) + + state_test(pre=pre, post={}, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.OutOfGas() +@pytest.mark.exception_test +@pytest.mark.parametrize( + "num_auths", + [ + pytest.param(1, id="one_auth"), + pytest.param(2, id="two_auths"), + ], +) +def test_authorization_no_fallback( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + num_auths: int, +) -> None: + """ + Reject a ``7702`` set-code transaction whose ``gas_limit`` is one + gas below the Amsterdam intrinsic. + + EIP-8038 raises the per-authorization intrinsic + (``AUTH_PER_EMPTY_ACCOUNT``). A client reusing the old per-auth + constant would compute an intrinsic smaller by + ``num_auths * auth_delta``; the exact-balance sender leaves no slack + for that fallback. + """ + new_costs = fork.gas_costs() + old_costs = fork.parent_or_fail().gas_costs() + auth_delta = ( + new_costs.AUTH_PER_EMPTY_ACCOUNT - old_costs.AUTH_PER_EMPTY_ACCOUNT + ) + fallback_delta = num_auths * auth_delta + assert fallback_delta > 0 + + target = pre.deploy_contract(code=b"") + authorization_list = [ + AuthorizationTuple( + address=target, + nonce=0, + signer=pre.fund_eoa(), + ) + for _ in range(num_auths) + ] + + intrinsic = fork.transaction_intrinsic_cost_calculator()( + authorization_list_or_count=authorization_list, + return_cost_deducted_prior_execution=True, + ) + gas_limit = intrinsic - 1 + assert intrinsic - fallback_delta <= gas_limit < intrinsic + + # Set-code (type-4) txs require EIP-1559 fee fields. With + # max_fee == max_priority and value 0, the upfront debit the + # protocol reserves is exactly gas_limit * GAS_PRICE. + sender = pre.fund_eoa(amount=gas_limit * GAS_PRICE) + tx = Transaction( + sender=sender, + to=pre.fund_eoa(amount=0), + authorization_list=authorization_list, + gas_limit=gas_limit, + max_fee_per_gas=GAS_PRICE, + max_priority_fee_per_gas=GAS_PRICE, + error=TransactionException.INTRINSIC_GAS_TOO_LOW, + ) + + state_test(pre=pre, post={}, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.OutOfGas() +@pytest.mark.exception_test +def test_cold_account_access_no_fallback( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Reject a plain call transaction whose ``gas_limit`` is one gas below + the Amsterdam intrinsic. + + Under EIP-2780 every non-create, non-self transaction pays one + ``COLD_ACCOUNT_ACCESS`` in its intrinsic for touching the recipient; + EIP-8038 raises that constant (2600 -> 3000). A client reusing the + old ``COLD_ACCOUNT_ACCESS`` would compute an intrinsic smaller by the + per-access delta, and with the sender funded to the wei that fallback + must not execute. + """ + new_costs = fork.gas_costs() + old_costs = fork.parent_or_fail().gas_costs() + fallback_delta = ( + new_costs.COLD_ACCOUNT_ACCESS - old_costs.COLD_ACCOUNT_ACCESS + ) + assert fallback_delta > 0 + + intrinsic = fork.transaction_intrinsic_cost_calculator()( + return_cost_deducted_prior_execution=True, + ) + gas_limit = intrinsic - 1 + assert intrinsic - fallback_delta <= gas_limit < intrinsic + + sender = pre.fund_eoa(amount=gas_limit * GAS_PRICE) + tx = Transaction( + sender=sender, + to=pre.deploy_contract(code=b""), + gas_limit=gas_limit, + gas_price=GAS_PRICE, + error=TransactionException.INTRINSIC_GAS_TOO_LOW, + ) + + state_test(pre=pre, post={}, tx=tx) diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_ext_code_opcodes_gas.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_ext_code_opcodes_gas.py new file mode 100644 index 00000000000..90f446398ce --- /dev/null +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_ext_code_opcodes_gas.py @@ -0,0 +1,471 @@ +""" +Tests for [EIP-8038: State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038). + +Covers the EIP-8038 ``EXT*`` "double-read" surcharge: ``EXTCODESIZE`` and +``EXTCODECOPY`` perform two database reads (the account leaf and then the +code) and are therefore charged an extra ``WARM_ACCESS`` on top of the +account-access cost, whereas ``BALANCE`` and ``EXTCODEHASH`` read only the +account leaf and are charged the account-access cost alone. +""" + +from typing import Callable + +import pytest +from execution_testing import ( + AccessList, + Account, + Address, + Alloc, + Bytecode, + CodeGasMeasure, + Environment, + Fork, + Op, + StateTestFiller, + Storage, + Transaction, +) +from execution_testing.checklists import EIPChecklist + +from .spec import ref_spec_8038 + +REFERENCE_SPEC_GIT_PATH = ref_spec_8038.git_path +REFERENCE_SPEC_VERSION = ref_spec_8038.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + + +# Each parameter carries: +# - executable: builds the runnable opcode targeting ``target`` +# - cost_metadata: builds the metadata-only opcode for gas computation +# - extra_stack_items: stack items left by the opcode (for CodeGasMeasure) +# - code_read_surcharge: whether EIP-8038 adds the extra WARM_ACCESS read +EXT_OPCODES = [ + pytest.param( + lambda target: Op.EXTCODESIZE(target), + lambda warm: Op.EXTCODESIZE(address_warm=warm), + 1, + True, + id="EXTCODESIZE", + ), + pytest.param( + lambda target: Op.EXTCODECOPY(target, 0, 0, 0), + lambda warm: Op.EXTCODECOPY(address_warm=warm), + 0, + True, + id="EXTCODECOPY", + ), + pytest.param( + lambda target: Op.EXTCODEHASH(target), + lambda warm: Op.EXTCODEHASH(address_warm=warm), + 1, + False, + id="EXTCODEHASH", + ), + pytest.param( + lambda target: Op.BALANCE(target), + lambda warm: Op.BALANCE(address_warm=warm), + 1, + False, + id="BALANCE", + ), +] + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize("warm", [False, True], ids=["cold", "warm"]) +@pytest.mark.parametrize( + "executable,cost_metadata,extra_stack_items,code_read_surcharge", + EXT_OPCODES, +) +def test_ext_code_opcode_gas( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + warm: bool, + executable: Callable[[object], Bytecode], + cost_metadata: Callable[[bool], Bytecode], + extra_stack_items: int, + code_read_surcharge: bool, +) -> None: + """ + Measure the exact gas of an external-code/account-access opcode and + assert it matches the EIP-8038 schedule. + + ``EXTCODESIZE``/``EXTCODECOPY`` must cost exactly one ``WARM_ACCESS`` + more than ``BALANCE``/``EXTCODEHASH`` at equal warmth (the second, + code-reading database access). + """ + gas_costs = fork.gas_costs() + + target = pre.deploy_contract(Op.STOP) + + measured_code = executable(target) + # Subtract the opcode's OWN cold cost (not BALANCE's) so the + # CodeGasMeasure overhead excludes only the PUSH wrapper; under + # EIP-8038 EXTCODESIZE/EXTCODECOPY have a higher cold cost than + # BALANCE because of the code-read surcharge. + overhead_cost = measured_code.gas_cost(fork) - cost_metadata( + False + ).gas_cost(fork) + + code_gas_measure = CodeGasMeasure( + code=measured_code, + overhead_cost=overhead_cost, + extra_stack_items=extra_stack_items, + ) + measure_address = pre.deploy_contract(code=code_gas_measure) + + access_cost = ( + gas_costs.WARM_ACCESS if warm else gas_costs.COLD_ACCOUNT_ACCESS + ) + surcharge = gas_costs.WARM_ACCESS if code_read_surcharge else 0 + expected_gas = access_cost + surcharge + # Cross-check the framework opcode model agrees with the formula. + assert expected_gas == cost_metadata(warm).gas_cost(fork) + + # Warm the target via the access list when required; the cold case + # leaves it absent so its first runtime access is cold. + tx = Transaction( + to=measure_address, + sender=pre.fund_eoa(), + access_list=[AccessList(address=target, storage_keys=[])] + if warm + else None, + ) + + post = {measure_address: Account(storage={0: expected_gas})} + + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize("warm", [False, True], ids=["cold", "warm"]) +@pytest.mark.parametrize( + "copy_size", [32, 96], ids=["one_word", "three_words"] +) +def test_extcodecopy_nonzero_composes_additively( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + warm: bool, + copy_size: int, +) -> None: + """ + Verify the EIP-8038 ``EXTCODECOPY`` surcharge composes additively. + + With a non-zero copy size, ``EXTCODECOPY`` charges the account-access + cost, the EIP-8038 code-read ``WARM_ACCESS`` surcharge, the EIP-150 + per-word copy cost (``OPCODE_COPY_PER_WORD`` per word, driven by the + copied data size), and the memory-expansion cost. The surcharge is a + flat add-on that does not interact with the copy or memory terms, so + the measured gas must equal the sum of all four components. + """ + gas_costs = fork.gas_costs() + + # Target carries enough code to satisfy the copy; STOP padding keeps + # it a deployable contract with a non-empty code hash. + target = pre.deploy_contract(Op.STOP * copy_size) + + # Runnable opcode copying ``copy_size`` bytes of the target's code into + # memory at offset 0. The metadata mirrors the runtime effect (warmth, + # copied byte count, and the 0 -> copy_size memory growth) so the + # opcode model agrees with execution and the overhead reduces to the + # operand pushes alone. + measured_code = Op.EXTCODECOPY.with_metadata( + address_warm=warm, + data_size=copy_size, + new_memory_size=copy_size, + old_memory_size=0, + )(target, 0, 0, copy_size) + + # Oracle: the same metadata-only opcode. Subtracting its cost from the + # measured code's cost yields the CodeGasMeasure overhead (the operand + # PUSHes only), so the stored value equals exactly this opcode cost. + oracle = Op.EXTCODECOPY.with_metadata( + address_warm=warm, + data_size=copy_size, + new_memory_size=copy_size, + old_memory_size=0, + ) + expected_gas = oracle.gas_cost(fork) + + # Additive decomposition the surcharge must satisfy. + words = (copy_size + 31) // 32 + access_cost = ( + gas_costs.WARM_ACCESS if warm else gas_costs.COLD_ACCOUNT_ACCESS + ) + memory_expansion = fork.memory_expansion_gas_calculator()( + new_bytes=copy_size, previous_bytes=0 + ) + assert expected_gas == ( + access_cost + + gas_costs.WARM_ACCESS # EIP-8038 code-read surcharge + + gas_costs.OPCODE_COPY_PER_WORD * words + + memory_expansion + ) + + code_gas_measure = CodeGasMeasure( + code=measured_code, + overhead_cost=measured_code.gas_cost(fork) - oracle.gas_cost(fork), + extra_stack_items=0, + ) + measure_address = pre.deploy_contract(code=code_gas_measure) + + tx = Transaction( + to=measure_address, + sender=pre.fund_eoa(), + access_list=[AccessList(address=target, storage_keys=[])] + if warm + else None, + ) + + post = {measure_address: Account(storage={0: expected_gas})} + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize("warm", [False, True], ids=["cold", "warm"]) +def test_extcodehash_empty_account( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + warm: bool, +) -> None: + """ + Verify ``EXTCODEHASH`` of an empty account is priced without surcharge. + + ``EXTCODEHASH`` reads only the account leaf, so EIP-8038 adds no + code-read surcharge: the cost is exactly ``COLD_ACCOUNT_ACCESS`` (cold) + or ``WARM_ACCESS`` (warm) regardless of the target being empty. The + returned hash of an empty/non-existent account is ``0``. + """ + gas_costs = fork.gas_costs() + + # A non-existent (empty) target: never deployed, no balance, no code. + empty_addr = Address(0xDEAD) + + expected_gas = ( + gas_costs.WARM_ACCESS if warm else gas_costs.COLD_ACCOUNT_ACCESS + ) + # No code-read surcharge for EXTCODEHASH; the opcode model must agree. + assert expected_gas == Op.EXTCODEHASH(address_warm=warm).gas_cost(fork) + + # Measure the access cost, then store the returned hash so the + # empty-account 0 result is asserted alongside the pricing. The + # measured opcode carries the runtime warmth so the overhead reduces + # to the address PUSH alone. + # + # The empty-account hash is 0, which is also the default of an + # unwritten storage slot: a stranded hash store would leave slot 1 at + # 0 and pass vacuously (the original defect). Slot 1 is poisoned with + # a non-zero sentinel before the measured region, so the real store + # must overwrite it back to 0. If that store is ever stranded, slot 1 + # keeps the sentinel and the assertion fails instead of silently + # passing. + # The poison precedes the measured region and the hash store follows + # it, so neither touches 0xDEAD before the measured access nor + # perturbs the cold-case gas measurement. + storage = Storage() + measured_code = Op.EXTCODEHASH.with_metadata(address_warm=warm)(empty_addr) + gas_slot = storage.store_next(expected_gas, "extcodehash_empty_gas") + hash_slot = storage.store_next(0, "extcodehash_empty_hash") + hash_slot_sentinel = 0xBADC0FFEE + code = ( + Op.SSTORE(hash_slot, hash_slot_sentinel) + + CodeGasMeasure( + code=measured_code, + overhead_cost=measured_code.gas_cost(fork) + - Op.EXTCODEHASH(address_warm=warm).gas_cost(fork), + extra_stack_items=1, + sstore_key=gas_slot, + ) + + Op.SSTORE(hash_slot, Op.EXTCODEHASH(empty_addr)) + ) + measure_address = pre.deploy_contract(code=code) + + tx = Transaction( + to=measure_address, + sender=pre.fund_eoa(), + access_list=[AccessList(address=empty_addr, storage_keys=[])] + if warm + else None, + ) + + post = {measure_address: Account(storage=storage)} + state_test(env=env, pre=pre, post=post, tx=tx) + + +# The two surcharge opcodes only -- both always pay the second, +# code-reading access, so there is no no-surcharge variant here. +DOUBLE_READ_OPCODES = [Op.EXTCODESIZE, Op.EXTCODECOPY] + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize("warm", [False, True], ids=["cold", "warm"]) +@pytest.mark.parametrize("opcode", DOUBLE_READ_OPCODES) +def test_ext_code_double_read_empty_account( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + warm: bool, + opcode: Op, +) -> None: + """ + Charge the EIP-8038 double-read surcharge on an empty target. + + ``EXTCODESIZE``/``EXTCODECOPY`` add the second, code-reading database + access unconditionally: the surcharge is charged before the account is + read, so an empty/non-existent target still costs + ``COLD_ACCOUNT_ACCESS + WARM_ACCESS`` (cold) or ``2 * WARM_ACCESS`` + (warm), i.e. 3100 / 200, exactly as for a code-bearing target. This + contrasts with ``EXTCODEHASH``/``BALANCE``, which read only the account + leaf and carry no surcharge (see ``test_extcodehash_empty_account``). A + client that skipped the second read for code-less accounts would be + caught here. + """ + # Never deployed: no code, no balance, non-existent account. + empty_addr = pre.nonexistent_account() + + measured_code = opcode(address=empty_addr) + # Subtract the opcode's OWN cold cost so the CodeGasMeasure overhead is + # only the operand PUSH wrapper; the surcharge is part of the cold cost. + overhead_cost = measured_code.gas_cost(fork) - opcode( + address_warm=False + ).gas_cost(fork) + + code_gas_measure = CodeGasMeasure( + code=measured_code, + overhead_cost=overhead_cost, + extra_stack_items=opcode.pushed_stack_items, + ) + measure_address = pre.deploy_contract(code=code_gas_measure) + expected_gas = opcode(address_warm=warm).gas_cost(fork) + + tx = Transaction( + to=measure_address, + sender=pre.fund_eoa(), + access_list=[AccessList(address=empty_addr, storage_keys=[])] + if warm + else None, + ) + + post = {measure_address: Account(storage={0: expected_gas})} + + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize("warm", [False, True], ids=["cold", "warm"]) +def test_extcodesize_empty_account_returns_zero( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + warm: bool, +) -> None: + """ + Pay the surcharge on an empty target while ``EXTCODESIZE`` returns 0. + + The size returned for an empty/non-existent account is ``0``, which + confirms the target genuinely has no code: the measured + ``COLD_ACCOUNT_ACCESS + WARM_ACCESS`` (cold) / ``2 * WARM_ACCESS`` + (warm) cost is therefore unambiguously the surcharge applied to an + empty account, not an artifact of the target accidentally holding code. + """ + # Never deployed: no code, no balance, non-existent account. + empty_addr = pre.nonexistent_account() + + expected_gas = Op.EXTCODESIZE(address_warm=warm).gas_cost(fork) + + # Measure the access cost and, separately, store the returned size so + # the empty-account 0 result is asserted alongside the pricing. The + # measured opcode carries the runtime warmth so the overhead reduces to + # the address PUSH alone. + storage = Storage() + measured_code = Op.EXTCODESIZE.with_metadata(address_warm=warm)(empty_addr) + gas_slot = storage.store_next(expected_gas, "extcodesize_empty_gas") + size_slot = storage.store_next(0, "extcodesize_empty_size") + code = CodeGasMeasure( + code=measured_code, + overhead_cost=measured_code.gas_cost(fork) + - Op.EXTCODESIZE(address_warm=warm).gas_cost(fork), + extra_stack_items=1, + sstore_key=gas_slot, + ) + Op.SSTORE(size_slot, Op.EXTCODESIZE(empty_addr)) + measure_address = pre.deploy_contract(code=code) + + tx = Transaction( + to=measure_address, + sender=pre.fund_eoa(), + access_list=[AccessList(address=empty_addr, storage_keys=[])] + if warm + else None, + ) + + post = {measure_address: Account(storage=storage)} + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize("warm", [False, True], ids=["cold", "warm"]) +@pytest.mark.parametrize( + "copy_size", [32, 96], ids=["one_word", "three_words"] +) +def test_extcodecopy_empty_account_composes_additively( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + warm: bool, + copy_size: int, +) -> None: + """ + Compose the surcharge additively when copying from an empty account. + + ``EXTCODECOPY`` of a non-existent source copies zero bytes into memory, + yet still charges the account-access cost, the EIP-8038 code-read + ``WARM_ACCESS`` surcharge, the EIP-150 per-word copy cost + (``OPCODE_COPY_PER_WORD`` per word, driven by the requested size, not + the source length), and the memory-expansion cost. The measured gas + must equal the sum of all four components, confirming the surcharge + composes additively even when there is no code to read. + """ + # Empty source: never deployed, no code. The copy yields zeros, but the + # cost is driven by the requested size, identical to a code-bearing + # source of the same length. + empty_addr = pre.nonexistent_account() + + oracle = Op.EXTCODECOPY.with_metadata( + address_warm=warm, + data_size=copy_size, + new_memory_size=copy_size, + old_memory_size=0, + ) + measured_code = oracle( + address=empty_addr, dest_offset=0, offset=0, size=copy_size + ) + + expected_gas = oracle.gas_cost(fork) + + code_gas_measure = CodeGasMeasure( + code=measured_code, + overhead_cost=measured_code.gas_cost(fork) - oracle.gas_cost(fork), + extra_stack_items=0, + ) + measure_address = pre.deploy_contract(code=code_gas_measure) + + tx = Transaction( + to=measure_address, + sender=pre.fund_eoa(), + access_list=[AccessList(address=empty_addr, storage_keys=[])] + if warm + else None, + ) + + post = {measure_address: Account(storage={0: expected_gas})} + state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_fork_transition.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_fork_transition.py new file mode 100644 index 00000000000..5ff91b0bbf6 --- /dev/null +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_fork_transition.py @@ -0,0 +1,523 @@ +""" +Fork-transition tests for +[EIP-8038: State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038). + +"Same operation, different gas" across the Amsterdam boundary. A block +at ``timestamp=14_999`` runs under the pre-fork (parent) schedule; a +block at ``timestamp=15_000`` runs under the EIP-8038 schedule. Every +before/after magnitude is derived from +``fork.fork_at(timestamp=...).gas_costs()`` — nothing is hardcoded. + +Two proof styles are used: + +* Account-access dimensions that are pure regular gas (``BALANCE`` cold + access and the ``EXT*`` code-read surcharge) are measured exactly with + ``CodeGasMeasure`` in each regime and asserted against the derived + cost. +* Constant repricings that the runtime opcode model cannot isolate + without state-gas confounders (``CALL_VALUE``, ``CREATE`` base, + ``SELFDESTRUCT`` account-write) are asserted at the constant level + from the derived schedules while the operation is still exercised in + both blocks to prove it runs in each regime. +* The authorization intrinsic rise is proven behaviourally: a tx whose + ``gas_limit`` equals the old auth intrinsic is valid before the fork + and rejected with ``INTRINSIC_GAS_TOO_LOW`` after. +""" + +from typing import List + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + AuthorizationTuple, + Block, + BlockchainTestFiller, + Bytecode, + CodeGasMeasure, + Fork, + Op, + Storage, + Transaction, + TransactionException, +) +from execution_testing.checklists import EIPChecklist + +from .spec import ref_spec_8038 + +REFERENCE_SPEC_GIT_PATH = ref_spec_8038.git_path +REFERENCE_SPEC_VERSION = ref_spec_8038.version + +pytestmark = pytest.mark.valid_at_transition_to("Amsterdam") + +# Block timestamps straddling the Amsterdam activation. +BEFORE_TS = 14_999 +AFTER_TS = 15_000 + + +def _measure_contract( + pre: Alloc, measured: Bytecode, opcode_cost: int, fork: Fork +) -> Address: + """ + Deploy a contract that stores the exact gas consumed by the measured + opcode in slot 0. + + ``measured`` is the runnable expression (opcode plus its PUSH + operands); ``opcode_cost`` is the bare opcode's own gas at ``fork``. + The wrapper overhead (the PUSH operands) is the difference between + the two, so ``CodeGasMeasure`` strips it and slot 0 holds only the + opcode's own cost. The opcode leaves one stack item (its result). + """ + overhead = measured.gas_cost(fork) - opcode_cost + code = CodeGasMeasure( + code=measured, + overhead_cost=overhead, + extra_stack_items=1, + ) + return pre.deploy_contract(code=code) + + +def transition_blocks( + before_to: Address, + after_to: Address, + pre: Alloc, + *, + value: int = 0, +) -> List[Block]: + """ + Return the two blocks that straddle the Amsterdam activation. + + The first block runs at ``BEFORE_TS`` (pre-fork schedule) and the + second at ``AFTER_TS`` (EIP-8038 schedule). Each carries a single + transaction from a fresh sender to its respective ``to`` target, + forwarding ``value`` so a value-bearing operation is exercised in both + regimes. + """ + return [ + Block( + timestamp=BEFORE_TS, + txs=[ + Transaction(to=before_to, value=value, sender=pre.fund_eoa()), + ], + ), + Block( + timestamp=AFTER_TS, + txs=[ + Transaction(to=after_to, value=value, sender=pre.fund_eoa()), + ], + ), + ] + + +@EIPChecklist.GasCostChanges.Test.ForkTransition.Before() +@EIPChecklist.GasCostChanges.Test.ForkTransition.After() +def test_cold_account_access_at_transition( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + ``BALANCE`` of a cold account costs ``COLD_ACCOUNT_ACCESS``, which + rises across the Amsterdam boundary (2600 -> 3000 on mainnet). The + same opcode is measured before and after; each block asserts its + regime's derived cost. + """ + before = fork.fork_at(timestamp=BEFORE_TS) + after = fork.fork_at(timestamp=AFTER_TS) + + cost_before = before.gas_costs().COLD_ACCOUNT_ACCESS + cost_after = after.gas_costs().COLD_ACCOUNT_ACCESS + assert cost_after > cost_before + + target = pre.deploy_contract(code=Op.STOP) + + # A distinct cold target per block keeps each measurement cold. + target_after = pre.deploy_contract(code=Op.STOP) + + # BALANCE has no code-read surcharge, so its bare cost equals + # COLD_ACCOUNT_ACCESS in each regime. + measure_before = _measure_contract( + pre, Op.BALANCE(target), cost_before, before + ) + measure_after = _measure_contract( + pre, Op.BALANCE(target_after), cost_after, after + ) + + blocks = transition_blocks(measure_before, measure_after, pre) + + post = { + measure_before: Account(storage={0: cost_before}), + measure_after: Account(storage={0: cost_after}), + } + blockchain_test(pre=pre, blocks=blocks, post=post) + + +@EIPChecklist.GasCostChanges.Test.ForkTransition.Before() +@EIPChecklist.GasCostChanges.Test.ForkTransition.After() +def test_ext_code_surcharge_at_transition( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + The EIP-8038 ``EXT*`` code-read surcharge appears at the fork. The + surcharge equals ``EXTCODESIZE`` minus ``BALANCE`` at equal warmth: + it is zero before the fork and one ``WARM_ACCESS`` (100) after. That + comparison is computed from the opcode model. On-chain, each block + measures only a cold ``EXTCODESIZE`` (2600 before, 3100 after): its + rise reflects the surcharge on top of the cold-access repricing, and + ``BALANCE`` is never executed. + """ + before = fork.fork_at(timestamp=BEFORE_TS) + after = fork.fork_at(timestamp=AFTER_TS) + + surcharge_before = Op.EXTCODESIZE(address_warm=True).gas_cost( + before + ) - Op.BALANCE(address_warm=True).gas_cost(before) + surcharge_after = Op.EXTCODESIZE(address_warm=True).gas_cost( + after + ) - Op.BALANCE(address_warm=True).gas_cost(after) + assert surcharge_before == 0 + assert surcharge_after == after.gas_costs().WARM_ACCESS + assert surcharge_after > surcharge_before + + extcodesize_cost_before = Op.EXTCODESIZE(address_warm=False).gas_cost( + before + ) + extcodesize_cost_after = Op.EXTCODESIZE(address_warm=False).gas_cost(after) + assert extcodesize_cost_after > extcodesize_cost_before + + target = pre.deploy_contract(code=Op.STOP) + target_after = pre.deploy_contract(code=Op.STOP) + + measure_before = _measure_contract( + pre, Op.EXTCODESIZE(target), extcodesize_cost_before, before + ) + measure_after = _measure_contract( + pre, Op.EXTCODESIZE(target_after), extcodesize_cost_after, after + ) + + blocks = transition_blocks(measure_before, measure_after, pre) + + post = { + measure_before: Account(storage={0: extcodesize_cost_before}), + measure_after: Account(storage={0: extcodesize_cost_after}), + } + blockchain_test(pre=pre, blocks=blocks, post=post) + + +@EIPChecklist.GasCostChanges.Test.ForkTransition.Before() +@EIPChecklist.GasCostChanges.Test.ForkTransition.After() +def test_call_value_cost_at_transition( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + ``CALL_VALUE`` rises across the boundary (9000 -> 10300 on mainnet, + becoming ``ACCOUNT_WRITE + CALL_STIPEND``). The constant transition + is asserted from the derived schedules while a value-bearing ``CALL`` + is exercised in both blocks to prove it still succeeds in each + regime. + """ + before = fork.fork_at(timestamp=BEFORE_TS) + after = fork.fork_at(timestamp=AFTER_TS) + + call_value_before = before.gas_costs().CALL_VALUE + call_value_after = after.gas_costs().CALL_VALUE + assert call_value_after > call_value_before + + callee_before = pre.deploy_contract(code=Op.STOP, balance=0) + callee_after = pre.deploy_contract(code=Op.STOP, balance=0) + + storage_before = Storage() + caller_before = pre.deploy_contract( + code=Op.SSTORE( + storage_before.store_next(1), + Op.CALL(gas=100_000, address=callee_before, value=1), + ), + ) + storage_after = Storage() + caller_after = pre.deploy_contract( + code=Op.SSTORE( + storage_after.store_next(1), + Op.CALL(gas=100_000, address=callee_after, value=1), + ), + ) + + blocks = transition_blocks(caller_before, caller_after, pre, value=1) + + post = { + caller_before: Account(storage=storage_before), + callee_before: Account(balance=1), + caller_after: Account(storage=storage_after), + callee_after: Account(balance=1), + } + blockchain_test(pre=pre, blocks=blocks, post=post) + + +@EIPChecklist.GasCostChanges.Test.ForkTransition.Before() +@EIPChecklist.GasCostChanges.Test.ForkTransition.After() +def test_create_base_cost_at_transition( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + The ``CREATE`` regular base cost changes across the boundary + (``OPCODE_CREATE_BASE``: 32000 -> 11000 on mainnet, redefined as + ``ACCOUNT_WRITE + COLD_STORAGE_ACCESS``). The constant transition is + asserted from the derived schedules and a ``CREATE`` is exercised in + both blocks to prove it still deploys. + """ + before = fork.fork_at(timestamp=BEFORE_TS) + after = fork.fork_at(timestamp=AFTER_TS) + + create_base_before = before.gas_costs().OPCODE_CREATE_BASE + create_base_after = after.gas_costs().OPCODE_CREATE_BASE + assert create_base_after != create_base_before + # Post-fork base is the harmonized ACCOUNT_WRITE + COLD_STORAGE_ACCESS. + assert create_base_after == ( + after.gas_costs().ACCOUNT_WRITE + after.gas_costs().COLD_STORAGE_ACCESS + ) + + init_code = Op.STOP + init_word = int.from_bytes(bytes(init_code), "big") << ( + 256 - 8 * len(init_code) + ) + + storage_before = Storage() + factory_before = pre.deploy_contract( + code=( + Op.MSTORE(0, init_word) + + Op.SSTORE( + storage_before.store_next(True), + Op.GT(Op.CREATE(0, 0, len(init_code)), 0), + ) + ), + ) + storage_after = Storage() + factory_after = pre.deploy_contract( + code=( + Op.MSTORE(0, init_word) + + Op.SSTORE( + storage_after.store_next(True), + Op.GT(Op.CREATE(0, 0, len(init_code)), 0), + ) + ), + ) + + blocks = transition_blocks(factory_before, factory_after, pre) + + post = { + factory_before: Account(storage=storage_before), + factory_after: Account(storage=storage_after), + } + blockchain_test(pre=pre, blocks=blocks, post=post) + + +@EIPChecklist.GasCostChanges.Test.ForkTransition.Before() +@EIPChecklist.GasCostChanges.Test.ForkTransition.After() +def test_selfdestruct_account_write_at_transition( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + ``SELFDESTRUCT`` gains an ``ACCOUNT_WRITE`` charge when it sends a + positive balance to an empty account, which is a new EIP-8038 + parameter (0 -> 8000 on mainnet). The constant transition is + asserted from the derived schedules and a value-bearing + ``SELFDESTRUCT`` to a fresh beneficiary is exercised in both blocks + to prove it still runs. + """ + before = fork.fork_at(timestamp=BEFORE_TS) + after = fork.fork_at(timestamp=AFTER_TS) + + account_write_before = before.gas_costs().ACCOUNT_WRITE + account_write_after = after.gas_costs().ACCOUNT_WRITE + assert account_write_after > account_write_before + + # Fresh empty beneficiaries so the positive-balance-to-empty branch + # that adds ACCOUNT_WRITE is taken in each regime. + beneficiary_before = pre.fund_eoa(amount=0) + beneficiary_after = pre.fund_eoa(amount=0) + + suicidal_before = pre.deploy_contract( + code=Op.SELFDESTRUCT(beneficiary_before), + balance=1, + ) + suicidal_after = pre.deploy_contract( + code=Op.SELFDESTRUCT(beneficiary_after), + balance=1, + ) + + blocks = transition_blocks(suicidal_before, suicidal_after, pre) + + post = { + beneficiary_before: Account(balance=1), + beneficiary_after: Account(balance=1), + } + blockchain_test(pre=pre, blocks=blocks, post=post) + + +@EIPChecklist.GasCostChanges.Test.ForkTransition.Before() +@EIPChecklist.GasCostChanges.Test.ForkTransition.After() +def test_sstore_write_cost_at_transition( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + The ``SSTORE`` first-change cost is repriced across the Amsterdam + boundary, and EIP-8038 changes the *model*, not a single number. + + Before the fork (parent schedule) a zero-to-nonzero ``SSTORE`` is a + flat regular charge (``COLD_STORAGE_ACCESS + STORAGE_SET``) with no + state-gas dimension. After the fork the charge splits: the regular + portion drops to ``COLD_STORAGE_ACCESS + STORAGE_WRITE`` while the + bulk moves into the new state-gas dimension, and the clear refund + rises. Every magnitude is derived from the two schedules; nothing is + hardcoded. + + The transition is asserted at the derived-constant level (the + runtime opcode cost cannot isolate the regular portion without the + state-gas confounder) and a zero-to-nonzero ``SSTORE`` is exercised + in both blocks to prove it still sets the slot in each regime. + """ + before = fork.fork_at(timestamp=BEFORE_TS) + after = fork.fork_at(timestamp=AFTER_TS) + + # First-change (zero -> nonzero, cold) SSTORE in each regime. + sstore = Op.SSTORE(new_value=1) + + regular_before = sstore.regular_cost(before) + regular_after = sstore.regular_cost(after) + state_before = sstore.state_cost(before) + state_after = sstore.state_cost(after) + total_before = sstore.gas_cost(before) + total_after = sstore.gas_cost(after) + + # The repricing changes the regular charge, introduces the state + # dimension, and therefore moves the total. + assert regular_after != regular_before + assert state_before == 0 + assert state_after > 0 + assert total_after != total_before + + # After the fork the regular portion is the EIP-8038 split: + # COLD_STORAGE_ACCESS plus the standalone STORAGE_WRITE (modeled as + # COLD_STORAGE_WRITE minus COLD_STORAGE_ACCESS). + after_costs = after.gas_costs() + storage_write_after = ( + after_costs.COLD_STORAGE_WRITE - after_costs.COLD_STORAGE_ACCESS + ) + assert regular_after == ( + after_costs.COLD_STORAGE_ACCESS + storage_write_after + ) + + # The storage-clear refund also rises across the boundary. + refund_before = before.gas_costs().REFUND_STORAGE_CLEAR + refund_after = after_costs.REFUND_STORAGE_CLEAR + assert refund_after > refund_before + + # Exercise the zero-to-nonzero SSTORE in both regimes; the slot ends + # set in each block. + storage_before = Storage() + contract_before = pre.deploy_contract( + code=Op.SSTORE(storage_before.store_next(1), 1), + ) + storage_after = Storage() + contract_after = pre.deploy_contract( + code=Op.SSTORE(storage_after.store_next(1), 1), + ) + + blocks = transition_blocks(contract_before, contract_after, pre) + + post = { + contract_before: Account(storage=storage_before), + contract_after: Account(storage=storage_after), + } + blockchain_test(pre=pre, blocks=blocks, post=post) + + +@EIPChecklist.GasCostChanges.Test.ForkTransition.Before() +@EIPChecklist.GasCostChanges.Test.ForkTransition.After() +@pytest.mark.exception_test +def test_auth_intrinsic_at_transition( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + The ``7702`` authorization intrinsic rises across the boundary. A tx + whose ``gas_limit`` equals the pre-fork single-authorization + intrinsic is valid before the fork but is rejected with + ``INTRINSIC_GAS_TOO_LOW`` after, because the EIP-8038 auth intrinsic + is strictly larger. + """ + before = fork.fork_at(timestamp=BEFORE_TS) + after = fork.fork_at(timestamp=AFTER_TS) + + intrinsic_before = before.transaction_intrinsic_cost_calculator()( + authorization_list_or_count=1, + return_cost_deducted_prior_execution=True, + ) + intrinsic_after = after.transaction_intrinsic_cost_calculator()( + authorization_list_or_count=1, + return_cost_deducted_prior_execution=True, + ) + # The pre-fork intrinsic is below the post-fork one, so the same + # gas_limit straddles validity at the boundary. + assert intrinsic_before < intrinsic_after + gas_limit = intrinsic_before + + target_before = pre.deploy_contract(code=Op.STOP) + target_after = pre.deploy_contract(code=Op.STOP) + + auth_before = pre.fund_eoa() + auth_after = pre.fund_eoa() + + blocks = [ + # Before the fork: gas_limit covers the old auth intrinsic. + Block( + timestamp=BEFORE_TS, + txs=[ + Transaction( + to=auth_before, + gas_limit=gas_limit, + authorization_list=[ + AuthorizationTuple( + address=target_before, + nonce=0, + signer=auth_before, + ), + ], + sender=pre.fund_eoa(), + ), + ], + ), + # After the fork: identical gas_limit is now below intrinsic. + Block( + timestamp=AFTER_TS, + txs=[ + Transaction( + to=auth_after, + gas_limit=gas_limit, + authorization_list=[ + AuthorizationTuple( + address=target_after, + nonce=0, + signer=auth_after, + ), + ], + sender=pre.fund_eoa(), + error=TransactionException.INTRINSIC_GAS_TOO_LOW, + ), + ], + exception=TransactionException.INTRINSIC_GAS_TOO_LOW, + ), + ] + + blockchain_test(pre=pre, blocks=blocks, post={}) diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_selfdestruct_gas.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_selfdestruct_gas.py new file mode 100644 index 00000000000..9349b191071 --- /dev/null +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_selfdestruct_gas.py @@ -0,0 +1,681 @@ +""" +Tests for the EIP-8038 [State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038) +``SELFDESTRUCT`` regular-gas dimension. + +Under EIP-8038 ``SELFDESTRUCT`` is charged, in its *regular* gas +dimension: + +- ``OPCODE_SELFDESTRUCT_BASE`` (5,000); +- a ``COLD_ACCOUNT_ACCESS`` (3,000) surcharge when the beneficiary is + cold (a warm beneficiary adds nothing — SELFDESTRUCT has no + ``WARM_ACCESS`` surcharge); +- a net-new ``ACCOUNT_WRITE`` (8,000) when a positive balance is sent to + an empty (or non-existent) beneficiary, replacing the legacy combined + 25,000 regular account-creation cost. + +So ``regular = 5,000 + (3,000 if cold) + (8,000 if creating)``: 13,000 +warm / 16,000 cold when a new beneficiary is created, 5,000 warm / 8,000 +cold otherwise. + +The beneficiary account-creation charge ``GAS_NEW_ACCOUNT`` (183,600) is +the EIP-8037 *state* dimension (`charge_state_gas` in the spec), covered +in ``eip8037_state_creation_gas_cost_increase/test_state_gas_selfdestruct.py``. + +``SELFDESTRUCT`` halts the frame, so it is driven via a wrapping ``CALL`` +and verified through block ``gas_used`` accounting and balances. Per +EIP-6780, a contract not created in the same transaction is not deleted, +but its balance is still transferred and the beneficiary creation charge +still applies. + +The framework opcode-gas model splits the two dimensions for +``SELFDESTRUCT`` exactly as the spec does: ``ACCOUNT_WRITE`` is charged +as regular gas and ``GAS_NEW_ACCOUNT`` as state gas, so +``Op.SELFDESTRUCT(account_new=True).regular_cost(fork)`` is the regular +charge (16,000 cold / 13,000 warm) and ``.state_cost(fork)`` is +``GAS_NEW_ACCOUNT``. These tests assert the regular dimension and verify +account-creation via balances; the state dimension is owned by +``eip8037_state_creation_gas_cost_increase/test_state_gas_selfdestruct.py``. +""" + +import pytest +from execution_testing import ( + AccessList, + Account, + Address, + Alloc, + Bytecode, + Environment, + Fork, + Header, + Op, + StateTestFiller, + Storage, + Transaction, + TransactionLog, + TransactionReceipt, + compute_create_address, +) +from execution_testing.checklists import EIPChecklist + +from ..eip7708_eth_transfer_logs.spec import transfer_log +from .spec import ref_spec_8038 + +REFERENCE_SPEC_GIT_PATH = ref_spec_8038.git_path +REFERENCE_SPEC_VERSION = ref_spec_8038.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + + +def _selfdestruct_regular(fork: Fork, *, warm: bool, account_new: bool) -> int: + """ + Return the EIP-8038 *regular* gas charged by SELFDESTRUCT. + + ``OPCODE_SELFDESTRUCT_BASE + access + (ACCOUNT_WRITE if account_new)``; + the ``GAS_NEW_ACCOUNT`` account-creation cost is the EIP-8037 state + dimension and is excluded from ``regular_cost``. + """ + gas_costs = fork.gas_costs() + regular = Op.SELFDESTRUCT( + address_warm=warm, account_new=account_new + ).regular_cost(fork) + # SELFDESTRUCT charges a cold-access surcharge only; a warm + # beneficiary adds nothing beyond the base (no WARM_ACCESS). + access = 0 if warm else gas_costs.COLD_ACCOUNT_ACCESS + expected = ( + gas_costs.OPCODE_SELFDESTRUCT_BASE + + access + + (gas_costs.ACCOUNT_WRITE if account_new else 0) + ) + assert regular == expected + return regular + + +def _destructor_code( + beneficiary: Address | Bytecode, *, warm: bool, account_new: bool +) -> Bytecode: + """ + Build SELFDESTRUCT bytecode with metadata so ``regular_cost(fork)`` + folds the beneficiary PUSH and the correct access/account-write + charge (account-creation state gas excluded — it is charged + separately by the spec). + """ + return Op.SELFDESTRUCT.with_metadata( + address_warm=warm, account_new=account_new + )(beneficiary) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize("warm", [False, True], ids=["cold", "warm"]) +def test_selfdestruct_new_beneficiary_regular_gas( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + warm: bool, +) -> None: + """ + SELFDESTRUCT to an empty beneficiary with balance charges + ACCOUNT_WRITE. + + The destructor has a non-zero balance and targets an empty, + non-existent beneficiary, so the net-new ``ACCOUNT_WRITE`` applies: + ``regular = 5,000 + access + 8,000`` (13,000 warm, 16,000 cold). The + creation gas ``GAS_NEW_ACCOUNT`` is charged on the state axis (the + EIP-8037 suite asserts it); here it is funded from the reservoir and + the value transfer to the new beneficiary confirms the path. + """ + gas_costs = fork.gas_costs() + new_account_state_gas = gas_costs.NEW_ACCOUNT + + regular = _selfdestruct_regular(fork, warm=warm, account_new=True) + assert regular == (13_000 if warm else 16_000) + + beneficiary = Address(0xDEAD) # empty, non-existent + + destructor_code = Op.SELFDESTRUCT(beneficiary) + destructor = pre.deploy_contract(code=destructor_code, balance=1) + + storage = Storage() + caller_code = Op.SSTORE( + storage.store_next(1, "call_succeeds"), + Op.CALL(gas=Op.GAS, address=destructor), + ) + caller = pre.deploy_contract(code=caller_code) + + tx = Transaction( + to=caller, + sender=pre.fund_eoa(), + access_list=[AccessList(address=beneficiary, storage_keys=[])] + if warm + else None, + state_gas_reservoir=new_account_state_gas, + ) + + state_test( + pre=pre, + post={ + caller: Account(storage=storage), + # New beneficiary created and credited the destructor balance. + beneficiary: Account(balance=1), + }, + tx=tx, + ) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize("warm", [False, True], ids=["cold", "warm"]) +def test_selfdestruct_alive_beneficiary_no_account_write( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + warm: bool, +) -> None: + """ + SELFDESTRUCT to an already-alive beneficiary charges no ACCOUNT_WRITE. + + The beneficiary already exists, so no account is created: regular = + ``5,000 + (3,000 if cold)`` (5,000 warm, 8,000 cold) and no state gas is + charged. The block header reflects the pure regular consumption. + """ + regular = _selfdestruct_regular(fork, warm=warm, account_new=False) + assert regular == (5_000 if warm else 8_000) + + beneficiary = pre.fund_eoa(amount=1) # alive + + destructor_code = _destructor_code( + beneficiary, warm=warm, account_new=False + ) + destructor = pre.deploy_contract(code=destructor_code, balance=1) + + caller_code = Op.POP(Op.CALL(gas=Op.GAS, address=destructor)) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + access_list = ( + [AccessList(address=beneficiary, storage_keys=[])] if warm else None + ) + # Intrinsic must include the access-list cost that warms the + # beneficiary; pass the list so the calculator folds it in. + intrinsic = fork.transaction_intrinsic_cost_calculator()( + access_list=access_list + ) + + # Pure regular: intrinsic + caller frame + destructor frame (whose + # regular_cost folds the SELFDESTRUCT charge and beneficiary PUSH). + expected_gas_used = ( + intrinsic + + caller_code.gas_cost(fork) + + destructor_code.regular_cost(fork) + ) + + tx = Transaction( + to=caller, + sender=pre.fund_eoa(), + access_list=access_list, + state_gas_reservoir=0, + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_gas_used, + ), + ) + + state_test( + pre=pre, + # EIP-6780: the pre-deployed destructor is not same-tx-created, + # so it is not deleted; its balance still transfers. + post={ + destructor: Account(balance=0, code=destructor_code), + beneficiary: Account(balance=2), + }, + tx=tx, + ) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize("warm", [False, True], ids=["cold", "warm"]) +def test_selfdestruct_codebearing_zero_balance_beneficiary_no_account_write( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + warm: bool, +) -> None: + """ + SELFDESTRUCT to a code-bearing zero-balance beneficiary: no + ACCOUNT_WRITE. + + The beneficiary is alive because it has code, not balance: it holds a + zero balance but a non-empty code (``Op.STOP``), so EIP-161 emptiness + does not apply and no account is created when a positive balance is + sent to it. Regular = ``5,000 + (3,000 if cold)`` (5,000 warm, 8,000 + cold) with no ACCOUNT_WRITE and no state gas — distinct from the + alive-via-balance case, which exercises the same path through a + different liveness source. + """ + regular = _selfdestruct_regular(fork, warm=warm, account_new=False) + assert regular == (5_000 if warm else 8_000) + + # Alive via code (non-empty code), with zero balance. + beneficiary = pre.deploy_contract(code=Op.STOP, balance=0) + + destructor_code = _destructor_code( + beneficiary, warm=warm, account_new=False + ) + destructor = pre.deploy_contract(code=destructor_code, balance=1) + + caller_code = Op.POP(Op.CALL(gas=Op.GAS, address=destructor)) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + access_list = ( + [AccessList(address=beneficiary, storage_keys=[])] if warm else None + ) + # Intrinsic must include the access-list cost that warms the + # beneficiary; pass the list so the calculator folds it in. + intrinsic = fork.transaction_intrinsic_cost_calculator()( + access_list=access_list + ) + + # Pure regular: intrinsic + caller frame + destructor frame (whose + # regular_cost folds the SELFDESTRUCT charge and beneficiary PUSH). + expected_gas_used = ( + intrinsic + + caller_code.gas_cost(fork) + + destructor_code.regular_cost(fork) + ) + + tx = Transaction( + to=caller, + sender=pre.fund_eoa(), + access_list=access_list, + state_gas_reservoir=0, + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_gas_used + ), + ) + + state_test( + pre=pre, + # EIP-6780: the pre-deployed destructor is not same-tx-created, + # so it is not deleted; its balance still transfers. + post={ + destructor: Account(balance=0, code=destructor_code), + # Code-bearing beneficiary credited the destructor balance. + beneficiary: Account(balance=1, code=Op.STOP), + }, + tx=tx, + ) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize("warm", [False, True], ids=["cold", "warm"]) +def test_selfdestruct_zero_balance_no_account_write( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + warm: bool, +) -> None: + """ + SELFDESTRUCT with a zero-balance destructor charges no ACCOUNT_WRITE. + + No value is transferred, so even a non-existent beneficiary is not + created: regular = ``5,000 + access`` and no state gas is charged. + """ + regular = _selfdestruct_regular(fork, warm=warm, account_new=False) + assert regular == (5_000 if warm else 8_000) + + beneficiary = Address(0xDEAD) # non-existent, but no value sent + + destructor_code = _destructor_code( + beneficiary, warm=warm, account_new=False + ) + destructor = pre.deploy_contract(code=destructor_code, balance=0) + + caller_code = Op.POP(Op.CALL(gas=Op.GAS, address=destructor)) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + access_list = ( + [AccessList(address=beneficiary, storage_keys=[])] if warm else None + ) + intrinsic = fork.transaction_intrinsic_cost_calculator()( + access_list=access_list + ) + + expected_gas_used = ( + intrinsic + + caller_code.gas_cost(fork) + + destructor_code.regular_cost(fork) + ) + + tx = Transaction( + to=caller, + sender=pre.fund_eoa(), + access_list=access_list, + state_gas_reservoir=0, + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_gas_used + ), + ) + + state_test( + pre=pre, + post={ + destructor: Account(balance=0, code=destructor_code), + beneficiary: Account.NONEXISTENT, + }, + tx=tx, + ) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize( + "beneficiary_kind", + [ + pytest.param("self", id="self_beneficiary"), + pytest.param("precompile", id="precompile_beneficiary"), + ], +) +def test_selfdestruct_self_or_precompile_beneficiary( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + beneficiary_kind: str, +) -> None: + """ + SELFDESTRUCT to self or a precompile is warm and charges no + ACCOUNT_WRITE. + + The executing account is in the accessed set on entry (self), and + precompiles are pre-warmed from the start, so neither pays a cold + surcharge: regular = ``5,000`` (warm base, no ``WARM_ACCESS``) with no + state gas. + + The destructor balance is chosen so no account creation occurs: self + is alive (sending to itself never creates), and the precompile case + sends zero value (precompiles hold no state entry, so a value + transfer would otherwise create one and charge ``GAS_NEW_ACCOUNT`` on + the state axis). + """ + gas_costs = fork.gas_costs() + + regular = _selfdestruct_regular(fork, warm=True, account_new=False) + # SELFDESTRUCT has no warm-access surcharge: warm == base only. + assert regular == gas_costs.OPCODE_SELFDESTRUCT_BASE + + if beneficiary_kind == "self": + # Self is warm on entry; the PUSH is `ADDRESS` (BASE=2). A + # non-zero balance is transferred to self (no creation). + destructor_code = Op.SELFDESTRUCT.with_metadata(address_warm=True)( + Op.ADDRESS + ) + balance = 1 + else: + # Identity precompile (address 4) is pre-warmed. Zero balance so + # no value transfer and thus no account creation. + destructor_code = Op.SELFDESTRUCT.with_metadata(address_warm=True)( + Address(4) + ) + balance = 0 + destructor = pre.deploy_contract(code=destructor_code, balance=balance) + + caller_code = Op.POP(Op.CALL(gas=Op.GAS, address=destructor)) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + intrinsic = fork.transaction_intrinsic_cost_calculator()() + + expected_gas_used = ( + intrinsic + + caller_code.gas_cost(fork) + + destructor_code.regular_cost(fork) + ) + + tx = Transaction( + to=caller, + sender=pre.fund_eoa(), + state_gas_reservoir=0, + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_gas_used + ), + ) + + # EIP-6780: the pre-deployed destructor is not deleted. The self case + # keeps its balance (transferred to itself); the precompile case sent + # nothing. + post = {destructor: Account(balance=balance, code=destructor_code)} + + state_test( + pre=pre, + post=post, + tx=tx, + ) + + +@EIPChecklist.GasCostChanges.Test.OutOfGas() +@pytest.mark.parametrize( + "sufficient_gas", [True, False], ids=["sufficient", "insufficient"] +) +def test_selfdestruct_oog_boundary( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + sufficient_gas: bool, +) -> None: + """ + Drive a cold SELFDESTRUCT that funds a new account at its exact total + gas and one short. + + The destructor sends value to an empty beneficiary, charging + ``5,000 + COLD_ACCOUNT_ACCESS + ACCOUNT_WRITE`` (16,000) in regular gas + and ``GAS_NEW_ACCOUNT`` in state gas. The child CALL frame has no state + reservoir of its own, so the state gas spills into the forwarded + regular gas and the frame needs its full ``gas_cost`` total. Forwarding + exactly that total lets the SELFDESTRUCT succeed (CALL returns 1); one + gas short OOGs (CALL returns 0) before the value transfer, so the + beneficiary is never created. + """ + gas_costs = fork.gas_costs() + + beneficiary = Address(0xDEAD) + regular = _selfdestruct_regular(fork, warm=False, account_new=True) + assert regular == ( + gas_costs.OPCODE_SELFDESTRUCT_BASE + + gas_costs.COLD_ACCOUNT_ACCESS + + gas_costs.ACCOUNT_WRITE + ) + + destructor_code = _destructor_code( + beneficiary, warm=False, account_new=True + ) + destructor = pre.deploy_contract(code=destructor_code, balance=1) + + # The child CALL frame gets no state reservoir, so the NEW_ACCOUNT + # state gas spills into the forwarded regular gas: forward the full + # total. One gas short forces an out-of-gas before the value transfer. + forwarded = destructor_code.gas_cost(fork) + if not sufficient_gas: + forwarded -= 1 + + storage = Storage() + caller_code = Op.SSTORE( + storage.store_next(1 if sufficient_gas else 0, "sd_result"), + Op.CALL(gas=forwarded, address=destructor), + ) + caller = pre.deploy_contract(code=caller_code) + + tx = Transaction( + to=caller, + sender=pre.fund_eoa(), + state_gas_reservoir=0, + ) + + if sufficient_gas: + post: dict = { + caller: Account(storage=storage), + beneficiary: Account(balance=1), + } + else: + post = { + caller: Account(storage=storage), + beneficiary: Account.NONEXISTENT, + destructor: Account(balance=1), + } + + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.pre_alloc_mutable() +def test_same_tx_created_selfdestruct_self_burn( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + EIP-6780: a same-tx-created contract SELFDESTRUCTs to itself, charged + the warm base only. + + A creation transaction whose initcode SELFDESTRUCTs the new contract + to ITSELF: the originator is created in this transaction so it is + deleted, and because a same-tx-created contract holding balance is + alive, ``account_new`` is false for the self-beneficiary — + ``regular = 5,000`` (warm self, no ``ACCOUNT_WRITE``) and no + SELFDESTRUCT state gas. + + EIP-8246 removes the SELFDESTRUCT burn, so the self-send is a no-op: + the balance stays in the (otherwise emptied) originator and no log is + emitted. + + No net state gas is charged either way: the only state cost is the + intrinsic creation ``NEW_ACCOUNT``, but the pre-funded created target + is alive at message entry, so EIP-8037 refunds it (the create-tx + ``created_target_alive`` refund). The block ``gas_used`` is therefore + the pure regular consumption regardless of the burn behavior. + """ + new_account_state_gas = fork.gas_costs().NEW_ACCOUNT + intrinsic_calc = fork.transaction_intrinsic_cost_calculator() + + amount = 1 + sender = pre.fund_eoa(amount=10**18) + created = compute_create_address(address=sender, nonce=0) + # Pre-fund the created address so its balance is present without an + # in-tx value transfer (which would emit its own Transfer log). The + # pre-funded target is alive at message entry, so the create-tx + # intrinsic NEW_ACCOUNT is refunded (EIP-8037). + pre.fund_address(created, amount) + + # Self is the executing account, warm on entry: no cold surcharge. + init_code = Op.SELFDESTRUCT.with_metadata(address_warm=True)(Op.ADDRESS) + + # Self-beneficiary on a balance-bearing same-tx-created contract is + # alive: account_new is false, so only the warm base is charged. + regular = _selfdestruct_regular(fork, warm=True, account_new=False) + assert regular == fork.gas_costs().OPCODE_SELFDESTRUCT_BASE + + intrinsic_total = intrinsic_calc( + calldata=bytes(init_code), contract_creation=True + ) + # The creation NEW_ACCOUNT is refunded (target alive at entry) and the + # self-burn adds no state gas, so net state gas is zero. + intrinsic_regular = intrinsic_total - new_account_state_gas + expected_regular = intrinsic_regular + init_code.regular_cost(fork) + expected_gas_used = expected_regular + + # EIP-8246 removes the SELFDESTRUCT burn: the self-send is a no-op, + # the balance stays in the (otherwise emptied) originator, and no + # log is emitted. + expected_logs: list[TransactionLog] = [] + created_post = Account(balance=amount, nonce=0, code=b"", storage={}) + + tx = Transaction( + to=None, + data=init_code, + sender=sender, + expected_receipt=TransactionReceipt( + logs=expected_logs, + cumulative_gas_used=expected_gas_used, + ), + ) + + state_test( + pre=pre, + post={created: created_post}, + tx=tx, + ) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.pre_alloc_mutable() +def test_same_tx_created_selfdestruct_to_fresh_beneficiary( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + EIP-6780: a same-tx-created contract sends value to a fresh + beneficiary, charged ``ACCOUNT_WRITE`` and creation state gas. + + A creation transaction whose initcode SELFDESTRUCTs the new contract + to a fresh ``Address(0xDEAD)``: the fresh, non-existent beneficiary + receives a positive balance, so ``account_new`` is true — + ``regular = 5,000 + COLD_ACCOUNT_ACCESS + ACCOUNT_WRITE`` (16,000 + cold) plus a beneficiary ``NEW_ACCOUNT`` on the state axis. The + beneficiary creation charge keys on the beneficiary, while the + originator (created in this transaction) is still deleted: a + ``Transfer`` log is emitted (not a ``Burn``). + + The net state gas is a single beneficiary ``NEW_ACCOUNT``: the + intrinsic creation ``NEW_ACCOUNT`` is refunded because the pre-funded + created target is alive at message entry (EIP-8037), while the fresh + beneficiary's ``NEW_ACCOUNT`` persists. + """ + new_account_state_gas = fork.gas_costs().NEW_ACCOUNT + intrinsic_calc = fork.transaction_intrinsic_cost_calculator() + + amount = 1 + beneficiary = Address(0xDEAD) # fresh, non-existent + sender = pre.fund_eoa(amount=10**18) + created = compute_create_address(address=sender, nonce=0) + # Pre-fund the created address so its balance is present without an + # in-tx value transfer (which would emit its own Transfer log). The + # pre-funded target is alive at message entry, so the create-tx + # intrinsic NEW_ACCOUNT is refunded (EIP-8037). + pre.fund_address(created, amount) + + # Cold beneficiary receiving value: account_new is true. + init_code = Op.SELFDESTRUCT.with_metadata( + address_warm=False, account_new=True + )(beneficiary) + + regular = _selfdestruct_regular(fork, warm=False, account_new=True) + assert regular == 16_000 + + intrinsic_total = intrinsic_calc( + calldata=bytes(init_code), contract_creation=True + ) + # The creation NEW_ACCOUNT is refunded (target alive at entry); only + # the fresh beneficiary's NEW_ACCOUNT remains as net state gas. + intrinsic_regular = intrinsic_total - new_account_state_gas + expected_state = new_account_state_gas + expected_regular = intrinsic_regular + init_code.regular_cost(fork) + expected_gas_used = max(expected_regular, expected_state) + + tx = Transaction( + to=None, + data=init_code, + sender=sender, + # Reservoir holds the beneficiary-creation state gas (above the + # creation's intrinsic NEW_ACCOUNT) so it does not spill into + # regular gas. + state_gas_reservoir=new_account_state_gas, + expected_receipt=TransactionReceipt( + logs=[transfer_log(created, beneficiary, amount)] + ), + ) + + state_test( + pre=pre, + # Same-tx-created originator is deleted; the fresh beneficiary is + # created and credited the originator balance. + post={ + created: Account.NONEXISTENT, + beneficiary: Account(balance=amount), + }, + tx=tx, + blockchain_test_header_verify=Header(gas_used=expected_gas_used), + ) diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_set_code_auth_gas.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_set_code_auth_gas.py new file mode 100644 index 00000000000..61cd928c2a5 --- /dev/null +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_set_code_auth_gas.py @@ -0,0 +1,609 @@ +""" +Tests for the EIP-7702 authorization *regular*-gas repricing under +[EIP-8038: State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038). + +EIP-8037 splits each EIP-7702 authorization into a *state* component +(refunded against the state-gas reservoir, covered by the sibling +``eip8037_state_creation_gas_cost_increase`` suite) and a *regular* +component. This module pins the **regular** per-authorization intrinsic +magnitude and the repriced cold/warm account-access costs that an +authorized delegation incurs when later accessed by a ``CALL``. + +The regular per-authorization magnitude is derived purely from fork +helpers as:: + + regular_per_auth = ( + fork.gas_costs().AUTH_PER_EMPTY_ACCOUNT + - fork.transaction_intrinsic_state_gas(authorization_count=1) + ) + +which on Amsterdam equals ``ACCOUNT_WRITE`` (``8000``) plus the EIP-7702 +regular auth base cost (``7816``), i.e. ``15816``. The state portion that +this subtracts off (``transaction_intrinsic_state_gas``) is exactly what +the EIP-8037 suite asserts on the state channel; this suite never +re-asserts it. +""" + +from typing import List + +import pytest +from execution_testing import ( + AccessList, + Account, + Address, + Alloc, + AuthorizationTuple, + Bytecode, + CodeGasMeasure, + Environment, + Fork, + Op, + StateTestFiller, + Storage, + Transaction, + TransactionException, + TransactionReceipt, +) +from execution_testing.checklists import EIPChecklist + +from ...prague.eip7702_set_code_tx.spec import Spec as Spec7702 +from .spec import ref_spec_8038 + +REFERENCE_SPEC_GIT_PATH = ref_spec_8038.git_path +REFERENCE_SPEC_VERSION = ref_spec_8038.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + + +def _regular_per_auth(fork: Fork) -> int: + """ + Return the EIP-8038 *regular* intrinsic gas charged per EIP-7702 + authorization, i.e. the total per-auth intrinsic less the EIP-8037 + state portion. + """ + return fork.gas_costs().AUTH_PER_EMPTY_ACCOUNT - ( + fork.transaction_intrinsic_state_gas(authorization_count=1) + ) + + +def _regular_intrinsic( + fork: Fork, + *, + n: int, + access_list: List[AccessList] | None = None, + calldata: bytes = b"", +) -> int: + """ + Return the regular (non-state) intrinsic gas of a set-code + transaction: the full intrinsic less the authorization state gas. + """ + total = fork.transaction_intrinsic_cost_calculator()( + authorization_list_or_count=n, + access_list=access_list, + calldata=calldata, + return_cost_deducted_prior_execution=True, + ) + return total - fork.transaction_intrinsic_state_gas( + authorization_count=n, + ) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize("n", [1, 2, 3]) +@pytest.mark.parametrize( + "authority_exists", + [ + pytest.param(False, id="new_authority"), + pytest.param(True, id="existing_authority"), + ], +) +@pytest.mark.parametrize( + "authority_in_access_list", + [ + pytest.param(False, id="empty_access_list"), + pytest.param(True, id="access_list_contains_authority"), + ], +) +def test_auth_regular_intrinsic_magnitude( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + n: int, + authority_exists: bool, + authority_in_access_list: bool, +) -> None: + """ + Assert the EIP-8038 *regular* per-authorization intrinsic magnitude. + + The regular intrinsic above the ``n=0`` base must equal + ``n * regular_per_auth`` plus the access-list delta (derived from + the calculator itself so the calldata-floor contribution of the + access-list bytes is accounted for). The state portion is excluded + via ``transaction_intrinsic_state_gas`` and is left to the EIP-8037 + suite. + """ + contract = pre.deploy_contract(code=Op.STOP) + + signers = [ + pre.fund_eoa() if authority_exists else pre.fund_eoa(amount=0) + for _ in range(n) + ] + authorization_list = [ + AuthorizationTuple(address=contract, nonce=0, signer=signer) + for signer in signers + ] + + access_list: List[AccessList] | None = None + if authority_in_access_list: + access_list = [ + AccessList(address=signer, storage_keys=[]) for signer in signers + ] + + base_regular = _regular_intrinsic(fork, n=0) + regular = _regular_intrinsic(fork, n=n, access_list=access_list) + + # Access-list delta is derived from the calculator (it folds in the + # calldata-floor cost of the access-list bytes), never hardcoded. + access_list_delta = _regular_intrinsic( + fork, n=0, access_list=access_list + ) - _regular_intrinsic(fork, n=0) + + expected_per_auth = _regular_per_auth(fork) + assert regular - base_regular == n * expected_per_auth + access_list_delta + + sender = pre.fund_eoa() + tx = Transaction( + to=contract, + authorization_list=authorization_list, + access_list=access_list, + sender=sender, + ) + + post = { + signer: Account(code=Spec7702.delegation_designation(contract)) + for signer in signers + } + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.OutOfGas() +@pytest.mark.exception_test +@pytest.mark.parametrize("n", [1, 3]) +def test_auth_intrinsic_oog_boundary( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + n: int, +) -> None: + """ + Reject a set-code transaction one gas below the full intrinsic. + + ``gas_limit`` is set to ``full_intrinsic - 1`` (full intrinsic = + regular + auth state gas). Catches an implementation that omits the + repriced regular per-authorization cost from the intrinsic check. + """ + contract = pre.deploy_contract(code=Op.STOP) + authorization_list = [ + AuthorizationTuple(address=contract, nonce=0, signer=pre.fund_eoa()) + for _ in range(n) + ] + + full_intrinsic = fork.transaction_intrinsic_cost_calculator()( + authorization_list_or_count=authorization_list, + ) + + tx = Transaction( + to=contract, + gas_limit=full_intrinsic - 1, + authorization_list=authorization_list, + sender=pre.fund_eoa(), + error=TransactionException.INTRINSIC_GAS_TOO_LOW, + ) + + state_test(env=env, pre=pre, post={}, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize( + "invalidity", + [ + pytest.param("invalid_nonce", id="invalid_nonce"), + pytest.param("invalid_chain_id", id="invalid_chain_id"), + pytest.param("repeated_nonce", id="repeated_nonce"), + pytest.param("authority_is_contract", id="authority_is_contract"), + ], +) +@pytest.mark.pre_alloc_mutable +def test_invalid_auth_charged_intrinsic( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + invalidity: str, +) -> None: + """ + A skipped (invalid) authorization is still charged the full + intrinsic, and the invalid authority's account is left unchanged. + + Each invalidity kind (``INVALID_NONCE``, ``INVALID_CHAIN_ID``, + ``REPEATED_NONCE``, ``AUTHORITY_IS_CONTRACT``) makes the + authorization invalid during processing, so it is silently skipped, + but its regular + state intrinsic gas is still paid. The transaction + succeeds. + """ + contract = pre.deploy_contract(code=Op.STOP) + + # Build a (possibly multi-element) authorization list where the + # authority that *should* end up untouched is the invalid one. + authorization_list: List[AuthorizationTuple] = [] + + if invalidity == "invalid_nonce": + authority = pre.fund_eoa() + authorization_list.append( + AuthorizationTuple( + address=contract, + nonce=99, # wrong nonce -> skipped + signer=authority, + ) + ) + expected_code: bytes | Bytecode = b"" + elif invalidity == "invalid_chain_id": + authority = pre.fund_eoa() + authorization_list.append( + AuthorizationTuple( + address=contract, + nonce=0, + chain_id=9999, # wrong chain id -> skipped + signer=authority, + ) + ) + expected_code = b"" + elif invalidity == "repeated_nonce": + # First auth is valid and consumes nonce 0; the second reuses + # nonce 0 and is therefore skipped. The (single) signer ends up + # delegated by the first auth, so assert that delegation. + authority = pre.fund_eoa() + authorization_list.append( + AuthorizationTuple(address=contract, nonce=0, signer=authority) + ) + authorization_list.append( + AuthorizationTuple(address=contract, nonce=0, signer=authority) + ) + expected_code = Spec7702.delegation_designation(contract) + elif invalidity == "authority_is_contract": + # An authority that is already a (non-delegation) contract is an + # invalid authority; the authorization is skipped and the + # contract code is left intact. + authority = pre.fund_eoa(code=Op.STOP) + authorization_list.append( + AuthorizationTuple(address=contract, nonce=0, signer=authority) + ) + expected_code = Op.STOP + else: + raise ValueError(f"unknown invalidity: {invalidity!r}") + + # The full intrinsic (regular + state) is charged regardless of + # validity. Provide a comfortable gas limit and let the receipt + # accounting be verified by the framework; the key assertion is the + # untouched-authority post state. + full_intrinsic = fork.transaction_intrinsic_cost_calculator()( + authorization_list_or_count=authorization_list, + ) + assert full_intrinsic > 0 + + sender = pre.fund_eoa() + tx = Transaction( + to=contract, + authorization_list=authorization_list, + sender=sender, + ) + + post = {authority: Account(code=expected_code)} + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize( + "invalidity", + [ + pytest.param("invalid_nonce", id="invalid_nonce"), + pytest.param("invalid_chain_id", id="invalid_chain_id"), + pytest.param("repeated_nonce", id="repeated_nonce"), + pytest.param("authority_is_contract", id="authority_is_contract"), + ], +) +@pytest.mark.pre_alloc_mutable +def test_mixed_validity_multi_auth_receipt_gas( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + invalidity: str, +) -> None: + """ + Pin the exact receipt gas of a transaction carrying one valid and + one invalid authorization. + + Every authorization tuple, valid or invalid, is charged the full + regular + state per-authorization intrinsic. The valid + authorization whose authority leaf already exists refills + ``NEW_ACCOUNT`` on the state channel (uncapped, subtracted first) + and returns ``ACCOUNT_WRITE`` on the regular channel (one-fifth + capped). The invalid tuple is silently skipped during + ``set_delegation``, refilling the full per-auth state intrinsic and + returning its regular ``ACCOUNT_WRITE`` charge. + + The dual-channel accounting mirrors ``process_transaction`` and the + sibling ``test_set_code_auth_refunds`` module: the state refill is + subtracted from ``gas_before_regular_refund`` first and uncapped, + then the regular refund clamps to + ``min(k * ACCOUNT_WRITE, gas_before_regular_refund // 5)`` where + ``k`` is the number of authorizations that return the regular + account-write charge. With no EVM execution, + ``gas_before_regular_refund`` reduces to the full per-authorization + intrinsic less the state refill, and the exact result is asserted + via ``expected_receipt``. + + Each ``invalidity`` kind (``INVALID_NONCE``, ``INVALID_CHAIN_ID``, + ``REPEATED_NONCE``, ``AUTHORITY_IS_CONTRACT``) yields one valid and + one invalid tuple, so ``n = 2`` and ``k = 2`` uniformly and every + kind pins the same receipt gas. This is the numeric-receipt + companion to ``test_invalid_auth_charged_intrinsic`` (which asserts + only post state). + """ + gas_costs = fork.gas_costs() + account_write = gas_costs.ACCOUNT_WRITE + + delegate = pre.deploy_contract(code=Op.STOP) + + # The single refundable (valid, existing-leaf) authorization. + valid_signer = pre.fund_eoa() + valid_auth = AuthorizationTuple( + address=delegate, nonce=0, signer=valid_signer + ) + + # Build the authorization list: one valid tuple plus one invalid + # tuple of the requested kind. ``authority`` is the account that must + # end up untouched by the skipped (invalid) authorization. + authorization_list: List[AuthorizationTuple] + post: dict = { + valid_signer: Account( + code=Spec7702.delegation_designation(delegate), + ), + } + + if invalidity == "invalid_nonce": + authority = pre.fund_eoa() + authorization_list = [ + valid_auth, + AuthorizationTuple( + address=delegate, + nonce=99, # wrong nonce -> skipped + signer=authority, + ), + ] + post[authority] = Account(code=b"") + elif invalidity == "invalid_chain_id": + authority = pre.fund_eoa() + authorization_list = [ + valid_auth, + AuthorizationTuple( + address=delegate, + nonce=0, + chain_id=9999, # wrong chain id -> skipped + signer=authority, + ), + ] + post[authority] = Account(code=b"") + elif invalidity == "repeated_nonce": + # The valid tuple consumes the signer's nonce 0; a second tuple + # reusing nonce 0 on the same signer is skipped. The signer is + # the refundable authority, delegated by its first (valid) tuple. + authorization_list = [ + valid_auth, + AuthorizationTuple(address=delegate, nonce=0, signer=valid_signer), + ] + elif invalidity == "authority_is_contract": + # An authority that is already a (non-delegation) contract is an + # invalid authority; its authorization is skipped and the + # contract code is left intact. + authority = pre.fund_eoa(code=Op.STOP) + authorization_list = [ + valid_auth, + AuthorizationTuple(address=delegate, nonce=0, signer=authority), + ] + post[authority] = Account(code=Op.STOP) + else: + raise ValueError(f"unknown invalidity: {invalidity!r}") + + n = len(authorization_list) + regular_refundable = 2 + + total_intrinsic = fork.transaction_intrinsic_cost_calculator()( + authorization_list_or_count=n, + ) + intrinsic_state = fork.transaction_intrinsic_state_gas( + authorization_count=n, + ) + # The valid existing-leaf authorization refills NEW_ACCOUNT. The + # invalid skipped tuple refills the full per-auth state intrinsic. + # State refills are subtracted first and are not subject to the + # one-fifth cap. + state_refund = gas_costs.REFUND_AUTH_PER_EXISTING_ACCOUNT + ( + intrinsic_state // n + ) + + # No EVM execution (the target is a STOP), so the regular and state + # execution gas are both zero and ``gas_before_regular_refund`` + # reduces to the full per-auth intrinsic less the state refill. + gas_before_regular_refund = total_intrinsic - state_refund + regular_refund = min( + regular_refundable * account_write, + gas_before_regular_refund // fork.max_refund_quotient(), + ) + # The one-fifth cap is generous, so both ACCOUNT_WRITE refunds clear + # on the regular channel. + assert regular_refund == regular_refundable * account_write + cumulative_gas_used = gas_before_regular_refund - regular_refund + + tx = Transaction( + to=delegate, + state_gas_reservoir=intrinsic_state, + authorization_list=authorization_list, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=cumulative_gas_used, + ), + ) + + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize( + "self_sponsored", + [ + pytest.param(False, id="external_sponsor"), + pytest.param(True, id="self_sponsor"), + ], +) +@pytest.mark.parametrize( + "delegation_in_access_list", + [ + pytest.param(False, id="delegation_cold"), + pytest.param(True, id="delegation_warm"), + ], +) +def test_auth_account_warming( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + self_sponsored: bool, + delegation_in_access_list: bool, +) -> None: + """ + A later ``CALL`` to an authorized authority pays the repriced + cold/warm account-access costs, plus the delegation double-charge. + + The authority itself is warmed by the authorization (added to + ``accessed_addresses`` during validation), so the ``CALL`` access + to it is ``WARM_ACCESS``. Because the authority carries a delegation + designator, accessing it triggers a *second* access to the + delegation target: ``WARM_ACCESS`` if that target is in the access + list (or is the authority itself, for self-delegation), else + ``COLD_ACCOUNT_ACCESS``. When the sponsor is the authority, the + authority is already warm for the same reason. + + All costs are taken from ``fork.gas_costs()`` so the repricing is + asserted against the live schedule rather than hardcoded constants. + """ + gas_costs = fork.gas_costs() + cold = gas_costs.COLD_ACCOUNT_ACCESS + warm = gas_costs.WARM_ACCESS + + delegation_target = pre.deploy_contract(code=Op.STOP) + + if self_sponsored: + # Self-sponsored: the sender is the authority. fund_eoa with a + # delegation pre-installs the designator and sets nonce to 1. + sender = pre.fund_eoa(delegation=delegation_target) + authority: Address = sender + authorization_list = None + else: + sender = pre.fund_eoa() + authority = pre.fund_eoa() + authorization_list = [ + AuthorizationTuple( + address=delegation_target, + nonce=0, + signer=authority, + ) + ] + + access_list: List[AccessList] | None = None + if delegation_in_access_list: + access_list = [AccessList(address=delegation_target, storage_keys=[])] + + # Authority access: always warm (authorization or self-sponsor warms + # it). Delegation target double-charge: warm iff in the access list, + # else cold. + delegation_access = warm if delegation_in_access_list else cold + expected_cost = warm + delegation_access + + # Measure the cost of a single CALL to the authority. The CALL + # opcode leaves one stack item (success); the overhead is the PUSHes + # for its arguments. + overhead_cost = gas_costs.VERY_LOW * len(Op.CALL.kwargs) + storage = Storage() + callee_code = CodeGasMeasure( + code=Op.CALL(gas=0, address=authority), + overhead_cost=overhead_cost, + extra_stack_items=1, + sstore_key=storage.store_next(expected_cost), + ) + callee_address = pre.deploy_contract(callee_code) + + tx = Transaction( + to=callee_address, + authorization_list=authorization_list, + access_list=access_list, + sender=sender, + ) + + post = { + callee_address: Account(storage=storage), + authority: Account( + code=Spec7702.delegation_designation(delegation_target), + ), + } + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +def test_many_auths_block_limit( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, +) -> None: + """ + Pack many authorizations into a single transaction near the gas + limit cap and confirm it succeeds. + + The authorization count is sized from the per-authorization total + intrinsic (regular + state) and the transaction gas-limit cap, so it + automatically tracks the repriced cost. + """ + gas_limit_cap = fork.transaction_gas_limit_cap() + assert gas_limit_cap is not None + + per_auth_total = fork.gas_costs().AUTH_PER_EMPTY_ACCOUNT + base = fork.transaction_intrinsic_cost_calculator()( + authorization_list_or_count=0, + ) + # Leave headroom for the base intrinsic and a little slack. + num_auths = (gas_limit_cap - base) // per_auth_total + assert num_auths >= 2 + + contract = pre.deploy_contract(code=Op.STOP) + signers = [pre.fund_eoa() for _ in range(num_auths)] + authorization_list = [ + AuthorizationTuple(address=contract, nonce=0, signer=signer) + for signer in signers + ] + + sender = pre.fund_eoa() + tx = Transaction( + to=contract, + authorization_list=authorization_list, + sender=sender, + ) + + post = { + signer: Account(code=Spec7702.delegation_designation(contract)) + for signer in signers + } + state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_set_code_auth_refunds.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_set_code_auth_refunds.py new file mode 100644 index 00000000000..ba3dc5e5773 --- /dev/null +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_set_code_auth_refunds.py @@ -0,0 +1,242 @@ +""" +Tests for the EIP-7702 authorization *regular*-gas refund under +[EIP-8038: State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038). + +When an authority's account leaf already exists, ``set_delegation`` +refunds on two independent channels: + +* the **state** channel: ``StateGasCosts.NEW_ACCOUNT`` is refilled into + ``state_gas_reservoir`` / ``state_refund`` (and ``AUTH_BASE`` too when + the code slot already holds a delegation indicator). It is subtracted + from ``tx_state_gas`` *before* the regular refund is applied and is + **not** subject to the EIP-3529 one-fifth cap. This channel is the + subject of the EIP-8037 ``eip8037_state_creation_gas_cost_increase`` + suite. +* the **regular** channel: the worst-case ``GasCosts.ACCOUNT_WRITE`` + charged in the regular intrinsic is returned via the regular refund + counter, and **is** subject to the one-fifth cap. + +This module pins the *regular* ``ACCOUNT_WRITE`` refund. The dual-channel +accounting mirrors ``process_transaction``: + + gas_before_regular_refund = ( + intrinsic_regular + exec_regular + + intrinsic_state + exec_state + - state_refund # uncapped, subtracted first + ) + regular_refund = min( + n * ACCOUNT_WRITE, + gas_before_regular_refund // fork.max_refund_quotient(), + ) + cumulative_gas_used = gas_before_regular_refund - regular_refund + +Two regimes are exercised: + +* a non-clearing delegation on an existing leaf, padded with cold + SSTOREs so ``gas_before_regular_refund`` is large and the full + ``n * ACCOUNT_WRITE`` clears under the cap; and +* a *clearing* re-authorization of an existing-delegation authority, + where the state channel refunds the **full** per-auth state intrinsic + (``NEW_ACCOUNT + AUTH_BASE``). That collapses + ``gas_before_regular_refund`` to the regular intrinsic alone, so the + cap ``gas // 5`` becomes the binding term and the regular refund + clamps below ``ACCOUNT_WRITE``. +""" + +from typing import List + +import pytest +from execution_testing import ( + Account, + Alloc, + AuthorizationTuple, + Bytecode, + Environment, + Fork, + Op, + StateTestFiller, + Storage, + Transaction, + TransactionReceipt, +) +from execution_testing.checklists import EIPChecklist + +from ...prague.eip7702_set_code_tx.spec import Spec as Spec7702 +from .spec import ref_spec_8038 + +REFERENCE_SPEC_GIT_PATH = ref_spec_8038.git_path +REFERENCE_SPEC_VERSION = ref_spec_8038.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + + +def _sstore_state_per_op(fork: Fork) -> int: + """Return the state gas of one cold ``0 -> 1`` SSTORE.""" + return Op.SSTORE(new_value=1).state_cost(fork) + + +@EIPChecklist.GasRefundsChanges.Test.RefundCalculation() +@pytest.mark.parametrize("n", [1, 2]) +def test_existing_authority_regular_refund_visible( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + n: int, +) -> None: + """ + Pin the full regular ``ACCOUNT_WRITE`` refund for set-code + authorizations whose authority leaves already exist. + + Each authority is an existing funded EOA delegating to a fresh + contract, so ``set_delegation`` refunds ``NEW_ACCOUNT`` on the state + channel (uncapped) and ``ACCOUNT_WRITE`` on the regular channel + (capped). The execution is padded with ten cold ``0 -> 1`` SSTOREs + so ``gas_before_regular_refund`` is large and the one-fifth cap + exceeds ``n * ACCOUNT_WRITE``; the entire regular refund is visible + in the receipt. + + The state refill is subtracted first and is not capped; it belongs + to the EIP-8037 suite and is only used here to size the receipt. + """ + gas_costs = fork.gas_costs() + account_write = gas_costs.ACCOUNT_WRITE + # Existing leaf overwritten with a fresh (non-clearing) delegation + # indicator: only NEW_ACCOUNT is refilled on the state channel. + state_refund = gas_costs.REFUND_AUTH_PER_EXISTING_ACCOUNT * n + + total_intrinsic = fork.transaction_intrinsic_cost_calculator()( + authorization_list_or_count=n, + ) + intrinsic_state = fork.transaction_intrinsic_state_gas( + authorization_count=n, + ) + + num_sstores = 10 + storage = Storage() + code = Bytecode() + for _ in range(num_sstores): + code += Op.SSTORE(storage.store_next(1), 1) + code += Op.STOP + contract = pre.deploy_contract(code=code) + + exec_state = _sstore_state_per_op(fork) * num_sstores + # The deployed bytecode's combined cost minus its state portion is + # the regular execution gas (includes the PUSHes for SSTORE args). + exec_regular = code.gas_cost(fork) - exec_state + + delegate = pre.deploy_contract(code=Op.STOP) + signers = [pre.fund_eoa() for _ in range(n)] + authorization_list = [ + AuthorizationTuple(address=delegate, nonce=0, signer=signer) + for signer in signers + ] + + gas_before_regular_refund = ( + total_intrinsic + exec_regular + exec_state - state_refund + ) + regular_refund = min( + n * account_write, + gas_before_regular_refund // fork.max_refund_quotient(), + ) + assert regular_refund == n * account_write + cumulative_gas_used = gas_before_regular_refund - regular_refund + + tx = Transaction( + to=contract, + state_gas_reservoir=intrinsic_state + exec_state, + authorization_list=authorization_list, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=cumulative_gas_used, + ), + ) + + post: dict = {contract: Account(storage=storage)} + for signer in signers: + post[signer] = Account( + code=Spec7702.delegation_designation(delegate), + ) + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasRefundsChanges.Test.RefundCalculation() +@pytest.mark.parametrize("n", [1, 3]) +def test_clearing_delegation_regular_refund_capped( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + n: int, +) -> None: + """ + Clearing a delegation refunds the full per-auth state intrinsic on + the state channel, which drives the regular refund into the + one-fifth cap. + + Each authority already holds a delegation and re-authorizes to the + reset (zero) address, clearing its code. The leaf exists, so + ``ACCOUNT_WRITE`` is refunded on the regular channel; the code slot + held a delegation indicator and the new indicator is empty, so both + ``NEW_ACCOUNT`` and ``AUTH_BASE`` are refilled on the state channel. + Refunding the full per-auth state intrinsic collapses + ``gas_before_regular_refund`` to the regular intrinsic alone, so the + cap ``gas // 5`` is below ``n * ACCOUNT_WRITE`` and the regular + refund clamps to ``gas // 5`` (cap-saturated). No execution padding + is used, so the contrast with the full-refund test is purely the + refunded state magnitude. + """ + gas_costs = fork.gas_costs() + account_write = gas_costs.ACCOUNT_WRITE + + total_intrinsic = fork.transaction_intrinsic_cost_calculator()( + authorization_list_or_count=n, + ) + intrinsic_state = fork.transaction_intrinsic_state_gas( + authorization_count=n, + ) + # Clearing an existing delegation refills the full per-auth state + # intrinsic (NEW_ACCOUNT + AUTH_BASE) for every authorization. + state_refund = intrinsic_state + + contract = pre.deploy_contract(code=Op.STOP) + delegated_to = pre.deploy_contract(code=Op.STOP) + # Authorities that already delegate; fund_eoa(delegation=...) sets + # the authority nonce to 1, which is the expected auth nonce. + signers = [pre.fund_eoa(delegation=delegated_to) for _ in range(n)] + authorization_list: List[AuthorizationTuple] = [ + AuthorizationTuple( + address=Spec7702.RESET_DELEGATION_ADDRESS, + nonce=1, + signer=signer, + ) + for signer in signers + ] + + gas_before_regular_refund = total_intrinsic - state_refund + regular_refund = min( + n * account_write, + gas_before_regular_refund // fork.max_refund_quotient(), + ) + # The cap is the binding term: the refund clamps below ACCOUNT_WRITE. + assert regular_refund < n * account_write + assert regular_refund == gas_before_regular_refund // ( + fork.max_refund_quotient() + ) + cumulative_gas_used = gas_before_regular_refund - regular_refund + + tx = Transaction( + to=contract, + state_gas_reservoir=intrinsic_state, + authorization_list=authorization_list, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=cumulative_gas_used, + ), + ) + + post: dict = {} + for signer in signers: + # Delegation cleared back to empty code, nonce incremented. + post[signer] = Account(nonce=2, code=b"") + state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sload_gas.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sload_gas.py new file mode 100644 index 00000000000..26502836c7b --- /dev/null +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sload_gas.py @@ -0,0 +1,192 @@ +""" +Tests for [EIP-8038: State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038). + +Covers the EIP-8038 ``SLOAD`` repricing: a cold storage slot read costs +``COLD_STORAGE_ACCESS`` (3000) and a warm read costs ``WARM_SLOAD`` (100). +A slot is warmed either by listing it in the transaction access list or by +a prior in-frame access; warmth acquired inside a sub-call that REVERTs is +discarded, so a subsequent read in the outer frame is cold again. +""" + +import pytest +from execution_testing import ( + AccessList, + Account, + Alloc, + Bytecode, + CodeGasMeasure, + Environment, + Fork, + Op, + StateTestFiller, + Transaction, +) +from execution_testing.checklists import EIPChecklist + +from .spec import ref_spec_8038 + +REFERENCE_SPEC_GIT_PATH = ref_spec_8038.git_path +REFERENCE_SPEC_VERSION = ref_spec_8038.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + + +def _measure_sload(slot: int, fork: Fork) -> CodeGasMeasure: + """ + Build a ``CodeGasMeasure`` around a single ``SLOAD`` whose stored + result is the bare opcode cost (the PUSH wrapper is subtracted out). + + The runtime warmth of ``slot`` determines whether the measured value + lands at ``COLD_STORAGE_ACCESS`` or ``WARM_SLOAD``. + """ + measured_code = Op.SLOAD(slot) + # Subtract the SLOAD opcode's own cold cost so only the PUSH wrapper + # remains as overhead; the runtime access cost is what gets stored. + overhead_cost = measured_code.gas_cost(fork) - Op.SLOAD( + key_warm=False + ).gas_cost(fork) + return CodeGasMeasure( + code=measured_code, + overhead_cost=overhead_cost, + extra_stack_items=1, + ) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize("warm", [False, True], ids=["cold", "warm"]) +def test_sload_gas( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + warm: bool, +) -> None: + """ + Measure the gas of a ``SLOAD`` on a slot that is either cold or + pre-warmed via the transaction access list. + + A cold read must cost ``COLD_STORAGE_ACCESS`` (3000); a warm read + must cost ``WARM_SLOAD`` (100). + """ + slot = 0x42 + expected_gas = Op.SLOAD(key_warm=warm).gas_cost(fork) + + measure_address = pre.deploy_contract( + code=_measure_sload(slot, fork), + storage={slot: 1}, + ) + + # Warm the slot via the access list when required; the cold case + # leaves it unlisted so its first runtime read is cold. + access_list = ( + [AccessList(address=measure_address, storage_keys=[slot])] + if warm + else None + ) + tx = Transaction( + to=measure_address, + sender=pre.fund_eoa(), + access_list=access_list, + ) + + # Slot 0 holds the measured gas; the read slot keeps its value. + post = {measure_address: Account(storage={0: expected_gas, slot: 1})} + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +def test_sload_warm_after_prior_touch( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, +) -> None: + """ + A first ``SLOAD`` on a cold slot warms it; the second in-frame + ``SLOAD`` of the same slot is charged ``WARM_SLOAD`` (100). + + Slot 0 records the cold first read and slot 1 the warm second read. + """ + slot = 0x42 + cold_gas = Op.SLOAD(key_warm=False).gas_cost(fork) + warm_gas = Op.SLOAD(key_warm=True).gas_cost(fork) + + measured_code = Op.SLOAD(slot) + overhead_cost = measured_code.gas_cost(fork) - Op.SLOAD( + key_warm=False + ).gas_cost(fork) + + # First measure (slot 0): cold read. Second measure (slot 1): the + # same slot is now warm. + code = CodeGasMeasure( + code=measured_code, + overhead_cost=overhead_cost, + extra_stack_items=1, + sstore_key=0, + ) + CodeGasMeasure( + code=measured_code, + overhead_cost=overhead_cost, + extra_stack_items=1, + sstore_key=1, + ) + measure_address = pre.deploy_contract(code=code, storage={slot: 1}) + + tx = Transaction(to=measure_address, sender=pre.fund_eoa()) + + # Slots 0/1 hold the two measured reads; the read slot keeps its + # value. + post = { + measure_address: Account(storage={0: cold_gas, 1: warm_gas, slot: 1}) + } + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +def test_sload_warmth_reverts_on_subcall_revert( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, +) -> None: + """ + Warmth acquired inside a reverted sub-call does not persist. + + An inner contract ``SLOAD``s the slot via ``DELEGATECALL`` (so the + warmed ``(address, slot)`` pair belongs to the outer account) then + ``REVERT``s. Back in the outer frame, that same slot's first + ``SLOAD`` is cold again and is charged ``COLD_STORAGE_ACCESS`` + (3000), proving the warm-slot set is rolled back on revert. + """ + slot = 0x42 + cold_gas = Op.SLOAD(key_warm=False).gas_cost(fork) + + # Inner: read the slot (warming it in the delegating account's + # context) then revert. + inner = pre.deploy_contract( + code=Op.SLOAD(slot) + Op.REVERT(0, 0), + ) + + # Outer: DELEGATECALL inner (which reverts), then measure its own + # first SLOAD of the slot. DELEGATECALL keeps the outer account's + # storage context, so inner's read warms (outer, slot); the revert + # discards that warmth, making the measured read cold. + measured_code = Op.SLOAD(slot) + overhead_cost = measured_code.gas_cost(fork) - Op.SLOAD( + key_warm=False + ).gas_cost(fork) + + outer_code: Bytecode = Op.POP( + Op.DELEGATECALL(gas=100_000, address=inner) + ) + CodeGasMeasure( + code=measured_code, + overhead_cost=overhead_cost, + extra_stack_items=1, + ) + outer = pre.deploy_contract(code=outer_code, storage={slot: 1}) + + tx = Transaction(to=outer, sender=pre.fund_eoa()) + + # Slot 0 holds the measured (cold) read; the read slot keeps its + # value. + post = {outer: Account(storage={0: cold_gas, slot: 1})} + state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sstore_gas.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sstore_gas.py new file mode 100644 index 00000000000..e6ba6c912fe --- /dev/null +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sstore_gas.py @@ -0,0 +1,215 @@ +""" +Tests for [EIP-8038: State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038). + +Covers the EIP-8038 ``SSTORE`` *regular* (non-state) gas schedule. The +state-creation charge for a zero-to-nonzero write is owned by EIP-8037 +and is asserted separately; here every expectation is taken from the +``regular_cost`` dimension only. + +The regular ``SSTORE`` cost is the slot-access cost (``COLD_STORAGE_ACCESS`` +when the key is cold, else ``WARM_SLOAD``) plus, on the first change of the +slot in the transaction (``original == current != new``), the write cost +``STORAGE_WRITE`` (modeled as ``COLD_STORAGE_WRITE - COLD_STORAGE_ACCESS``). +""" + +import pytest +from execution_testing import ( + AccessList, + Account, + Alloc, + Bytecode, + CodeGasMeasure, + Fork, + Op, + StateTestFiller, + Transaction, +) +from execution_testing.checklists import EIPChecklist + +from .spec import ref_spec_8038 + +REFERENCE_SPEC_GIT_PATH = ref_spec_8038.git_path +REFERENCE_SPEC_VERSION = ref_spec_8038.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + + +# Each parameter: (key_warm, original, current, new). The id encodes the +# (original, current, new) triple, where ``0`` is the zero value and +# ``x``/``y``/``z`` are distinct non-zero values (1, 2, 3). The suffix marks +# the slot state at the measured write. A clean slot (current == original) +# is ``_cold`` or access-list ``_warm``; a dirty slot (current != original) +# is ``_dirty`` and has necessarily been warmed by the prior in-frame SSTORE. +SSTORE_ROWS = [ + pytest.param(False, 0, 0, 1, id="00x_cold"), + pytest.param(True, 0, 0, 1, id="00x_warm"), + pytest.param(True, 0, 1, 0, id="0x0_dirty"), + pytest.param(True, 1, 1, 0, id="xx0_warm"), + pytest.param(False, 1, 1, 2, id="xxy_cold"), + pytest.param(True, 1, 1, 2, id="xxy_warm"), + pytest.param(True, 1, 2, 3, id="xyz_dirty"), + pytest.param(True, 1, 2, 1, id="xyx_dirty"), + pytest.param(True, 1, 1, 1, id="xxx_warm"), + pytest.param(False, 1, 1, 1, id="xxx_cold"), +] + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize("key_warm,original,current,new", SSTORE_ROWS) +def test_sstore_regular_gas( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + key_warm: bool, + original: int, + current: int, + new: int, +) -> None: + """ + Measure the regular ``SSTORE`` gas for each EIP-8038 row and assert it. + + The final (measured) ``SSTORE`` is wrapped in ``CodeGasMeasure`` so the + executed regular cost is stored on-chain and asserted against + ``expected_regular`` (slot access plus write-on-first-change). The same + value is cross-checked against the framework opcode model's + ``regular_cost`` as a secondary guard. The state-gas dimension is owned + by EIP-8037 and funded from the reservoir, so it is excluded here. + """ + # Move the data off slot 0 so ``CodeGasMeasure`` can store the measured + # cost in slot 0. The bare (operand-free) opcode carries the metadata so + # the measure overhead resolves to just the two operand PUSHes, and + # ``regular_cost``/``gas_cost`` are exact. + data_slot = 0x42 + result_slot = 0 + measured_bare = Op.SSTORE.with_metadata( + key_warm=key_warm, + original_value=original, + current_value=current, + new_value=new, + ) + measured = measured_bare(data_slot, new) + + # Cross-check the oracle agrees with the hand-derived formula. + expected_regular = measured_bare.regular_cost(fork) + + # Reach ``current`` from ``original`` with an unmeasured prep SSTORE when + # they differ, then measure the write to ``new``. The slot is warmed for + # ``key_warm`` rows via the access list (and, where current != original, + # the prep SSTORE warms it too); cold rows have neither, so the measured + # write is cold. + code = Bytecode() + if current != original: + code += Op.SSTORE(data_slot, current) + code += CodeGasMeasure( + code=measured, + overhead_cost=measured.gas_cost(fork) - measured_bare.gas_cost(fork), + extra_stack_items=0, + sstore_key=result_slot, + ) + + contract = pre.deploy_contract( + code=code, + storage={data_slot: original} if original != 0 else {}, + ) + + # Warm the slot for ``key_warm`` rows that have no prep to warm it; + # harmless for prep rows (warmth is set membership). Built after + # ``deploy_contract`` so the address exists. + access_list = ( + [AccessList(address=contract, storage_keys=[data_slot])] + if key_warm + else None + ) + + # State gas (owned by EIP-8037) is funded from the reservoir so it never + # disturbs the regular gas this test isolates. ``gas_limit`` is left + # unset so the reservoir lands above the EIP-7825 cap and ``Op.GAS`` + # measures regular gas only; an explicit gas_limit below the cap would + # zero the reservoir and spill state gas into the measurement. + single_set_state_gas = Op.SSTORE(new_value=1).state_cost(fork) + tx = Transaction( + to=contract, + sender=pre.fund_eoa(), + access_list=access_list, + state_gas_reservoir=2 * single_set_state_gas, + ) + + # result_slot holds the measured regular cost; data_slot holds ``new`` + # (absent when new == 0, because the slot is cleared). + expected_storage = {result_slot: expected_regular} + if new != 0: + expected_storage[data_slot] = new + post = {contract: Account(storage=expected_storage)} + state_test(pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +def test_sstore_cold_then_warm_same_slot( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + A first ``SSTORE`` on a cold slot warms it; the second in-frame + ``SSTORE`` of the same slot is charged only ``WARM_SLOAD`` (100). + + The slot starts non-zero (original 1) and is left unlisted, so the + first write is cold and is its first change (original == current != + new), costing ``COLD_STORAGE_ACCESS + STORAGE_WRITE`` (3000 + 10000). + That write warms the slot, so the second write -- which moves the slot + again without being a first change -- costs only ``WARM_SLOAD`` (100), + with no further ``STORAGE_WRITE``. Slot 0 records the cold first write + and slot 1 the warm second write; the data slot keeps its final value. + """ + data_slot = 0x42 + + # First write: cold, first change of a non-zero-original slot. The + # bare (operand-free) opcode carries the same metadata so that the + # CodeGasMeasure overhead resolves to just the two operand PUSHes. + first_bare = Op.SSTORE.with_metadata( + key_warm=False, + original_value=1, + current_value=1, + new_value=2, + ) + first = first_bare(data_slot, 2) + # Second write: same slot, now warm; not a first change, so the + # write cost is not re-charged and only the warm access applies. + second_bare = Op.SSTORE.with_metadata( + key_warm=True, + original_value=1, + current_value=2, + new_value=3, + ) + second = second_bare(data_slot, 3) + + expected_first = first.regular_cost(fork) - 2 * fork.gas_costs().VERY_LOW + expected_second = second.regular_cost(fork) - 2 * fork.gas_costs().VERY_LOW + + # Each measured write stores its own runtime cost; the overhead + # subtraction strips the two operand PUSHes so the stored value is the + # bare SSTORE cost. The second write finds the slot warm. + code = CodeGasMeasure( + code=first, + overhead_cost=first.gas_cost(fork) - first_bare.gas_cost(fork), + extra_stack_items=0, + sstore_key=0, + ) + CodeGasMeasure( + code=second, + overhead_cost=second.gas_cost(fork) - second_bare.gas_cost(fork), + extra_stack_items=0, + sstore_key=1, + ) + + contract = pre.deploy_contract(code=code, storage={data_slot: 1}) + + tx = Transaction(to=contract, sender=pre.fund_eoa()) + + # Slots 0/1 hold the two measured writes; the data slot ends at its + # final written value. + post = { + contract: Account( + storage={0: expected_first, 1: expected_second, data_slot: 3} + ) + } + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sstore_refunds.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sstore_refunds.py new file mode 100644 index 00000000000..7becbe2bb22 --- /dev/null +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sstore_refunds.py @@ -0,0 +1,349 @@ +""" +Tests for [EIP-8038: State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038). + +The headline mechanism of the pinned spec version ``a8862ae`` is the +``SSTORE`` clear-refund *reversal*: ``refund_counter`` is decremented by +``REFUND_STORAGE_CLEAR`` when a slot's original value is non-zero, its +current value is zero and the new value is non-zero (a slot cleared +earlier in the same transaction is restored). The spec reverses the clear +refund "so that clearing and then restoring a slot within the same +transaction is never net-profitable", closing the ``x -> 0 -> x`` round +trip; this reversal is exercised by +``test_sstore_clear_then_reset_nets_zero``. + +This module covers the EIP-8038 *regular* ``SSTORE`` refund schedule via +the transaction receipt's ``cumulative_gas_used``: + +* Clearing a slot whose original value is non-zero grants + ``REFUND_STORAGE_CLEAR`` (12480) to ``refund_counter`` (no EIP-8037 + state refund, since no state was created). +* Clearing then re-setting the same non-zero-original slot nets a zero + refund: the clear grant is reversed (``refund -= REFUND_STORAGE_CLEAR``) + exactly when ``original != 0 and current == 0`` and a non-zero value is + written back. +* Restoring a non-zero-original slot to its original value refunds the + write cost ``STORAGE_WRITE`` (10000). +* The applied refund is capped at ``gas_used // 5`` (EIP-3529 quotient). + +All refunds use a non-zero original so the state-creation refund owned by +EIP-8037 is never involved; only the EIP-8038 regular dimension is +exercised. +""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Bytecode, + Fork, + Op, + StateTestFiller, + Transaction, + TransactionReceipt, +) +from execution_testing.checklists import EIPChecklist + +from .spec import ref_spec_8038 + +REFERENCE_SPEC_GIT_PATH = ref_spec_8038.git_path +REFERENCE_SPEC_VERSION = ref_spec_8038.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + + +def _cumulative_gas_used(code: Bytecode, fork: Fork) -> int: + """ + Return the receipt ``cumulative_gas_used`` for a single transaction + whose execution is exactly ``code``. + + Mirrors the spec: gross gas is intrinsic plus the regular and state + gas of the code; the applied refund is ``min(gross // 5, refund)`` + (EIP-3529 quotient cap); the receipt reports gross minus the applied + refund. + """ + intrinsic = fork.transaction_intrinsic_cost_calculator()( + return_cost_deducted_prior_execution=True + ) + gross = intrinsic + code.regular_cost(fork) + code.state_cost(fork) + applied_refund = min(gross // 5, code.refund(fork)) + return gross - applied_refund + + +@EIPChecklist.GasRefundsChanges.Test.RefundCalculation() +@EIPChecklist.GasRefundsChanges.Test.RefundCalculation.Under() +def test_sstore_clear_grants_refund( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Clearing a non-zero-original slot grants ``REFUND_STORAGE_CLEAR``. + + Enough unrelated gas is burned so the EIP-3529 quotient cap + (``gas_used // 5``) does not bind, letting the full 12480 refund be + observed in ``cumulative_gas_used``. The non-zero original means no + EIP-8037 state refund participates. + """ + gas_costs = fork.gas_costs() + refund_clear = gas_costs.REFUND_STORAGE_CLEAR + + clear = Op.SSTORE.with_metadata( + key_warm=False, + original_value=1, + current_value=1, + new_value=0, + )(0, 0) + # Burn cheap gas (JUMPDEST = 1 gas, no stack effect) so that + # gas_used // 5 exceeds the refund and the full grant applies. + burn = Op.JUMPDEST * 60_000 + code = clear + burn + + contract = pre.deploy_contract(code=code, storage={0: 1}) + + # Sanity: the slot's refund counter accrues exactly one clear grant. + assert code.refund(fork) == refund_clear + expected_cumulative = _cumulative_gas_used(code, fork) + # The cap must not bind here, so the full grant is visible. + intrinsic = fork.transaction_intrinsic_cost_calculator()( + return_cost_deducted_prior_execution=True + ) + gross = intrinsic + code.regular_cost(fork) + assert gross // 5 > refund_clear + assert expected_cumulative == gross - refund_clear + + tx = Transaction( + to=contract, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_cumulative + ), + ) + + post = {contract: Account(storage={0: 0})} + state_test(pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasRefundsChanges.Test.RefundCalculation() +def test_sstore_clear_then_reset_nets_zero( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Clearing then re-setting a non-zero-original slot nets zero refund. + + The clear grants ``REFUND_STORAGE_CLEAR``; re-setting the slot to a + non-zero value reverses it. ``refund_counter`` ends at zero, so + ``cumulative_gas_used`` equals the gross gas with no refund applied. + """ + code = Op.SSTORE.with_metadata( + key_warm=False, + original_value=1, + current_value=1, + new_value=0, + )(0, 0) + Op.SSTORE.with_metadata( + key_warm=True, + original_value=1, + current_value=0, + new_value=2, + )(0, 2) + + contract = pre.deploy_contract(code=code, storage={0: 1}) + + # The grant and its reversal cancel exactly. + assert code.refund(fork) == 0 + expected_cumulative = _cumulative_gas_used(code, fork) + + tx = Transaction( + to=contract, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_cumulative + ), + ) + + post = {contract: Account(storage={0: 2})} + state_test(pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasRefundsChanges.Test.RefundCalculation() +@EIPChecklist.GasRefundsChanges.Test.RefundCalculation.Under() +def test_sstore_restore_nonzero_refunds_write( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Restoring a non-zero-original slot refunds the write cost. + + The slot is changed (charging ``STORAGE_WRITE``) then restored to its + original non-zero value, refunding ``STORAGE_WRITE`` (10000). Gas is + burned so the quotient cap does not bind and the full refund is + observable. + """ + gas_costs = fork.gas_costs() + storage_write = ( + gas_costs.COLD_STORAGE_WRITE - gas_costs.COLD_STORAGE_ACCESS + ) + + code = Op.SSTORE.with_metadata( + key_warm=False, + original_value=1, + current_value=1, + new_value=2, + )(0, 2) + Op.SSTORE.with_metadata( + key_warm=True, + original_value=1, + current_value=2, + new_value=1, + )(0, 1) + burn = Op.JUMPDEST * 60_000 + code += burn + + contract = pre.deploy_contract(code=code, storage={0: 1}) + + assert code.refund(fork) == storage_write + expected_cumulative = _cumulative_gas_used(code, fork) + intrinsic = fork.transaction_intrinsic_cost_calculator()( + return_cost_deducted_prior_execution=True + ) + gross = intrinsic + code.regular_cost(fork) + assert gross // 5 > storage_write + assert expected_cumulative == gross - storage_write + + tx = Transaction( + to=contract, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_cumulative + ), + ) + + post = {contract: Account(storage={0: 1})} + state_test(pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasRefundsChanges.Test.RefundCalculation() +@EIPChecklist.GasRefundsChanges.Test.RefundCalculation.Exact() +@pytest.mark.parametrize("num_clears", [1, 8, 32]) +def test_sstore_refund_quotient_cap( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + num_clears: int, +) -> None: + """ + The applied refund saturates at the EIP-3529 quotient cap. + + ``num_clears`` distinct non-zero-original slots are each cleared, + accruing ``num_clears * REFUND_STORAGE_CLEAR`` into ``refund_counter``. + A single clear's gross gas is small enough that ``gas_used // 5`` is + always below the accrued refund, so the applied refund is the cap and + ``cumulative_gas_used`` reflects ``min(gas_used // 5, accrued)``. + """ + gas_costs = fork.gas_costs() + accrued = num_clears * gas_costs.REFUND_STORAGE_CLEAR + + code = Bytecode() + for slot in range(num_clears): + code += Op.SSTORE.with_metadata( + key_warm=False, + original_value=1, + current_value=1, + new_value=0, + )(slot, 0) + + contract = pre.deploy_contract( + code=code, + storage=dict.fromkeys(range(num_clears), 1), + ) + + assert code.refund(fork) == accrued + intrinsic = fork.transaction_intrinsic_cost_calculator()( + return_cost_deducted_prior_execution=True + ) + gross = intrinsic + code.regular_cost(fork) + # The cap binds for every parametrization (single-clear gross is far + # below 5x a clear refund). + cap = gross // 5 + assert cap < accrued + applied_refund = min(cap, accrued) + expected_cumulative = gross - applied_refund + + tx = Transaction( + to=contract, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_cumulative + ), + ) + + post = {contract: Account(storage=dict.fromkeys(range(num_clears), 0))} + state_test(pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasRefundsChanges.Test.RefundCalculation() +@EIPChecklist.GasRefundsChanges.Test.RefundCalculation.Exact() +def test_sstore_refund_cap_exact_equality( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + The applied refund equals the EIP-3529 cap at exact equality. + + A single non-zero-original clear accrues ``REFUND_STORAGE_CLEAR``. + Cheap ``JUMPDEST`` gas (1 each) is burned so the gross gas lands at + exactly ``max_refund_quotient * accrued``; the quotient cap + ``gross // max_refund_quotient`` then equals the accrued refund + *exactly*, the boundary between the cap binding and not binding. The + full refund applies and ``cumulative_gas_used`` is ``gross - accrued``. + """ + gas_costs = fork.gas_costs() + quotient = fork.max_refund_quotient() + accrued = gas_costs.REFUND_STORAGE_CLEAR + + clear = Op.SSTORE.with_metadata( + key_warm=False, + original_value=1, + current_value=1, + new_value=0, + )(0, 0) + + intrinsic = fork.transaction_intrinsic_cost_calculator()( + return_cost_deducted_prior_execution=True + ) + # Target the exact boundary: gross == quotient * accrued, so that + # gross // quotient == accrued with no slack. Solve for the JUMPDEST + # count from the remaining gas after intrinsic and the clear's + # regular cost; each JUMPDEST costs exactly 1 gas. + jumpdest_gas = Op.JUMPDEST.gas_cost(fork) + target_gross = quotient * accrued + base_gross = intrinsic + clear.regular_cost(fork) + burn_gas = target_gross - base_gross + num_jumpdest, remainder = divmod(burn_gas, jumpdest_gas) + # An exact integer JUMPDEST count must reach the boundary; otherwise + # the equality below would not hold and the test would (correctly) + # fail rather than silently approximate. + assert remainder == 0 + + code = clear + Op.JUMPDEST * num_jumpdest + contract = pre.deploy_contract(code=code, storage={0: 1}) + + assert code.refund(fork) == accrued + gross = intrinsic + code.regular_cost(fork) + code.state_cost(fork) + # Exact equality: the cap is neither under nor over the accrued refund. + assert gross == target_gross + assert gross // quotient == accrued + expected_cumulative = gross - accrued + + tx = Transaction( + to=contract, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_cumulative + ), + ) + + post = {contract: Account(storage={0: 0})} + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_transient_storage_regression.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_transient_storage_regression.py new file mode 100644 index 00000000000..fc79ee51299 --- /dev/null +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_transient_storage_regression.py @@ -0,0 +1,87 @@ +""" +Tests for [EIP-8038: State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038). + +Regression guard: EIP-8038 reprices persistent storage and account +access but must NOT touch transient storage. ``TLOAD`` and ``TSTORE`` +remain at their EIP-1153 cost of ``OPCODE_TLOAD`` / ``OPCODE_TSTORE`` +(100 each), unchanged by the persistent-storage repricing and distinct +from the (repriced) persistent ``COLD_STORAGE_WRITE``. +""" + +import pytest +from execution_testing import ( + Account, + Alloc, + CodeGasMeasure, + Environment, + Fork, + Op, + StateTestFiller, + Transaction, +) +from execution_testing.checklists import EIPChecklist + +from .spec import ref_spec_8038 + +REFERENCE_SPEC_GIT_PATH = ref_spec_8038.git_path +REFERENCE_SPEC_VERSION = ref_spec_8038.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +def test_transient_storage_gas_unchanged( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, +) -> None: + """ + Measure ``TLOAD`` and ``TSTORE`` gas and confirm EIP-8038 left them + at the transient-storage price of 100 each. + + The bare-opcode costs (excluding their PUSH wrappers) must equal + ``OPCODE_TLOAD`` / ``OPCODE_TSTORE``. The guard + ``OPCODE_TSTORE != COLD_STORAGE_WRITE`` ensures the persistent + write repricing did not bleed into transient storage. + """ + gas_costs = fork.gas_costs() + very_low = gas_costs.VERY_LOW + + # Bare opcode costs: subtract the PUSH wrapper from each. + tload_bare = Op.TLOAD(0).gas_cost(fork) - 1 * very_low + tstore_bare = Op.TSTORE(0, 1).gas_cost(fork) - 2 * very_low + + assert tload_bare == gas_costs.OPCODE_TLOAD == 100 + assert tstore_bare == gas_costs.OPCODE_TSTORE == 100 + # Guard against over-eager repricing: transient write must not have + # been folded into the (repriced) persistent cold write cost. + assert gas_costs.OPCODE_TSTORE != gas_costs.COLD_STORAGE_WRITE + + # Measure TSTORE then TLOAD of the same transient slot in one frame. + tstore_code = CodeGasMeasure( + code=Op.TSTORE(0, 1), + overhead_cost=2 * very_low, + extra_stack_items=0, + sstore_key=0, + ) + tload_code = CodeGasMeasure( + code=Op.TLOAD(0), + overhead_cost=1 * very_low, + extra_stack_items=1, + sstore_key=1, + ) + contract = pre.deploy_contract(code=tstore_code + tload_code) + + tx = Transaction(to=contract, sender=pre.fund_eoa()) + + # Slot 0: measured TSTORE cost. Slot 1: measured TLOAD cost. + post = { + contract: Account( + storage={ + 0: gas_costs.OPCODE_TSTORE, + 1: gas_costs.OPCODE_TLOAD, + } + ) + } + state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/berlin/eip2930_access_list/test_acl.py b/tests/berlin/eip2930_access_list/test_acl.py index 226f842bc6c..9e7299994e6 100644 --- a/tests/berlin/eip2930_access_list/test_acl.py +++ b/tests/berlin/eip2930_access_list/test_acl.py @@ -235,6 +235,7 @@ def test_transaction_intrinsic_gas_cost( calldata=tx_data, contract_creation=contract_creation, access_list=access_lists, + sends_value=True, ) if not enough_gas: tx_gas_limit -= 1 diff --git a/tests/cancun/eip1153_tstore/test_tstorage_create_contexts.py b/tests/cancun/eip1153_tstore/test_tstorage_create_contexts.py index bd69ce23086..ed426e0be87 100644 --- a/tests/cancun/eip1153_tstore/test_tstorage_create_contexts.py +++ b/tests/cancun/eip1153_tstore/test_tstorage_create_contexts.py @@ -6,7 +6,6 @@ import pytest from execution_testing import ( - AccessList, Account, Address, Alloc, @@ -280,24 +279,33 @@ def test_tstore_rollback_on_failed_create( https://github.com/ethereum/execution-specs/issues/917 Initcode does TLOAD(1) to compute a return size, then does - TSTORE(1, 0x6000), then returns data of the computed size. - When TLOAD(1) is 0, the return size is 0x600a (exceeds max code - size 0x6000), so creation fails. + TSTORE(1, max_code_size), then returns data of the computed size. + When TLOAD(1) is 0, the return size exceeds the max code size, so + creation fails. The caller invokes CREATE/CREATE2 twice with the same initcode. If TSTORE from the first (failed) creation is properly rolled - back, the second creation also sees TLOAD(1)==0 and fails the - same way. If not rolled back, TLOAD(1)==0x6000 and the second - creation succeeds. + back, the second CREATE2 also sees TLOAD(1)==0 and fails the same + way, so nothing is deployed. If it were not rolled back, the + second CREATE2 would see TLOAD(1)==max_code_size, return a small + valid contract and succeed; the post-state therefore asserts the + target address is non-existent. + + The create results are not recorded with SSTORE: a failed create + burns its full 63/64 gas forward, and under the EIP-8037 + transaction gas-limit cap two of them in sequence leave too little + regular gas to write the result. Asserting account non-existence + checks the same rollback property without that write. """ # Initcode: - # return_size = 0x600a - TLOAD(1) - # TSTORE(1, 0x6000) + # return_size = (max_code_size + 0x0A) - TLOAD(1) + # TSTORE(1, max_code_size) # RETURN(offset=0, size=return_size) # - # TLOAD(1)==0: return_size = 0x600a > max code size -> fail - # TLOAD(1)==0x6000: return_size = 0x0a <= max code size -> succeed + # TLOAD(1)==0: return_size > max code size -> fail + # TLOAD(1)==max_code_size: return_size = 0x0A <= max -> succeed max_code_size = fork.max_code_size() + salt = 0 initcode = ( Op.TLOAD(1) @@ -310,36 +318,47 @@ def test_tstore_rollback_on_failed_create( initcode_bytes = bytes(initcode) initcode_len = len(initcode_bytes) + create_call = ( + create_opcode(0, 0, initcode_len, salt) + if create_opcode == Op.CREATE2 + else create_opcode(0, 0, initcode_len) + ) caller_code = ( Om.MSTORE(initcode_bytes, 0) - + Op.SSTORE( - 0, - create_opcode(0, 0, initcode_len, 0) - if create_opcode == Op.CREATE2 - else create_opcode(0, 0, initcode_len), - ) - + Op.SSTORE( - 1, - create_opcode(0, 0, initcode_len, 0) - if create_opcode == Op.CREATE2 - else create_opcode(0, 0, initcode_len), - ) + + create_call + + Op.POP + + create_call + + Op.POP ) - caller_address = pre.deploy_contract(caller_code, storage={0: 1, 1: 1}) + caller_address = pre.deploy_contract(caller_code) + + # CREATE2 targets one deterministic address (salt + initcode) for + # both attempts; CREATE targets nonce-derived addresses (the + # deployed caller starts at nonce 1). + if create_opcode == Op.CREATE2: + created_addresses = [ + compute_create_address( + address=caller_address, + salt=salt, + initcode=initcode, + opcode=Op.CREATE2, + ) + ] + else: + created_addresses = [ + compute_create_address( + address=caller_address, nonce=nonce, opcode=Op.CREATE + ) + for nonce in (1, 2) + ] sender = pre.fund_eoa() - tx = Transaction( - sender=sender, - to=caller_address, - access_list=[ - AccessList(address=caller_address, storage_keys=[0, 1]), - ], - ) + tx = Transaction(sender=sender, to=caller_address) - post = { - # Both creations fail because TSTORE is rolled back; - # initial storage {0: 1, 1: 1} is overwritten to zeros - caller_address: Account(storage={0: 0, 1: 0}), - } + # Both creations fail because TSTORE is rolled back, so nothing is + # deployed; the caller nonce still advances once per attempt. + post = {caller_address: Account(nonce=3)} + for created_address in created_addresses: + post[created_address] = Account.NONEXISTENT # type: ignore state_test(pre=pre, post=post, tx=tx) diff --git a/tests/cancun/eip4844_blobs/conftest.py b/tests/cancun/eip4844_blobs/conftest.py index 1504daa458f..6f93d162f88 100644 --- a/tests/cancun/eip4844_blobs/conftest.py +++ b/tests/cancun/eip4844_blobs/conftest.py @@ -9,6 +9,7 @@ Environment, Fork, Hash, + RecipientType, Transaction, TransitionFork, add_kzg_version, @@ -333,12 +334,20 @@ def non_zero_blob_gas_used_genesis_block( ] def create_blob_transaction(blob_range: Iterable[int]) -> Transaction: + intrinsic_gas = block_fork.transaction_intrinsic_cost_calculator()( + recipient_type=RecipientType.EMPTY_ACCOUNT, + sends_value=True, + ) + top_frame_gas = block_fork.transaction_top_frame_state_gas( + recipient_type=RecipientType.EMPTY_ACCOUNT, + sends_value=True, + ) return Transaction( ty=Spec.BLOB_TX_TYPE, sender=sender, to=empty_account_destination, value=1, - gas_limit=21_000, + gas_limit=intrinsic_gas + top_frame_gas, max_fee_per_gas=tx_max_fee_per_gas, max_priority_fee_per_gas=0, max_fee_per_blob_gas=blob_gas_price_calculator( diff --git a/tests/cancun/eip4844_blobs/test_blob_txs.py b/tests/cancun/eip4844_blobs/test_blob_txs.py index 50be25f9524..9b05cf607ef 100644 --- a/tests/cancun/eip4844_blobs/test_blob_txs.py +++ b/tests/cancun/eip4844_blobs/test_blob_txs.py @@ -33,6 +33,7 @@ Hash, Header, Op, + RecipientType, Removable, StateTestFiller, Storage, @@ -75,19 +76,93 @@ def destination_account( return pre.fund_eoa(destination_account_balance) +def _destination_recipient_type( + destination_account_code: Bytecode | None, + destination_account_balance: int, +) -> RecipientType: + if destination_account_code is not None: + return RecipientType.CONTRACT + if destination_account_balance == 0: + return RecipientType.EMPTY_ACCOUNT + return RecipientType.EOA + + @pytest.fixture def tx_gas( fork: Fork | TransitionFork, tx_calldata: bytes, tx_access_list: List[AccessList], + tx_value: int, + destination_account_code: Bytecode | None, + destination_account_balance: int, ) -> int: """Gas allocated to transactions sent during test.""" + post_transition_fork = fork.transitions_to() tx_intrinsic_cost_calculator = ( - fork.transitions_to().transaction_intrinsic_cost_calculator() + post_transition_fork.transaction_intrinsic_cost_calculator() + ) + recipient_type = _destination_recipient_type( + destination_account_code, destination_account_balance + ) + sends_value = tx_value > 0 + intrinsic = tx_intrinsic_cost_calculator( + calldata=tx_calldata, + access_list=tx_access_list, + recipient_type=recipient_type, + sends_value=sends_value, ) - return tx_intrinsic_cost_calculator( - calldata=tx_calldata, access_list=tx_access_list + top_frame_state = post_transition_fork.transaction_top_frame_state_gas( + recipient_type=recipient_type, + sends_value=sends_value, ) + return intrinsic + top_frame_state + + +@pytest.fixture +def tx_gas_per_tx( + fork: Fork | TransitionFork, + tx_gas: int, + tx_calldata: bytes, + tx_access_list: List[AccessList], + tx_value: int, + destination_account_code: Bytecode | None, + destination_account_balance: int, + blob_hashes_per_tx: List[List[bytes]], +) -> List[int]: + """ + Gas allocated to each transaction in the block. + + After the first value-sending tx to an initially-empty destination, + the recipient is no longer empty, so the EIP-2780 top-frame + ``NEW_ACCOUNT`` state-gas charge does not fire on subsequent txs. + """ + n_txs = len(blob_hashes_per_tx) + if n_txs <= 1: + return [tx_gas] * n_txs + + destination_starts_empty = ( + destination_account_code is None and destination_account_balance == 0 + ) + if destination_starts_empty and tx_value > 0: + post_transition_fork = fork.transitions_to() + intrinsic_calc = ( + post_transition_fork.transaction_intrinsic_cost_calculator() + ) + intrinsic = intrinsic_calc( + calldata=tx_calldata, + access_list=tx_access_list, + recipient_type=RecipientType.EOA, + sends_value=True, + ) + top_frame_state = post_transition_fork.transaction_top_frame_state_gas( + recipient_type=RecipientType.EOA, + sends_value=True, + ) + tx_gas_nonempty = intrinsic + top_frame_state + else: + tx_gas_nonempty = tx_gas + + return [tx_gas] + [tx_gas_nonempty] * (n_txs - 1) @pytest.fixture @@ -124,7 +199,7 @@ def blob_hashes_per_tx(blobs_per_tx: List[int]) -> List[List[Hash]]: @pytest.fixture def total_account_minimum_balance( # noqa: D103 blob_gas_per_blob: int, - tx_gas: int, + tx_gas_per_tx: List[int], tx_value: int, tx_max_fee_per_gas: int, tx_max_fee_per_blob_gas: int, @@ -135,15 +210,17 @@ def total_account_minimum_balance( # noqa: D103 transactions in the block of the test. """ minimum_cost = 0 - for tx_blob_count in [len(x) for x in blob_hashes_per_tx]: + for tx_i, tx_blob_count in enumerate(len(x) for x in blob_hashes_per_tx): blob_cost = tx_max_fee_per_blob_gas * blob_gas_per_blob * tx_blob_count - minimum_cost += (tx_gas * tx_max_fee_per_gas) + tx_value + blob_cost + minimum_cost += ( + (tx_gas_per_tx[tx_i] * tx_max_fee_per_gas) + tx_value + blob_cost + ) return minimum_cost @pytest.fixture def total_account_transactions_fee( # noqa: D103 - tx_gas: int, + tx_gas_per_tx: List[int], tx_value: int, blob_gas_price: int, block_base_fee_per_gas: int, @@ -156,7 +233,7 @@ def total_account_transactions_fee( # noqa: D103 Calculate actual fee for the blob transactions in the block of the test. """ total_cost = 0 - for tx_blob_count in [len(x) for x in blob_hashes_per_tx]: + for tx_i, tx_blob_count in enumerate(len(x) for x in blob_hashes_per_tx): blob_cost = blob_gas_price * blob_gas_per_blob * tx_blob_count block_producer_fee = ( tx_max_fee_per_gas - block_base_fee_per_gas @@ -164,7 +241,7 @@ def total_account_transactions_fee( # noqa: D103 else 0 ) total_cost += ( - (tx_gas * (block_base_fee_per_gas + block_producer_fee)) + tx_gas_per_tx[tx_i] * (block_base_fee_per_gas + block_producer_fee) + tx_value + blob_cost ) @@ -208,7 +285,7 @@ def sender(pre: Alloc, sender_initial_balance: int) -> Address: # noqa: D103 def txs( # noqa: D103 sender: EOA, destination_account: Optional[Address], - tx_gas: int, + tx_gas_per_tx: List[int], tx_value: int, tx_calldata: bytes, tx_max_fee_per_gas: int, @@ -225,7 +302,7 @@ def txs( # noqa: D103 sender=sender, to=destination_account, value=tx_value, - gas_limit=tx_gas, + gas_limit=tx_gas_per_tx[tx_i], data=tx_calldata, max_fee_per_gas=tx_max_fee_per_gas, max_priority_fee_per_gas=tx_max_priority_fee_per_gas, @@ -754,6 +831,7 @@ def test_sufficient_balance_blob_tx( @pytest.mark.valid_from("Cancun") def test_sufficient_balance_blob_tx_pre_fund_tx( blockchain_test: BlockchainTestFiller, + fork: Fork, total_account_minimum_balance: int, sender: EOA, env: Environment, @@ -773,15 +851,29 @@ def test_sufficient_balance_blob_tx_pre_fund_tx( - Transactions with max fee per blob gas lower or higher than the priority fee """ + recipient_type = ( + RecipientType.EOA if sender in pre else RecipientType.EMPTY_ACCOUNT + ) + sends_value = total_account_minimum_balance > 0 + intrinsic_calc = fork.transaction_intrinsic_cost_calculator() + intrinsic_gas = intrinsic_calc( + recipient_type=recipient_type, + sends_value=sends_value, + ) + top_frame_state_gas = fork.transaction_top_frame_state_gas( + recipient_type=recipient_type, + sends_value=sends_value, + ) + pre_funding_gas_limit = intrinsic_gas + top_frame_state_gas pre_funding_sender = pre.fund_eoa( - amount=(21_000 * 100) + total_account_minimum_balance + amount=(pre_funding_gas_limit * 100) + total_account_minimum_balance ) txs = [ Transaction( sender=pre_funding_sender, to=sender, value=total_account_minimum_balance, - gas_limit=21_000, + gas_limit=pre_funding_gas_limit, ) ] + txs blockchain_test( diff --git a/tests/paris/eip7610_create_collision/test_collision_selfdestruct.py b/tests/paris/eip7610_create_collision/test_collision_selfdestruct.py index a0f58bbbf18..4bb20365f3d 100644 --- a/tests/paris/eip7610_create_collision/test_collision_selfdestruct.py +++ b/tests/paris/eip7610_create_collision/test_collision_selfdestruct.py @@ -74,14 +74,12 @@ def test_selfdestruct_after_create2_collision( + Op.SSTORE( storage.store_next(1, "create2_call_success"), Op.CALL( - # Forwarded budget covers deployer's CREATE2 (charged - # then refunded on collision under EIP-8037) plus its - # SSTORE; both 0 pre-EIP-8037 and scale with cpsb. - gas=( - 500_000 - + fork.gas_costs().NEW_ACCOUNT - + Op.SSTORE(new_value=1).state_cost(fork) - ), + # The colliding CREATE2 consumes 63/64 of the deployer's + # gas (the account-creation state gas is charged then + # refunded on collision under EIP-8037); size the budget + # so the surviving 1/64 still covers the deployer's cold + # SSTORE of the CREATE2 result. + gas=500_000 + 64 * fork.gas_costs().COLD_STORAGE_WRITE, address=deployer, args_size=Op.CALLDATASIZE, ), diff --git a/tests/ported_static/stBadOpcode/test_measure_gas.py b/tests/ported_static/stBadOpcode/test_measure_gas.py index b262b96b127..f100b2b1ea8 100644 --- a/tests/ported_static/stBadOpcode/test_measure_gas.py +++ b/tests/ported_static/stBadOpcode/test_measure_gas.py @@ -3,6 +3,15 @@ Ported from: state_tests/stBadOpcode/measureGasFiller.yml + +@manually-enhanced: Do not overwrite. A binary search measures the gas +an opcode needs to succeed. Only the EXTCODE case shifts: it runs a +warm `EXTCODESIZE` plus a warm `EXTCODECOPY` (the target is warmed by +earlier search iterations), and EIP-8038 adds a flat +100 to each warm +extcode access. The stored threshold therefore grows by the sum of the +two opcodes' warm `(Amsterdam - Cancun)` cost deltas, derived from the +fork's own gas model so it is exactly 0 before EIP-8038; do not +hardcode the Amsterdam number. """ import pytest @@ -363,6 +372,26 @@ def test_measure_gas( address=Address(0x0000000000000000000000000000000000C0DEF2), # noqa: E501 ) + # The EXTCODE search measures a warm `EXTCODESIZE` plus a warm + # `EXTCODECOPY` (the target is warmed by earlier search iterations). + # EIP-8038 adds a flat surcharge to each warm extcode access, so the + # threshold grows by the two opcodes' combined warm cost delta versus + # Cancun. Derived from the fork gas model so it is 0 before EIP-8038. + # The EXTCODECOPY metadata mirrors the measured access: a 0x20-byte + # copy into already-expanded memory, so only the account-access + # component varies across forks. + warm_extcode_delta = ( + Op.EXTCODESIZE.with_metadata(address_warm=True).gas_cost(fork) - 100 + ) + ( + Op.EXTCODECOPY.with_metadata( + address_warm=True, + data_size=0x20, + new_memory_size=0x120, + old_memory_size=0x120, + ).gas_cost(fork) + - 103 + ) + expect_entries_: list[dict] = [ { "indexes": {"data": [0], "gas": -1, "value": -1}, @@ -397,7 +426,9 @@ def test_measure_gas( { "indexes": {"data": [10], "gas": -1, "value": -1}, "network": [">=Cancun"], - "result": {contract_12: Account(storage={0: 221})}, + "result": { + contract_12: Account(storage={0: 221 + warm_extcode_delta}) + }, }, { "indexes": {"data": [9], "gas": -1, "value": -1}, diff --git a/tests/ported_static/stBadOpcode/test_operation_diff_gas.py b/tests/ported_static/stBadOpcode/test_operation_diff_gas.py index 82a3e5c4cbd..be3bf8c0559 100644 --- a/tests/ported_static/stBadOpcode/test_operation_diff_gas.py +++ b/tests/ported_static/stBadOpcode/test_operation_diff_gas.py @@ -3,6 +3,16 @@ Ported from: state_tests/stBadOpcode/operationDiffGasFiller.yml + +@manually-enhanced: Do not overwrite. A search measures the gas an +opcode needs to succeed. Two access classes shift under EIP-8038: the +CALL-family probes (`CALL`/`CALLCODE`/`DELEGATECALL`/`STATICCALL`) make +one cold account access to the callee, repricing by +`COLD_ACCOUNT_ACCESS - 2600`; the EXTCODE probe runs a cold +`EXTCODESIZE` plus a warm `EXTCODECOPY`, each carrying the extra +extcode surcharge. Every delta is derived from the fork's own gas +model, so it is exactly 0 before EIP-8038 and tracks future parameter +changes; do not hardcode the Amsterdam numbers. """ import pytest @@ -358,6 +368,27 @@ def test_operation_diff_gas( address=Address(0x0000000000000000000000000000000000C0DEF2), # noqa: E501 ) + # The CALL-family probes make one cold account access to the callee; + # EIP-8038 reprices it by `COLD_ACCOUNT_ACCESS - 2600`. The EXTCODE + # probe runs a cold `EXTCODESIZE` plus a warm `EXTCODECOPY`, each + # carrying the extcode surcharge. Both deltas come from the fork gas + # model, so they are 0 before EIP-8038. The EXTCODECOPY metadata + # mirrors the measured access (a 0x20-byte copy into already-expanded + # memory) so only the account-access component varies across forks. + gas_costs = fork.gas_costs() + cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 + extcode_probe_delta = ( + Op.EXTCODESIZE.with_metadata(address_warm=False).gas_cost(fork) - 2600 + ) + ( + Op.EXTCODECOPY.with_metadata( + address_warm=True, + data_size=0x20, + new_memory_size=0x120, + old_memory_size=0x120, + ).gas_cost(fork) + - 103 + ) + expect_entries_: list[dict] = [ { "indexes": {"data": [0], "gas": -1, "value": -1}, @@ -372,7 +403,9 @@ def test_operation_diff_gas( { "indexes": {"data": [2, 3, 4, 5], "gas": -1, "value": -1}, "network": [">=Cancun"], - "result": {contract_12: Account(storage={0: 2700})}, + "result": { + contract_12: Account(storage={0: 2700 + cold_account_delta}) + }, }, { "indexes": {"data": [8, 6, 7], "gas": -1, "value": -1}, @@ -382,7 +415,9 @@ def test_operation_diff_gas( { "indexes": {"data": [10], "gas": -1, "value": -1}, "network": [">=Cancun"], - "result": {contract_12: Account(storage={0: 2800})}, + "result": { + contract_12: Account(storage={0: 2800 + extcode_probe_delta}) + }, }, { "indexes": {"data": [9], "gas": -1, "value": -1}, diff --git a/tests/ported_static/stCreate2/test_create2_oo_gafter_init_code.py b/tests/ported_static/stCreate2/test_create2_oo_gafter_init_code.py index fd230fb046f..08c8f8e8445 100644 --- a/tests/ported_static/stCreate2/test_create2_oo_gafter_init_code.py +++ b/tests/ported_static/stCreate2/test_create2_oo_gafter_init_code.py @@ -3,9 +3,15 @@ Ported from: state_tests/stCreate2/Create2OOGafterInitCodeFiller.json -@manually-enhanced: Do not overwrite. tx_gas[1] is tuned to barely -succeed CREATE2 on Cancun; on Amsterdam EIP-8037 the NEW_ACCOUNT -state-gas spills, so lift the budget by Fork.oog_budget_lift. +@manually-enhanced: Do not overwrite. The init code RETURNs a 5-byte +deployed contract; g0 must run out before the deposit (account stays +NONEXISTENT) and g1 must just clear it (account created). On Cancun +the deploy gap is the 1000-gas regular code deposit and the test's +two budgets straddle it. EIP-8037/8038 move account creation into a +spilling state-gas charge AND drop OPCODE_CREATE_BASE, so the budget +that reaches the same RETURN point changes by a fork-derived amount. +The lift restores the straddle: it is exactly 0 pre-EIP-8037 and +tracks the parameters. See `_oog_lift` below for the derivation. """ import pytest @@ -113,19 +119,47 @@ def test_create2_oo_gafter_init_code( tx_data = [ Bytes(""), ] - # Lift both entries on Amsterdam so the test still exercises its - # named scenario. With only tx_gas[1] lifted, g=0 OoG'd at CREATE2 - # dispatch (NEW_ACCOUNT state-gas spill) before init code ever ran — - # the assertion still passes (`NONEXISTENT` either way) but the - # failure mode is "dispatch-time OoG" instead of "OoG after init - # code". A simple `fork.oog_budget_lift(creates_before_oog=1)` (183600) - # is *too* generous and pushes g=0 past the deploy threshold; the - # Cancun 1000-gas gap between g=0 and g=1 collapses on Amsterdam - # because once dispatch is cleared, the 5-byte init code is cheap - # enough to always complete. The value below is the middle of the - # empirically-safe range (166499, 167000) where g=0 still OoGs at - # dispatch *and* g=1 just clears the deploy threshold (~221.5k). - _oog_lift = 166_750 if fork.is_eip_enabled(8037) else 0 + # The init code RETURNs a 5-byte deployed contract, so the CREATE2 + # frame is charged a code deposit after the init RETURN. On Cancun + # that deposit is 1000 (200 * 5 regular) and the two budgets below + # straddle it: g0 reaches RETURN just under 1000 gas (deploy fails, + # account NONEXISTENT) and g1 just over (deploy succeeds). The + # 1000-gas gap between the budgets is exactly this Cancun deploy + # threshold. + # + # EIP-8037/8038 change the CREATE2 dispatch in two ways that the + # budget must absorb before the init code RETURNs: the new + # `create_state_gas()` spills into regular gas (empty reservoir), + # and `OPCODE_CREATE_BASE` drops from its Cancun value of 32000. + # Their sum is the net extra the dispatch consumes from the budget. + # The deposit step then changes too: its regular `CODE_DEPOSIT_PER_BYTE + # * 5` portion is now covered by the state-gas reservoir credited at + # dispatch, while `code_deposit_state_gas(code_size=5)` spills and + # must come from the forwarded gas instead. The lift restores the + # Cancun straddle by funding the net dispatch consumption plus the + # deposit's state spill, minus the regular deposit the budgets + # already carried in their 1000-gas gap. Every term is 0 + # pre-EIP-8037, so the original Cancun behavior is preserved. + gas_costs = fork.gas_costs() + _cancun_create_base = 32000 + _deploy_size = 5 + _oog_lift = 0 + if fork.is_eip_enabled(8037): + _oog_lift = ( + fork.oog_budget_lift( + creates_before_oog=1, deploy_code_size=_deploy_size + ) + + (gas_costs.OPCODE_CREATE_BASE - _cancun_create_base) + - gas_costs.CODE_DEPOSIT_PER_BYTE * _deploy_size + ) + # EIP-2780 reshapes the tx intrinsic for non-self non-value txs: + # ``TX_BASE`` drops to 12_000 and an explicit + # ``COLD_ACCOUNT_ACCESS`` (3_000) recipient charge is added. The + # original test was built against Cancun's flat ``TX_BASE`` of + # 21_000, so shift the budget by the intrinsic delta to keep the + # straddle landing at the same RETURN point. + intrinsic = fork.transaction_intrinsic_cost_calculator()() + _oog_lift += intrinsic - 21_000 tx_gas = [54000 + _oog_lift, 55000 + _oog_lift] tx = Transaction( diff --git a/tests/ported_static/stCreate2/test_create2_smart_init_code.py b/tests/ported_static/stCreate2/test_create2_smart_init_code.py index 09b73134629..f63fd21214e 100644 --- a/tests/ported_static/stCreate2/test_create2_smart_init_code.py +++ b/tests/ported_static/stCreate2/test_create2_smart_init_code.py @@ -4,9 +4,16 @@ Ported from: state_tests/stCreate2/create2SmartInitCodeFiller.json -@manually-enhanced: Do not overwrite. tx_gas was raised from 400 000 to -1 000 000 so the CREATE2 path can afford its EIP-8037 NEW_ACCOUNT state -gas on Amsterdam (post-state expectations are unchanged on all forks). +@manually-enhanced: Do not overwrite. The d0 call chain performs two +value-bearing CREATE2s plus a SELFDESTRUCT to a non-alive beneficiary +and two fresh SSTORE-sets before it finishes; with an empty state-gas +reservoir every one of those state-gas charges spills into regular gas +on EIP-8037, overrunning the original 400 000 budget. Lift the budget +by exactly that spilled state gas via `fork.oog_budget_lift` (three +`create_state_gas()` charges -- two CREATE2 dispatches and the +SELFDESTRUCT account creation -- plus two fresh SSTORE-set state +costs), which is 0 pre-EIP-8037. Post-state expectations are unchanged +on all forks. """ import pytest @@ -173,11 +180,14 @@ def test_create2_smart_init_code( Hash(contract_0, left_padding=True), Hash(contract_1, left_padding=True), ] - # EIP-8037 NEW_ACCOUNT + per-byte state-gas spill into the regular - # budget on Amsterdam; pre-EIP-8037 forks keep the original 400 000. - outer_tx_gas = 400_000 - if fork.is_eip_enabled(8037): - outer_tx_gas = 1_000_000 + # The d0 chain spills three `create_state_gas()` charges (two + # CREATE2 dispatches and the SELFDESTRUCT to a non-alive + # beneficiary) and two fresh SSTORE-set state costs into regular + # gas when the reservoir is empty. Lift the original budget by + # exactly that spilled state gas; 0 pre-EIP-8037. + outer_tx_gas = 400_000 + fork.oog_budget_lift( + creates_before_oog=3, sstores_before_oog=2 + ) tx_gas = [outer_tx_gas] tx = Transaction( diff --git a/tests/ported_static/stCreate2/test_create2check_fields_in_initcode.py b/tests/ported_static/stCreate2/test_create2check_fields_in_initcode.py index da4c2cabd35..8ee471f590e 100644 --- a/tests/ported_static/stCreate2/test_create2check_fields_in_initcode.py +++ b/tests/ported_static/stCreate2/test_create2check_fields_in_initcode.py @@ -3,6 +3,8 @@ Ported from: state_tests/stCreate2/create2checkFieldsInInitcodeFiller.json +@manually-enhanced: Do not overwrite. The env `gas_limit` is omitted so +the framework default supplies ample gas for EIP-8037 state accounting. """ import pytest @@ -115,7 +117,6 @@ def test_create2check_fields_in_initcode( timestamp=1000, prev_randao=0x20000, base_fee_per_gas=10, - gas_limit=1000000, ) pre[sender] = Account(balance=0x56BC75E2D63100000) diff --git a/tests/ported_static/stCreateTest/test_create_address_warm_after_fail.py b/tests/ported_static/stCreateTest/test_create_address_warm_after_fail.py index 80c2ba100bc..358473d4fb0 100644 --- a/tests/ported_static/stCreateTest/test_create_address_warm_after_fail.py +++ b/tests/ported_static/stCreateTest/test_create_address_warm_after_fail.py @@ -9,6 +9,14 @@ Ported from: state_tests/stCreateTest/CreateAddressWarmAfterFailFiller.yml + +@manually-enhanced: Do not overwrite. The post-state records the +measured cost of accessing the create address after a failed CREATE, +which is a cold account access. EIP-8038 reprices a cold account +access from 2 600 to 3 000, so each such measurement gains 400 at +Amsterdam. Derive that delta from the fork's gas model so it is +exactly 0 pre-EIP-8037 and tracks parameter changes; do not hardcode +the Amsterdam value. """ import pytest @@ -381,6 +389,11 @@ def test_create_address_warm_after_fail( address=Address(0x00000000000000000000000000000000000C0DEC), # noqa: E501 ) + # The create address access after a failed CREATE is cold here; + # EIP-8038 reprices a cold account access from 2 600 to 3 000. + # Derive the delta from the fork so it is 0 pre-EIP-8037. + cold_account_delta = fork.gas_costs().COLD_ACCOUNT_ACCESS - 2600 + expect_entries_: list[dict] = [ { "indexes": {"data": [0, 2, 11, 4], "gas": -1, "value": [0]}, @@ -396,7 +409,7 @@ def test_create_address_warm_after_fail( 5: 1, 12: 328, 13: 316, - 14: 2828, + 14: 2828 + cold_account_delta, 15: 316, }, nonce=1, @@ -447,7 +460,7 @@ def test_create_address_warm_after_fail( 5: 1, 12: 328, 13: 316, - 14: 2828, + 14: 2828 + cold_account_delta, 15: 316, }, nonce=1, @@ -498,7 +511,7 @@ def test_create_address_warm_after_fail( 5: 1, 12: 328, 13: 316, - 14: 2828, + 14: 2828 + cold_account_delta, 15: 316, }, nonce=1, @@ -549,7 +562,7 @@ def test_create_address_warm_after_fail( 5: 1, 12: 328, 13: 316, - 14: 2828, + 14: 2828 + cold_account_delta, 15: 316, }, nonce=1, @@ -600,7 +613,7 @@ def test_create_address_warm_after_fail( 5: 1, 12: 328, 13: 316, - 14: 2828, + 14: 2828 + cold_account_delta, 15: 316, }, nonce=1, @@ -649,9 +662,9 @@ def test_create_address_warm_after_fail( 3: 1, 4: 1, 5: 1, - 12: 2828, + 12: 2828 + cold_account_delta, 13: 316, - 14: 2828, + 14: 2828 + cold_account_delta, 15: 316, }, nonce=0, @@ -703,9 +716,9 @@ def test_create_address_warm_after_fail( 3: 1, 4: 1, 5: 1, - 12: 2828, + 12: 2828 + cold_account_delta, 13: 316, - 14: 2828, + 14: 2828 + cold_account_delta, 15: 316, }, nonce=0, @@ -753,7 +766,7 @@ def test_create_address_warm_after_fail( 5: 1, 12: 328, 13: 316, - 14: 2828, + 14: 2828 + cold_account_delta, 15: 316, }, nonce=1, @@ -804,7 +817,7 @@ def test_create_address_warm_after_fail( 5: 1, 12: 328, 13: 316, - 14: 2828, + 14: 2828 + cold_account_delta, 15: 316, }, nonce=1, diff --git a/tests/ported_static/stCreateTest/test_create_oo_gafter_init_code.py b/tests/ported_static/stCreateTest/test_create_oo_gafter_init_code.py index 76b1fd09653..8fcff4f2bbd 100644 --- a/tests/ported_static/stCreateTest/test_create_oo_gafter_init_code.py +++ b/tests/ported_static/stCreateTest/test_create_oo_gafter_init_code.py @@ -3,9 +3,15 @@ Ported from: state_tests/stCreateTest/CreateOOGafterInitCodeFiller.json -@manually-enhanced: Do not overwrite. tx_gas[1] is tuned to barely -succeed CREATE on Cancun; on Amsterdam EIP-8037 the NEW_ACCOUNT -state-gas spills, so lift the budget by Fork.oog_budget_lift. +@manually-enhanced: Do not overwrite. The init code RETURNs a 5-byte +deployed contract; g0 must run out before the deposit (account stays +NONEXISTENT) and g1 must just clear it (account created). On Cancun +the deploy gap is the 1000-gas regular code deposit and the test's +two budgets straddle it. EIP-8037/8038 move account creation into a +spilling state-gas charge AND drop OPCODE_CREATE_BASE, so the budget +that reaches the same RETURN point changes by a fork-derived amount. +The lift restores the straddle: it is exactly 0 pre-EIP-8037 and +tracks the parameters. See `_oog_lift` below for the derivation. """ import pytest @@ -109,19 +115,47 @@ def test_create_oo_gafter_init_code( tx_data = [ Bytes(""), ] - # Lift both entries on Amsterdam so the test still exercises its - # named scenario. With only tx_gas[1] lifted, g=0 OoG'd at CREATE - # dispatch (NEW_ACCOUNT state-gas spill) before init code ever ran — - # the assertion still passes (`NONEXISTENT` either way) but the - # failure mode is "dispatch-time OoG" instead of "OoG after init - # code". A simple `fork.oog_budget_lift(creates_before_oog=1)` (183600) - # is *too* generous and pushes g=0 past the deploy threshold; the - # Cancun 1000-gas gap between g=0 and g=1 collapses on Amsterdam - # because once dispatch is cleared, the 5-byte init code is cheap - # enough to always complete. The value below is the middle of the - # empirically-safe range (166499, 167000) where g=0 still OoGs at - # dispatch *and* g=1 just clears the deploy threshold (~221.5k). - _oog_lift = 166_750 if fork.is_eip_enabled(8037) else 0 + # The init code RETURNs a 5-byte deployed contract, so the CREATE + # frame is charged a code deposit after the init RETURN. On Cancun + # that deposit is 1000 (CODE_DEPOSIT_PER_BYTE * 5 regular) and the + # two budgets below straddle it: g0 reaches RETURN just under the + # threshold (deploy fails, account NONEXISTENT) and g1 just over + # (deploy succeeds). The 1000-gas gap between the budgets is exactly + # this Cancun deploy threshold. + # + # EIP-8037/8038 change the CREATE dispatch in two ways that the + # budget must absorb before the init code RETURNs: the new + # `create_state_gas()` spills into regular gas (empty reservoir), + # and `OPCODE_CREATE_BASE` drops from its Cancun value of 32000. + # Their sum is the net extra the dispatch consumes from the budget. + # The deposit step then changes too: its regular `CODE_DEPOSIT_PER_BYTE + # * 5` portion is now covered by the state-gas reservoir credited at + # dispatch, while `code_deposit_state_gas(code_size=5)` spills and + # must come from the forwarded gas instead. The lift restores the + # Cancun straddle by funding the net dispatch consumption plus the + # deposit's state spill, minus the regular deposit the budgets + # already carried in their 1000-gas gap. Every term is 0 + # pre-EIP-8037, so the original Cancun behavior is preserved. + gas_costs = fork.gas_costs() + _cancun_create_base = 32000 + _deploy_size = 5 + _oog_lift = 0 + if fork.is_eip_enabled(8037): + _oog_lift = ( + fork.oog_budget_lift( + creates_before_oog=1, deploy_code_size=_deploy_size + ) + + (gas_costs.OPCODE_CREATE_BASE - _cancun_create_base) + - gas_costs.CODE_DEPOSIT_PER_BYTE * _deploy_size + ) + # EIP-2780 reshapes the tx intrinsic for non-self non-value txs: + # ``TX_BASE`` drops to 12_000 and an explicit + # ``COLD_ACCOUNT_ACCESS`` (3_000) recipient charge is added. The + # original test was built against Cancun's flat ``TX_BASE`` of + # 21_000, so shift the budget by the intrinsic delta to keep the + # straddle landing at the same RETURN point. + intrinsic = fork.transaction_intrinsic_cost_calculator()() + _oog_lift += intrinsic - 21_000 tx_gas = [54000 + _oog_lift, 55000 + _oog_lift] tx = Transaction( diff --git a/tests/ported_static/stEIP1153_transientStorage/test_14_revert_after_nested_staticcall.py b/tests/ported_static/stEIP1153_transientStorage/test_14_revert_after_nested_staticcall.py index 06ddaf015a0..739a986e972 100644 --- a/tests/ported_static/stEIP1153_transientStorage/test_14_revert_after_nested_staticcall.py +++ b/tests/ported_static/stEIP1153_transientStorage/test_14_revert_after_nested_staticcall.py @@ -3,6 +3,16 @@ Ported from: state_tests/Cancun/stEIP1153_transientStorage/14_revertAfterNestedStaticcallFiller.yml + +@manually-enhanced: Do not overwrite. The caller writes four fresh +storage slots and asserts the resulting values (slot 1's pre-marker +must be overwritten). EIP-8037/8038 spill each fresh SSTORE's +state-gas charge back into regular gas (the reservoir is empty), +pushing total consumption past the original 400 000 transaction +budget; the final SSTORE then OOGs and reverts the whole call, +leaving slot 1 at its marker. Bump the gas limit by the summed +fork-derived SSTORE increases so the success path stays funded; the +bump is exactly 0 before EIP-8037. """ import pytest @@ -15,6 +25,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -31,8 +42,30 @@ def test_14_revert_after_nested_staticcall( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Transient storage can't be manipulated from nested staticcall.""" + + # EIP-8037/8038 spill each fresh SSTORE's state-gas charge back into + # regular gas. The caller writes three fresh slots (0, 2, 3: each a + # cold zero -> nonzero set) and clears slot 1's cold marker; sum the + # per-slot increases so the original budget stays sufficient. Each + # term is exactly 0 before EIP-8037. + def _sstore_delta(cancun_cost: int, **metadata: int) -> int: + op = Op.SSTORE.with_metadata(**metadata) + return op.gas_cost(fork) - cancun_cost + + cold_set_delta = _sstore_delta( + 22100, key_warm=False, original_value=0, current_value=0, new_value=10 + ) + cold_clear_delta = _sstore_delta( + 5000, + key_warm=False, + original_value=65535, + current_value=65535, + new_value=0, + ) + gas_limit_bump = 3 * cold_set_delta + cold_clear_delta coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) sender = pre.fund_eoa(amount=0x3635C9ADC5DEA00000) @@ -136,7 +169,7 @@ def test_14_revert_after_nested_staticcall( sender=sender, to=target, data=Bytes("f5f40590"), - gas_limit=400000, + gas_limit=400000 + gas_limit_bump, max_fee_per_gas=2000, max_priority_fee_per_gas=0, access_list=[], diff --git a/tests/ported_static/stEIP150Specific/test_call_and_callcode_consume_more_gas_then_transaction_has.py b/tests/ported_static/stEIP150Specific/test_call_and_callcode_consume_more_gas_then_transaction_has.py index aa6ecf33493..1e588e4489e 100644 --- a/tests/ported_static/stEIP150Specific/test_call_and_callcode_consume_more_gas_then_transaction_has.py +++ b/tests/ported_static/stEIP150Specific/test_call_and_callcode_consume_more_gas_then_transaction_has.py @@ -3,6 +3,16 @@ Ported from: state_tests/stEIP150Specific/CallAndCallcodeConsumeMoreGasThenTransactionHasFiller.json + +@manually-enhanced: Do not overwrite. The post-state asserts +`storage[8] = 0x8D5B6` captured by `Op.GAS`, which depends on the exact +post-intrinsic execution budget. The original hardcoded `gas_limit` of +600_000 was built against Cancun's `TX_BASE` of 21_000; EIP-2780 lowers +the intrinsic for non-self non-value txs, so `gas_limit` is derived as +`600_000 + (intrinsic - 21_000)` from `transaction_intrinsic_cost_calculator` +to shift by the fork intrinsic delta and keep the Op.GAS assertion correct. +The `- 21_000` is the pre-EIP-2780 baseline intrinsic, so the adjustment +is exactly 0 pre-repricing. Do not hardcode the literal gas_limit. """ import pytest @@ -12,6 +22,7 @@ Alloc, Bytes, Environment, + Fork, StateTestFiller, Transaction, ) @@ -31,6 +42,7 @@ def test_call_and_callcode_consume_more_gas_then_transaction_has( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_call_and_callcode_consume_more_gas_then_transaction_has.""" coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) @@ -83,11 +95,19 @@ def test_call_and_callcode_consume_more_gas_then_transaction_has( nonce=0, ) + # The original test was built against Cancun's ``TX_BASE`` of + # 21_000. EIP-2780 lowers the intrinsic for non-self non-value + # txs, so shift ``gas_limit`` by the intrinsic delta to preserve + # the post-intrinsic execution budget the Op.GAS storage + # assertions depend on. + intrinsic = fork.transaction_intrinsic_cost_calculator()() + gas_limit = 600_000 + (intrinsic - 21_000) + tx = Transaction( sender=sender, to=target, data=Bytes(""), - gas_limit=600000, + gas_limit=gas_limit, ) post = {target: Account(storage={0: 18, 8: 0x8D5B6, 9: 1, 10: 1})} diff --git a/tests/ported_static/stEIP150Specific/test_call_goes_oog_on_second_level.py b/tests/ported_static/stEIP150Specific/test_call_goes_oog_on_second_level.py index ce3ec8f7f19..954c5e29ae7 100644 --- a/tests/ported_static/stEIP150Specific/test_call_goes_oog_on_second_level.py +++ b/tests/ported_static/stEIP150Specific/test_call_goes_oog_on_second_level.py @@ -3,6 +3,14 @@ Ported from: state_tests/stEIP150Specific/CallGoesOOGOnSecondLevelFiller.json + +@manually-enhanced: Do not overwrite. The `gas_limit` is derived from +the fork intrinsic calculator instead of the original hardcoded value. +The test fixes the post-intrinsic budget that the nested Op.GAS storage +assertions (8: 0x927BE, 8: 0x213FB6) depend on, so it shifts the base +2_200_000 budget by the intrinsic delta versus the pre-EIP-2780 Cancun +baseline of 21_000 (`intrinsic - 21_000`). This stays correct across +the EIP-2780 intrinsic decomposition. Do not hardcode the gas_limit. """ import pytest @@ -12,6 +20,7 @@ Alloc, Bytes, Environment, + Fork, StateTestFiller, Transaction, ) @@ -29,6 +38,7 @@ def test_call_goes_oog_on_second_level( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_call_goes_oog_on_second_level.""" coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) @@ -93,11 +103,19 @@ def test_call_goes_oog_on_second_level( nonce=0, ) + # The original test was built against Cancun's ``TX_BASE`` of + # 21_000. EIP-2780 lowers the intrinsic for non-self non-value + # txs, so shift ``gas_limit`` by the intrinsic delta to preserve + # the post-intrinsic execution budget the Op.GAS storage + # assertions depend on. + intrinsic = fork.transaction_intrinsic_cost_calculator()() + gas_limit = 2_200_000 + (intrinsic - 21_000) + tx = Transaction( sender=sender, to=target, data=Bytes(""), - gas_limit=2200000, + gas_limit=gas_limit, ) post = { diff --git a/tests/ported_static/stEIP150Specific/test_suicide_to_existing_contract.py b/tests/ported_static/stEIP150Specific/test_suicide_to_existing_contract.py index f8cea302098..15bacfb9c84 100644 --- a/tests/ported_static/stEIP150Specific/test_suicide_to_existing_contract.py +++ b/tests/ported_static/stEIP150Specific/test_suicide_to_existing_contract.py @@ -3,6 +3,13 @@ Ported from: state_tests/stEIP150Specific/SuicideToExistingContractFiller.json + +@manually-enhanced: Do not overwrite. The measured slot captures the +regular gas of a value-0 CALL to a cold contract that then +SELFDESTRUCTs back to its (warm, alive) caller. EIP-8038 reprices the +cold account access of that CALL; the beneficiary is warm so the +SELFDESTRUCT is unchanged. The delta is therefore the fork's +`COLD_ACCOUNT_ACCESS - 2600`, exactly 0 before EIP-8038. """ import pytest @@ -15,6 +22,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -29,8 +37,11 @@ def test_suicide_to_existing_contract( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_suicide_to_existing_contract.""" + # EIP-8038 cold account access reprice; 0 before EIP-8038. + cold_account_delta = fork.gas_costs().COLD_ACCOUNT_ACCESS - 2600 coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) sender = pre.fund_eoa(amount=0xE8D4A51000) @@ -90,7 +101,7 @@ def test_suicide_to_existing_contract( balance=0, nonce=0, ), - target: Account(storage={1: 7637}), + target: Account(storage={1: 7637 + cold_account_delta}), } state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stEIP150Specific/test_suicide_to_not_existing_contract.py b/tests/ported_static/stEIP150Specific/test_suicide_to_not_existing_contract.py index 5a3ff77c96f..6dfee984b84 100644 --- a/tests/ported_static/stEIP150Specific/test_suicide_to_not_existing_contract.py +++ b/tests/ported_static/stEIP150Specific/test_suicide_to_not_existing_contract.py @@ -3,6 +3,14 @@ Ported from: state_tests/stEIP150Specific/SuicideToNotExistingContractFiller.json + +@manually-enhanced: Do not overwrite. The measured slot captures the +regular gas of a value-0 CALL to a cold contract that then +SELFDESTRUCTs (with a zero balance) to a cold, non-alive beneficiary. +EIP-8038 reprices both the CALL's cold account access and the +SELFDESTRUCT beneficiary's cold access; no value is sent so there is +no new-account write. The delta is therefore twice the fork's +`COLD_ACCOUNT_ACCESS - 2600`, exactly 0 before EIP-8038. """ import pytest @@ -15,6 +23,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -29,8 +38,13 @@ def test_suicide_to_not_existing_contract( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_suicide_to_not_existing_contract.""" + # EIP-8038 cold account access reprice; 0 before EIP-8038. Charged + # twice: once for the CALL target, once for the cold SELFDESTRUCT + # beneficiary. + cold_account_delta = fork.gas_costs().COLD_ACCOUNT_ACCESS - 2600 coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) sender = pre.fund_eoa(amount=0xE8D4A51000) @@ -88,7 +102,7 @@ def test_suicide_to_not_existing_contract( balance=0, nonce=0, ), - target: Account(storage={1: 10237}), + target: Account(storage={1: 10237 + 2 * cold_account_delta}), } state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stEIP150singleCodeGasPrices/test_eip2929.py b/tests/ported_static/stEIP150singleCodeGasPrices/test_eip2929.py index c62a6e1859a..8cc16c0dc46 100644 --- a/tests/ported_static/stEIP150singleCodeGasPrices/test_eip2929.py +++ b/tests/ported_static/stEIP150singleCodeGasPrices/test_eip2929.py @@ -3,6 +3,25 @@ Ported from: state_tests/stEIP150singleCodeGasPrices/eip2929Filler.yml + +@manually-enhanced: Do not overwrite. Each parametrization runs three +operations (`oper1, oper2, oper3` from the calldata) on the same +measurement contract and stores each one's `Op.GAS` cost in slots 0, +1, 2. EIP-8038 reprices state access, so the cost of every measured +operation shifts by the (Amsterdam - Cancun) repricing of whatever +cold/warm account or storage access it performs. The access pattern, +and hence the delta, depends on what the two preceding operations +already warmed, so the deltas are computed by a small simulator +(`_slot_deltas`) that walks the operation triple while tracking the +warm state of the contract-0 account and storage slot 0x100. Each +component is built only from the fork's own gas model +(`COLD_ACCOUNT_ACCESS`, `COLD_STORAGE_ACCESS`, the EIP-8038 extra +`WARM_ACCESS` for code reads, and `Op.SSTORE` metadata costs), so +every delta is exactly 0 pre-EIP-8037 and tracks future parameter +changes. The `far*` operations call contract-1 (which does +`BALANCE(contract-0)`) or contract-2 (which does `SLOAD(0x100)`), so +they contribute the inner access's delta. Do not hardcode the +Amsterdam numbers. """ import pytest @@ -836,113 +855,201 @@ def test_eip2929( nonce=0, ) + # EIP-8038 access-repricing component deltas (each 0 pre-EIP-8037). + gas_costs = fork.gas_costs() + eip_active = fork.is_eip_enabled(8037) + cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 + cold_storage_delta = gas_costs.COLD_STORAGE_ACCESS - 2100 + # EIP-8038 charges an extra warm access for an EXTCODE* code read, + # on every access (cold adds it on top of the cold account cost, + # warm pays it as a second warm access). + extra_code_read = gas_costs.WARM_ACCESS if eip_active else 0 + cold_code_read_delta = cold_account_delta + extra_code_read + warm_code_read_delta = extra_code_read + + def _sstore_delta(cancun_cost: int, **metadata: int) -> int: + return Op.SSTORE.with_metadata(**metadata).gas_cost(fork) - cancun_cost + + # SSTORE 24743 -> 5 (existing nonzero slot changed to a new nonzero + # value): cold first write vs warm subsequent write. + cold_sstore_write_delta = _sstore_delta( + 5000, key_warm=False, original_value=1, current_value=1, new_value=2 + ) + warm_sstore_write_delta = _sstore_delta( + 2900, key_warm=True, original_value=1, current_value=1, new_value=2 + ) + + # Operation opcodes (from the calldata oper words). + op_nop, op_sload, op_sstore = 0x0, 0x1, 0x2 + op_balance, op_extsize, op_extcopy, op_exthash = 0xB, 0xC, 0xD, 0xE + op_call0, op_callcode0, op_deleg0, op_static0 = 0x15, 0x16, 0x17, 0x18 + op_call1, op_callcode2, op_deleg2 = 0x1F, 0x20, 0x21 + account_c0_ops = { + op_balance, + op_exthash, + op_call0, + op_callcode0, + op_deleg0, + op_static0, + } + code_read_ops = {op_extsize, op_extcopy} + inner_sload_ops = {op_sload, op_callcode2, op_deleg2} + # oper triples per data index, matching tx_data below. + oper_triples = { + 0: (op_nop, op_nop, op_nop), + 1: (op_sload, op_sload, op_sload), + 2: (op_sstore, op_sstore, op_sstore), + 3: (op_balance, op_balance, op_balance), + 4: (op_extsize, op_extsize, op_extsize), + 5: (op_extcopy, op_extcopy, op_extcopy), + 6: (op_exthash, op_exthash, op_exthash), + 7: (op_call0, op_call0, op_call0), + 8: (op_callcode0, op_callcode0, op_callcode0), + 9: (op_deleg0, op_deleg0, op_deleg0), + 10: (op_static0, op_static0, op_static0), + 11: (op_call1, op_call1, op_call1), + 12: (op_callcode2, op_callcode2, op_callcode2), + 13: (op_deleg2, op_deleg2, op_deleg2), + 14: (op_sload, op_sstore, op_sload), + 15: (op_sload, op_callcode2, op_deleg2), + 16: (op_sload, op_sstore, op_deleg2), + 17: (op_callcode2, op_sload, op_deleg2), + 18: (op_deleg2, op_sload, op_sstore), + 19: (op_balance, op_extsize, op_exthash), + 20: (op_balance, op_exthash, op_extsize), + 21: (op_extsize, op_balance, op_exthash), + 22: (op_extsize, op_exthash, op_balance), + 23: (op_exthash, op_extsize, op_balance), + 24: (op_exthash, op_balance, op_extsize), + 25: (op_call0, op_callcode0, op_call0), + 26: (op_callcode0, op_callcode0, op_call0), + 27: (op_deleg0, op_static0, op_deleg0), + 28: (op_deleg0, op_static0, op_static0), + 29: (op_balance, op_call0, op_callcode0), + 30: (op_extsize, op_call0, op_callcode0), + 31: (op_exthash, op_call0, op_callcode0), + 32: (op_balance, op_callcode0, op_call0), + 33: (op_extsize, op_callcode0, op_call0), + 34: (op_exthash, op_callcode0, op_call0), + 35: (op_balance, op_extsize, op_call1), + 36: (op_balance, op_call1, op_exthash), + 37: (op_call1, op_exthash, op_balance), + } + + def _slot_deltas(index: int) -> tuple[int, int, int]: + """ + Return the (slot0, slot1, slot2) EIP-8038 deltas for an index. + + Walk the operation triple, tracking the warm state of the + contract-0 account and storage slot 0x100 (pre-value 24743), + and accumulate the (Amsterdam - Cancun) repricing each measured + operation incurs. The `far*` calls reach a pre-warmed contract + whose body performs the inner access, so they contribute that + inner access's delta. + """ + c0_warm = False + slot_warm = False + slot_value = 24743 + out = [] + for op in oper_triples[index]: + delta = 0 + if op in inner_sload_ops: + # Direct SLOAD(0x100) or a far call whose body SLOADs it. + if not slot_warm: + delta = cold_storage_delta + slot_warm = True + elif op == op_sstore: + if slot_value != 5: + delta = ( + cold_sstore_write_delta + if not slot_warm + else warm_sstore_write_delta + ) + slot_value = 5 + slot_warm = True + elif op in code_read_ops: + delta = ( + cold_code_read_delta + if not c0_warm + else warm_code_read_delta + ) + c0_warm = True + elif op in account_c0_ops or op == op_call1: + # Account access to contract-0 (CALL1's body BALANCEs it). + if not c0_warm: + delta = cold_account_delta + c0_warm = True + out.append(delta) + return out[0], out[1], out[2] + + def _expect(index: int, base: tuple[int, int, int]) -> dict: + """Storage dict with each slot bumped by its EIP-8038 delta.""" + deltas = _slot_deltas(index) + return {i: base[i] + deltas[i] for i in range(3)} + + # Cancun-era base value of each measured slot, per data index. The + # per-index EIP-8038 delta is added by `_expect`, so each entry is a + # single data index (grouped entries with identical Cancun bases can + # still need different deltas once the access pattern differs). + base_values: dict[int, tuple[int, int, int]] = { + 1: (2090, 90, 90), + 2: (4991, 91, 91), + 3: (2590, 90, 90), + 4: (2590, 90, 90), + 5: (2597, 97, 97), + 6: (2590, 90, 90), + 7: (2608, 108, 108), + 8: (2608, 108, 108), + 9: (2605, 105, 105), + 10: (2605, 105, 105), + 11: (2711, 211, 211), + 12: (2211, 211, 211), + 13: (2208, 208, 208), + 14: (2090, 2891, 90), + 15: (2090, 211, 208), + 16: (2090, 2891, 208), + 17: (2211, 90, 208), + 18: (2208, 90, 2891), + 19: (2590, 90, 90), + 20: (2590, 90, 90), + 21: (2590, 90, 90), + 22: (2590, 90, 90), + 23: (2590, 90, 90), + 24: (2590, 90, 90), + 25: (2608, 108, 108), + 26: (2608, 108, 108), + 27: (2605, 105, 105), + 28: (2605, 105, 105), + 29: (2590, 108, 108), + 30: (2590, 108, 108), + 31: (2590, 108, 108), + 32: (2590, 108, 108), + 33: (2590, 108, 108), + 34: (2590, 108, 108), + 35: (2590, 90, 211), + 36: (2590, 211, 90), + 37: (2711, 90, 90), + } + expect_entries_: list[dict] = [ { "indexes": {"data": [0], "gas": -1, "value": -1}, "network": [">=Cancun"], "result": {contract_3: Account(storage={0: 0})}, }, - { - "indexes": {"data": [1], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": {contract_3: Account(storage={0: 2090, 1: 90, 2: 90})}, - }, - { - "indexes": {"data": [2], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": {contract_3: Account(storage={0: 4991, 1: 91, 2: 91})}, - }, - { - "indexes": {"data": [14], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": {contract_3: Account(storage={0: 2090, 1: 2891, 2: 90})}, - }, - { - "indexes": { - "data": [3, 4, 6, 19, 20, 21, 22, 23, 24], - "gas": -1, - "value": -1, - }, - "network": [">=Cancun"], - "result": {contract_3: Account(storage={0: 2590, 1: 90, 2: 90})}, - }, - { - "indexes": {"data": [5], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": {contract_3: Account(storage={0: 2597, 1: 97, 2: 97})}, - }, - { - "indexes": {"data": [8, 25, 26, 7], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": {contract_3: Account(storage={0: 2608, 1: 108, 2: 108})}, - }, - { - "indexes": {"data": [9, 10, 27, 28], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": {contract_3: Account(storage={0: 2605, 1: 105, 2: 105})}, - }, - { - "indexes": { - "data": [32, 33, 34, 29, 30, 31], - "gas": -1, - "value": -1, - }, - "network": [">=Cancun"], - "result": {contract_3: Account(storage={0: 2590, 1: 108, 2: 108})}, - }, - { - "indexes": {"data": [11], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": {contract_3: Account(storage={0: 2711, 1: 211, 2: 211})}, - }, - { - "indexes": {"data": [35], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": {contract_3: Account(storage={0: 2590, 1: 90, 2: 211})}, - }, - { - "indexes": {"data": [36], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": {contract_3: Account(storage={0: 2590, 1: 211, 2: 90})}, - }, - { - "indexes": {"data": [37], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": {contract_3: Account(storage={0: 2711, 1: 90, 2: 90})}, - }, - { - "indexes": {"data": [12], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": {contract_3: Account(storage={0: 2211, 1: 211, 2: 211})}, - }, - { - "indexes": {"data": [13], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": {contract_3: Account(storage={0: 2208, 1: 208, 2: 208})}, - }, - { - "indexes": {"data": [15], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": {contract_3: Account(storage={0: 2090, 1: 211, 2: 208})}, - }, - { - "indexes": {"data": [16], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - contract_3: Account(storage={0: 2090, 1: 2891, 2: 208}) - }, - }, - { - "indexes": {"data": [17], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": {contract_3: Account(storage={0: 2211, 1: 90, 2: 208})}, - }, - { - "indexes": {"data": [18], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": {contract_3: Account(storage={0: 2208, 1: 90, 2: 2891})}, - }, ] + for index in sorted(base_values): + expect_entries_.append( + { + "indexes": {"data": [index], "gas": -1, "value": -1}, + "network": [">=Cancun"], + "result": { + contract_3: Account( + storage=_expect(index, base_values[index]) + ) + }, + } + ) post, _exc = resolve_expect_post(expect_entries_, d, g, v, fork) diff --git a/tests/ported_static/stEIP150singleCodeGasPrices/test_eip2929_minus_ff.py b/tests/ported_static/stEIP150singleCodeGasPrices/test_eip2929_minus_ff.py index 0e9ecf78d46..fbba23d97e0 100644 --- a/tests/ported_static/stEIP150singleCodeGasPrices/test_eip2929_minus_ff.py +++ b/tests/ported_static/stEIP150singleCodeGasPrices/test_eip2929_minus_ff.py @@ -3,6 +3,15 @@ Ported from: state_tests/stEIP150singleCodeGasPrices/eip2929-ffFiller.yml + +@manually-enhanced: Do not overwrite. The first expect-entry (the +`simple`/NOP case) measures a `CALL` into a contract that +`SELFDESTRUCT`s with an as-yet-untouched (cold) beneficiary. EIP-8038 +reprices the cold account access (`COLD_ACCOUNT_ACCESS`, 2600 -> 3000), +so that cost shifts by `COLD_ACCOUNT_ACCESS - 2600`, derived from the +fork's own constant so it is exactly 0 pre-EIP-8038. The other entry +pre-warms the beneficiary, so its cost is unchanged. Do not hardcode +the Amsterdam number. """ import pytest @@ -99,6 +108,8 @@ def test_eip2929_minus_ff( v: int, ) -> None: """Ori Pomerantz qbzzt1@gmail.""" + # EIP-8038 cold account repricing (2600 -> 3000); 0 on earlier forks. + cold_account_delta = fork.gas_costs().COLD_ACCOUNT_ACCESS - 2600 coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) contract_0 = Address(0x000000000000000000000000000000000000DE57) contract_1 = Address(0x000000000000000000000000000000000000CA11) @@ -315,7 +326,11 @@ def test_eip2929_minus_ff( { "indexes": {"data": [0], "gas": -1, "value": -1}, "network": [">=Cancun"], - "result": {contract_2: Account(storage={0: 7726, 1: 105})}, + "result": { + contract_2: Account( + storage={0: 7726 + cold_account_delta, 1: 105} + ) + }, }, { "indexes": { diff --git a/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost.py b/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost.py index 98b4497269a..d539c14f735 100644 --- a/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost.py +++ b/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost.py @@ -3,6 +3,17 @@ Ported from: state_tests/stEIP150singleCodeGasPrices/gasCostFiller.yml + +@manually-enhanced: Do not overwrite. This crafts a one-opcode +contract, CALLs it, and stores the opcode's measured gas via `Op.GAS`. +EIP-8038 reprices state access, so four opcodes shift: `BALANCE` and +`SELFDESTRUCT` (cold account, `COLD_ACCOUNT_ACCESS` 2600 -> 3000, +400), +`EXTCODESIZE` (cold account plus the extra `WARM_ACCESS` charged for +the opcode's second read of the code, +500), and `SSTORE` to a cold +fresh slot (`COLD_STORAGE_ACCESS` 2100 -> 3000, +900). `BALANCE` and +`EXTCODESIZE` share a Cancun baseline but need different deltas, so +their expect-entries are split. Every delta is derived from the fork's +own constants and is exactly 0 pre-EIP-8038; do not hardcode it. """ import pytest @@ -714,6 +725,14 @@ def test_gas_cost( v: int, ) -> None: """Ori Pomerantz qbzzt1@gmail.""" + gas_costs = fork.gas_costs() + # EIP-8038 access repricing; each term is 0 on earlier forks. + cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 + cold_storage_delta = gas_costs.COLD_STORAGE_ACCESS - 2100 + # EXTCODESIZE also gains an extra warm access for its code read. + code_read_delta = cold_account_delta + ( + gas_costs.WARM_ACCESS if fork.is_eip_enabled(8037) else 0 + ) coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) sender = EOA( key=0x40AC0FC28C27E961EE46EC43355A094DE205856EDBD4654CF2577C2608D4EC1E @@ -1070,10 +1089,15 @@ def test_gas_cost( }, }, { + # SSTORE to a cold fresh slot: cold storage repricing. "indexes": {"data": [39], "gas": -1, "value": -1}, "network": [">=Cancun"], "result": { - addr: Account(storage=_storage_with_any({0: 700}, [1])) + addr: Account( + storage=_storage_with_any( + {0: 700 + cold_storage_delta}, [1] + ) + ) }, }, { @@ -1084,17 +1108,38 @@ def test_gas_cost( }, }, { + # SELFDESTRUCT to a cold (zero) beneficiary: cold account. "indexes": {"data": [45], "gas": -1, "value": -1}, "network": [">=Cancun"], "result": { - addr: Account(storage=_storage_with_any({0: 2000}, [1])) + addr: Account( + storage=_storage_with_any( + {0: 2000 + cold_account_delta}, [1] + ) + ) + }, + }, + { + # BALANCE on a cold (zero) address: cold account. + "indexes": {"data": [23], "gas": -1, "value": -1}, + "network": [">=Cancun"], + "result": { + addr: Account( + storage=_storage_with_any( + {0: 1300 + cold_account_delta}, [1] + ) + ) }, }, { - "indexes": {"data": [31, 23], "gas": -1, "value": -1}, + # EXTCODESIZE on a cold (zero) address: cold account plus the + # extra warm access for the opcode's code read. + "indexes": {"data": [31], "gas": -1, "value": -1}, "network": [">=Cancun"], "result": { - addr: Account(storage=_storage_with_any({0: 1300}, [1])) + addr: Account( + storage=_storage_with_any({0: 1300 + code_read_delta}, [1]) + ) }, }, ] diff --git a/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost_berlin.py b/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost_berlin.py index c122aab7d30..5a92e62ca28 100644 --- a/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost_berlin.py +++ b/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost_berlin.py @@ -3,6 +3,18 @@ Ported from: state_tests/stEIP150singleCodeGasPrices/gasCostBerlinFiller.yml + +@manually-enhanced: Do not overwrite. This crafts a one-opcode +contract, CALLs it, and stores the opcode's measured gas minus the +data's hardcoded Cancun-era expected cost (so the net is normally 0). +EIP-8038 reprices state access, so four opcodes now exceed their old +expected cost by a fork-derived delta: `BALANCE` and `SELFDESTRUCT` +(cold account, `COLD_ACCOUNT_ACCESS` 2600 -> 3000, +400), `EXTCODESIZE` +(cold account plus the extra `WARM_ACCESS` for the opcode's code read, ++500), and `SLOAD` (cold storage, `COLD_STORAGE_ACCESS` 2100 -> 3000, ++900). The stored net for those four data indices becomes that delta; +every other index stays 0. Each delta is derived from the fork's own +constants and is exactly 0 pre-EIP-8038; do not hardcode it. """ import pytest @@ -710,6 +722,23 @@ def test_gas_cost_berlin( v: int, ) -> None: """Ori Pomerantz qbzzt1@gmail.""" + gas_costs = fork.gas_costs() + # EIP-8038 access repricing; each term is 0 on earlier forks. + cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 + cold_storage_delta = gas_costs.COLD_STORAGE_ACCESS - 2100 + # EXTCODESIZE also gains an extra warm access for its code read. + code_read_delta = cold_account_delta + ( + gas_costs.WARM_ACCESS if fork.is_eip_enabled(8037) else 0 + ) + # Each measured opcode subtracts its Cancun-era expected cost, so the + # net is the (Amsterdam - Cancun) repricing of the one state access + # it performs (cold address 0 / cold fresh slot), keyed by data index. + measured_delta = { + 23: cold_account_delta, # BALANCE + 31: code_read_delta, # EXTCODESIZE + 39: cold_storage_delta, # SLOAD + 45: cold_account_delta, # SELFDESTRUCT + }.get(d, 0) coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) sender = pre.fund_eoa(amount=0xBA1A9CE0BA1A9CE) @@ -969,6 +998,6 @@ def test_gas_cost_berlin( value=tx_value[v], ) - post = {addr: Account(storage=_storage_with_any({0: 0}, [1]))} + post = {addr: Account(storage=_storage_with_any({0: measured_delta}, [1]))} state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost_memory.py b/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost_memory.py index aad4a36436a..ba5c6ac4b67 100644 --- a/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost_memory.py +++ b/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost_memory.py @@ -3,6 +3,15 @@ Ported from: state_tests/stEIP150singleCodeGasPrices/gasCostMemoryFiller.yml + +@manually-enhanced: Do not overwrite. The second expect-entry (data +36-48) stores the regular gas of a measured window that includes one +extra cold `CALL` to a previously untouched contract relative to its +baseline. EIP-8038 reprices `COLD_ACCOUNT_ACCESS` (2600 -> 3000), so +that net cost shifts by `COLD_ACCOUNT_ACCESS - 2600`, derived from the +fork's own constant so it is exactly 0 pre-EIP-8038. The first entry +measures a difference of two equal-cost operations and is unchanged. +Do not hardcode the Amsterdam number. """ import pytest @@ -495,6 +504,8 @@ def test_gas_cost_memory( v: int, ) -> None: """Ori Pomerantz qbzzt1@gmail.""" + # EIP-8038 cold account repricing (2600 -> 3000); 0 on earlier forks. + cold_account_delta = fork.gas_costs().COLD_ACCOUNT_ACCESS - 2600 coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) contract_0 = Address(0x000000000000000000000000000000000000BA5E) contract_1 = Address(0x000000000000000000000000000000000010BA5E) @@ -846,7 +857,9 @@ def test_gas_cost_memory( "value": -1, }, "network": [">=Cancun"], - "result": {contract_3: Account(storage={0: 1900})}, + "result": { + contract_3: Account(storage={0: 1900 + cold_account_delta}) + }, }, ] diff --git a/tests/ported_static/stEIP150singleCodeGasPrices/test_raw_ext_code_copy_gas.py b/tests/ported_static/stEIP150singleCodeGasPrices/test_raw_ext_code_copy_gas.py index c75b732ee35..4a23ef5d079 100644 --- a/tests/ported_static/stEIP150singleCodeGasPrices/test_raw_ext_code_copy_gas.py +++ b/tests/ported_static/stEIP150singleCodeGasPrices/test_raw_ext_code_copy_gas.py @@ -3,6 +3,17 @@ Ported from: state_tests/stEIP150singleCodeGasPrices/RawExtCodeCopyGasFiller.json + +@manually-enhanced: Do not overwrite. This measures the regular gas +that a single cold `EXTCODECOPY` consumes via `Op.GAS`. EIP-8038 +reprices the cold account access (`COLD_ACCOUNT_ACCESS`, 2600 -> 3000) +and charges an extra `WARM_ACCESS` for the opcode's second read (the +code). The stored cost therefore shifts by +`(COLD_ACCOUNT_ACCESS - 2600) + WARM_ACCESS`. The cold term comes from +the fork's own constant; the extra warm term is gated on the +`is_eip_enabled(8037)` flag (the registered flag that activates the +repricing at Amsterdam), so the delta is exactly 0 on earlier forks. +Do not hardcode it. """ import pytest @@ -15,6 +26,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -29,8 +41,17 @@ def test_raw_ext_code_copy_gas( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_raw_ext_code_copy_gas.""" + gas_costs = fork.gas_costs() + # EIP-8038: cold account repricing plus the extra warm access charged + # for the opcode's second read (the code). Both terms are 0 before + # EIP-8038, so the stored cost is unchanged on earlier forks. + cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 + code_read_delta = cold_account_delta + ( + gas_costs.WARM_ACCESS if fork.is_eip_enabled(8037) else 0 + ) coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) sender = pre.fund_eoa(amount=0xE8D4A51000) @@ -68,6 +89,6 @@ def test_raw_ext_code_copy_gas( gas_limit=600000, ) - post = {target: Account(storage={1: 2629})} + post = {target: Account(storage={1: 2629 + code_read_delta})} state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stEIP150singleCodeGasPrices/test_raw_ext_code_copy_memory_gas.py b/tests/ported_static/stEIP150singleCodeGasPrices/test_raw_ext_code_copy_memory_gas.py index a03431f4f6c..d16eef9ae8f 100644 --- a/tests/ported_static/stEIP150singleCodeGasPrices/test_raw_ext_code_copy_memory_gas.py +++ b/tests/ported_static/stEIP150singleCodeGasPrices/test_raw_ext_code_copy_memory_gas.py @@ -3,6 +3,17 @@ Ported from: state_tests/stEIP150singleCodeGasPrices/RawExtCodeCopyMemoryGasFiller.json + +@manually-enhanced: Do not overwrite. This measures the regular gas +that a single cold `EXTCODECOPY` (with memory expansion) consumes via +`Op.GAS`. EIP-8038 reprices the cold account access +(`COLD_ACCOUNT_ACCESS`, 2600 -> 3000) and charges an extra +`WARM_ACCESS` for the opcode's second read (the code). The stored cost +therefore shifts by `(COLD_ACCOUNT_ACCESS - 2600) + WARM_ACCESS`. The +cold term comes from the fork's own constant; the extra warm term is +gated on the `is_eip_enabled(8037)` flag (the registered flag that +activates the repricing at Amsterdam), so the delta is exactly 0 on +earlier forks. Do not hardcode it. """ import pytest @@ -15,6 +26,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -31,8 +43,17 @@ def test_raw_ext_code_copy_memory_gas( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_raw_ext_code_copy_memory_gas.""" + gas_costs = fork.gas_costs() + # EIP-8038: cold account repricing plus the extra warm access charged + # for the opcode's second read (the code). Both terms are 0 before + # EIP-8038, so the stored cost is unchanged on earlier forks. + cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 + code_read_delta = cold_account_delta + ( + gas_costs.WARM_ACCESS if fork.is_eip_enabled(8037) else 0 + ) coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) sender = pre.fund_eoa(amount=0xE8D4A51000) @@ -72,6 +93,6 @@ def test_raw_ext_code_copy_memory_gas( gas_limit=600000, ) - post = {target: Account(storage={1: 4948})} + post = {target: Account(storage={1: 4948 + code_read_delta})} state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stEIP150singleCodeGasPrices/test_raw_ext_code_size_gas.py b/tests/ported_static/stEIP150singleCodeGasPrices/test_raw_ext_code_size_gas.py index 4da7798cbdc..cc1641db59f 100644 --- a/tests/ported_static/stEIP150singleCodeGasPrices/test_raw_ext_code_size_gas.py +++ b/tests/ported_static/stEIP150singleCodeGasPrices/test_raw_ext_code_size_gas.py @@ -3,6 +3,17 @@ Ported from: state_tests/stEIP150singleCodeGasPrices/RawExtCodeSizeGasFiller.json + +@manually-enhanced: Do not overwrite. This measures the regular gas +that a single cold `EXTCODESIZE` consumes via `Op.GAS`. EIP-8038 +reprices the cold account access (`COLD_ACCOUNT_ACCESS`, 2600 -> 3000) +and charges an extra `WARM_ACCESS` for the opcode's second read (the +code). The stored cost therefore shifts by +`(COLD_ACCOUNT_ACCESS - 2600) + WARM_ACCESS`. The cold term comes from +the fork's own constant; the extra warm term is gated on the +`is_eip_enabled(8037)` flag (the registered flag that activates the +repricing at Amsterdam), so the delta is exactly 0 on earlier forks. +Do not hardcode it. """ import pytest @@ -15,6 +26,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -29,8 +41,17 @@ def test_raw_ext_code_size_gas( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_raw_ext_code_size_gas.""" + gas_costs = fork.gas_costs() + # EIP-8038: cold account repricing plus the extra warm access charged + # for the opcode's second read (the code). Both terms are 0 before + # EIP-8038, so the stored cost is unchanged on earlier forks. + cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 + code_read_delta = cold_account_delta + ( + gas_costs.WARM_ACCESS if fork.is_eip_enabled(8037) else 0 + ) coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) sender = pre.fund_eoa(amount=0xE8D4A51000) @@ -68,6 +89,6 @@ def test_raw_ext_code_size_gas( gas_limit=600000, ) - post = {target: Account(storage={1: 2616})} + post = {target: Account(storage={1: 2616 + code_read_delta})} state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stEIP1559/test_low_gas_limit.py b/tests/ported_static/stEIP1559/test_low_gas_limit.py index d2baa131eae..9e42e627208 100644 --- a/tests/ported_static/stEIP1559/test_low_gas_limit.py +++ b/tests/ported_static/stEIP1559/test_low_gas_limit.py @@ -3,6 +3,13 @@ Ported from: state_tests/stEIP1559/lowGasLimitFiller.yml + +@manually-enhanced: Do not overwrite. The `-g3` case must sit just below +the fork intrinsic to trigger `INTRINSIC_GAS_TOO_LOW`. EIP-2780 decomposes +and lowers the intrinsic, so the original hardcoded `20000` is no longer +below it; instead derive `intrinsic - 1` from the fork's +`transaction_intrinsic_cost_calculator()` for the single zero-byte +calldata so the boundary stays correct across the repricing. """ import pytest @@ -129,7 +136,13 @@ def test_low_gas_limit( tx_data = [ Bytes("00"), ] - tx_gas = [90000, 50000, 25000, 20000] + # -g3 must sit below the fork's intrinsic to trigger + # ``INTRINSIC_GAS_TOO_LOW``. EIP-2780 lowers the intrinsic so the + # original ``20000`` is no longer below it; derive the boundary. + intrinsic = fork.transaction_intrinsic_cost_calculator()( + calldata=Bytes("00"), + ) + tx_gas = [90000, 50000, 25000, intrinsic - 1] tx_access_lists: dict[int, list] = { 0: [], } diff --git a/tests/ported_static/stEIP158Specific/test_call_one_v_call_suicide.py b/tests/ported_static/stEIP158Specific/test_call_one_v_call_suicide.py index d99dcdf7185..b9be146f25c 100644 --- a/tests/ported_static/stEIP158Specific/test_call_one_v_call_suicide.py +++ b/tests/ported_static/stEIP158Specific/test_call_one_v_call_suicide.py @@ -3,6 +3,15 @@ Ported from: state_tests/stEIP158Specific/CALL_OneVCallSuicideFiller.json + +@manually-enhanced: Do not overwrite. The measured slot captures the +regular gas of a CALL with value to a not-yet-accessed contract that +SELFDESTRUCTs to the (alive) caller. EIP-8038 reprices the cold account +access (2600 -> 3000) and the CALL value transfer (9000 -> 10300); the +beneficiary stays alive so there is no new-account write. The delta is +`(COLD_ACCOUNT_ACCESS - 2600) + (CALL_VALUE - 9000)`, exactly 0 before +EIP-8037 and tracks parameter changes; do not hardcode the Amsterdam +value. """ import pytest @@ -15,6 +24,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -29,8 +39,14 @@ def test_call_one_v_call_suicide( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_call_one_v_call_suicide.""" + # EIP-8038 deltas, each 0 before EIP-8037. The CALL pays a cold + # account access plus a value transfer; the beneficiary stays alive. + gas_costs = fork.gas_costs() + cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 + call_value_delta = gas_costs.CALL_VALUE - 9000 coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) sender = pre.fund_eoa(amount=0xE8D4A51000) @@ -84,7 +100,10 @@ def test_call_one_v_call_suicide( post = { addr: Account(storage={}, balance=0), - target: Account(storage={100: 14337}, balance=100), + target: Account( + storage={100: 14337 + cold_account_delta + call_value_delta}, + balance=100, + ), } state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stEIP158Specific/test_call_one_v_call_suicide2.py b/tests/ported_static/stEIP158Specific/test_call_one_v_call_suicide2.py index 2c222ca68f4..ad7c1dcc8ed 100644 --- a/tests/ported_static/stEIP158Specific/test_call_one_v_call_suicide2.py +++ b/tests/ported_static/stEIP158Specific/test_call_one_v_call_suicide2.py @@ -3,6 +3,15 @@ Ported from: state_tests/stEIP158Specific/CALL_OneVCallSuicide2Filler.json + +@manually-enhanced: Do not overwrite. The measured slot captures the +regular gas of a value-1 CALL to a cold contract that then +SELFDESTRUCTs (with a zero balance) to a cold, alive beneficiary. +EIP-8038 reprices the CALL's cold account access and value transfer, +plus the SELFDESTRUCT beneficiary's cold access; the beneficiary is +alive so there is no new-account write. The delta is therefore +`2 * (COLD_ACCOUNT_ACCESS - 2600) + (CALL_VALUE - 9000)`, exactly 0 +before EIP-8038. """ import pytest @@ -16,6 +25,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -30,8 +40,15 @@ def test_call_one_v_call_suicide2( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_call_one_v_call_suicide2.""" + # EIP-8038 deltas, each 0 before EIP-8038. The CALL pays the cold + # account reprice and the value-transfer reprice; the cold + # SELFDESTRUCT beneficiary pays a second cold account reprice. + gas_costs = fork.gas_costs() + cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 + call_value_delta = gas_costs.CALL_VALUE - 9000 coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) addr_2 = Address(0xEB201D2887816E041F6E807E804F64F3A7A226FE) sender = EOA( @@ -90,7 +107,10 @@ def test_call_one_v_call_suicide2( post = { addr: Account(storage={}, balance=0), - target: Account(storage={100: 16937}, balance=99), + target: Account( + storage={100: 16937 + 2 * cold_account_delta + call_value_delta}, + balance=99, + ), addr_2: Account(balance=1), } diff --git a/tests/ported_static/stEIP158Specific/test_call_zero_v_call_suicide.py b/tests/ported_static/stEIP158Specific/test_call_zero_v_call_suicide.py index 901902b9a5b..6dbef1bc4d7 100644 --- a/tests/ported_static/stEIP158Specific/test_call_zero_v_call_suicide.py +++ b/tests/ported_static/stEIP158Specific/test_call_zero_v_call_suicide.py @@ -3,6 +3,13 @@ Ported from: state_tests/stEIP158Specific/CALL_ZeroVCallSuicideFiller.json + +@manually-enhanced: Do not overwrite. The measured slot captures the +regular gas of a value-0 CALL to a cold contract that then +SELFDESTRUCTs back to its (warm, alive) caller. EIP-8038 reprices the +cold account access of that CALL; the beneficiary is warm so the +SELFDESTRUCT is unchanged. The delta is therefore the fork's +`COLD_ACCOUNT_ACCESS - 2600`, exactly 0 before EIP-8038. """ import pytest @@ -15,6 +22,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -29,8 +37,11 @@ def test_call_zero_v_call_suicide( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_call_zero_v_call_suicide.""" + # EIP-8038 cold account access reprice; 0 before EIP-8038. + cold_account_delta = fork.gas_costs().COLD_ACCOUNT_ACCESS - 2600 coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) sender = pre.fund_eoa(amount=0xE8D4A51000) @@ -83,7 +94,7 @@ def test_call_zero_v_call_suicide( post = { addr: Account(balance=0), - target: Account(storage={100: 7637}), + target: Account(storage={100: 7637 + cold_account_delta}), } state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stEIP158Specific/test_extcodesize_to_epmty_paris.py b/tests/ported_static/stEIP158Specific/test_extcodesize_to_epmty_paris.py index 35e01d26d30..8437cc2a5fb 100644 --- a/tests/ported_static/stEIP158Specific/test_extcodesize_to_epmty_paris.py +++ b/tests/ported_static/stEIP158Specific/test_extcodesize_to_epmty_paris.py @@ -3,6 +3,15 @@ Ported from: state_tests/stEIP158Specific/EXTCODESIZE_toEpmtyParisFiller.json + +@manually-enhanced: Do not overwrite. The measured slot captures the +regular gas of an EXTCODESIZE on a cold (empty, code-less) EOA plus +the SSTORE that clears a populated slot to its (zero) result. +EIP-8038 reprices the cold account access and adds a second +WARM_ACCESS for the code read (EXTCODESIZE delta), and spills the +cold SSTORE-clear's state-gas into regular gas (the reservoir is +empty). Both deltas are derived from the fork's own opcode gas model, +so each is exactly 0 before EIP-8038. """ import pytest @@ -15,6 +24,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -29,8 +39,22 @@ def test_extcodesize_to_epmty_paris( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_extcodesize_to_epmty_paris.""" + # EIP-8038 deltas, each 0 before EIP-8038. EXTCODESIZE gains the + # cold account reprice plus a second WARM_ACCESS for the code read; + # the cold SSTORE-clear (nonzero -> 0) spills its state-gas back + # into regular gas. + extcodesize_delta = ( + Op.EXTCODESIZE.with_metadata(address_warm=False).gas_cost(fork) - 2600 + ) + cold_clear_sstore_delta = ( + Op.SSTORE.with_metadata( + key_warm=False, original_value=1, current_value=1, new_value=0 + ).gas_cost(fork) + - 5000 + ) coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) sender = pre.fund_eoa(amount=0xE8D4A51000) @@ -64,7 +88,9 @@ def test_extcodesize_to_epmty_paris( post = { addr: Account(storage={}, code=b"", balance=10, nonce=0), - target: Account(storage={100: 7617}), + target: Account( + storage={100: 7617 + extcodesize_delta + cold_clear_sstore_delta} + ), } state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stEIP158Specific/test_extcodesize_to_non_existent.py b/tests/ported_static/stEIP158Specific/test_extcodesize_to_non_existent.py index 66b98387b28..5f597f0b42e 100644 --- a/tests/ported_static/stEIP158Specific/test_extcodesize_to_non_existent.py +++ b/tests/ported_static/stEIP158Specific/test_extcodesize_to_non_existent.py @@ -3,6 +3,14 @@ Ported from: state_tests/stEIP158Specific/EXTCODESIZE_toNonExistentFiller.json + +@manually-enhanced: Do not overwrite. The measured slot captures the +regular gas of an EXTCODESIZE on a cold, non-existent address plus the +SSTORE that stores its (zero) result. EIP-8038 reprices the cold +account access and adds a second WARM_ACCESS for the code read +(EXTCODESIZE delta), and reprices the cold value-unchanged SSTORE. +Both deltas are derived from the fork's own opcode gas model, so each +is exactly 0 before EIP-8038. """ import pytest @@ -16,6 +24,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -30,8 +39,21 @@ def test_extcodesize_to_non_existent( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_extcodesize_to_non_existent.""" + # EIP-8038 deltas, each 0 before EIP-8038. EXTCODESIZE gains the + # cold account reprice plus a second WARM_ACCESS for the code read; + # the cold value-unchanged SSTORE gains its own reprice. + extcodesize_delta = ( + Op.EXTCODESIZE.with_metadata(address_warm=False).gas_cost(fork) - 2600 + ) + cold_noop_sstore_delta = ( + Op.SSTORE.with_metadata( + key_warm=False, original_value=0, current_value=0, new_value=0 + ).gas_cost(fork) + - 2200 + ) coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) contract_0 = Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B) sender = EOA( @@ -75,7 +97,9 @@ def test_extcodesize_to_non_existent( Address( 0xC94F5374FCE5EDBC8E2A8697C15331677E6EBF0B ): Account.NONEXISTENT, - contract_0: Account(storage={100: 4817}), + contract_0: Account( + storage={100: 4817 + extcodesize_delta + cold_noop_sstore_delta} + ), } state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stEIP2930/test_address_opcodes.py b/tests/ported_static/stEIP2930/test_address_opcodes.py index 2a55b6043b5..38279bf7408 100644 --- a/tests/ported_static/stEIP2930/test_address_opcodes.py +++ b/tests/ported_static/stEIP2930/test_address_opcodes.py @@ -3,6 +3,18 @@ Ported from: state_tests/stEIP2930/addressOpcodesFiller.yml + +@manually-enhanced: Do not overwrite. The contract measures, via +`Op.GAS`, the regular gas of each account-touching opcode (`BALANCE`, +`EXTCODESIZE`, `EXTCODEHASH`, `EXTCODECOPY`) on both a first (cold or +pre-warmed) and a second (warm) access. EIP-8038 reprices these: cold +`BALANCE`/`EXTCODEHASH` by +`COLD_ACCOUNT_ACCESS - 2600`, while +`EXTCODESIZE`/`EXTCODECOPY` carry an extra flat surcharge on both their +warm and cold forms. The single Cancun-era literals are therefore split +per opcode and per access, each adjusted by that opcode's own warm or +cold `(Amsterdam - Cancun)` cost delta taken from the fork gas model, so +every value is exactly 0 before EIP-8038 and tracks future parameter +changes; do not hardcode the Amsterdam numbers. """ import pytest @@ -21,7 +33,7 @@ from execution_testing.specs.static_state.expect_section import ( resolve_expect_post, ) -from execution_testing.vm import Op +from execution_testing.vm import Op, Opcode REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" @@ -545,65 +557,154 @@ def test_address_opcodes( address=Address(0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC), # noqa: E501 ) + # Per-opcode warm and cold cost deltas versus Cancun, derived from + # the fork gas model so each is exactly 0 before EIP-8038. The + # EXTCODECOPY metadata mirrors the measured access (a 0x20-byte copy + # into already-expanded memory) so only the account-access component + # varies across forks. `BALANCE`/`EXTCODEHASH` warm forms are + # unchanged; `EXTCODESIZE`/`EXTCODECOPY` gain a flat warm surcharge. + extcodecopy_meta = dict( + data_size=0x20, new_memory_size=0x120, old_memory_size=0x120 + ) + + def _account_delta(op: Opcode, warm: bool, base: int, **meta: int) -> int: + cost = op.with_metadata(address_warm=warm, **meta).gas_cost(fork) + return cost - base + + balance_warm_d = _account_delta(Op.BALANCE, True, 100) + balance_cold_d = _account_delta(Op.BALANCE, False, 2600) + extcodesize_warm_d = _account_delta(Op.EXTCODESIZE, True, 100) + extcodesize_cold_d = _account_delta(Op.EXTCODESIZE, False, 2600) + extcodehash_warm_d = _account_delta(Op.EXTCODEHASH, True, 100) + extcodehash_cold_d = _account_delta(Op.EXTCODEHASH, False, 2600) + extcodecopy_warm_d = _account_delta( + Op.EXTCODECOPY, True, 103, **extcodecopy_meta + ) + extcodecopy_cold_d = _account_delta( + Op.EXTCODECOPY, False, 2603, **extcodecopy_meta + ) + + # Slot 0 holds the first access (pre-warmed in the valid cases, cold + # in the invalid cases); slot 1 holds the always-warm second access. expect_entries_: list[dict] = [ + # valid (pre-warmed first access): both slots measure a warm + # access; only EXTCODESIZE/EXTCODECOPY shift. { "indexes": { - "data": [ - 0, - 1, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23, - 24, - 25, - 28, - 29, - 30, - 31, - 32, - 33, - 34, - 35, - 36, - 37, - 40, - 41, - 42, - 43, - 44, - 45, - 46, - 47, - ], + "data": [0, 1, 4, 5, 6, 7, 8, 9, 10, 11], + "gas": -1, + "value": -1, + }, + "network": [">=Cancun"], + "result": { + contract_0: Account( + storage={ + 0: 97 + balance_warm_d, + 1: 97 + balance_warm_d, + } + ) + }, + }, + { + "indexes": { + "data": [12, 13, 16, 17, 18, 19, 20, 21, 22, 23], + "gas": -1, + "value": -1, + }, + "network": [">=Cancun"], + "result": { + contract_0: Account( + storage={ + 0: 97 + extcodesize_warm_d, + 1: 97 + extcodesize_warm_d, + } + ) + }, + }, + { + "indexes": { + "data": [24, 25, 28, 29, 30, 31, 32, 33, 34, 35], "gas": -1, "value": -1, }, "network": [">=Cancun"], - "result": {contract_0: Account(storage={0: 97, 1: 97})}, + "result": { + contract_0: Account( + storage={ + 0: 97 + extcodehash_warm_d, + 1: 97 + extcodehash_warm_d, + } + ) + }, }, { "indexes": { - "data": [2, 3, 14, 15, 26, 27, 38, 39], + "data": [36, 37, 40, 41, 42, 43, 44, 45, 46, 47], "gas": -1, "value": -1, }, "network": [">=Cancun"], - "result": {contract_0: Account(storage={0: 2597, 1: 97, 2: 0})}, + "result": { + contract_0: Account( + storage={ + 0: 97 + extcodecopy_warm_d, + 1: 97 + extcodecopy_warm_d, + } + ) + }, + }, + # invalid (cold first access): slot 0 cold, slot 1 warm. + { + "indexes": {"data": [2, 3], "gas": -1, "value": -1}, + "network": [">=Cancun"], + "result": { + contract_0: Account( + storage={ + 0: 2597 + balance_cold_d, + 1: 97 + balance_warm_d, + 2: 0, + } + ) + }, + }, + { + "indexes": {"data": [14, 15], "gas": -1, "value": -1}, + "network": [">=Cancun"], + "result": { + contract_0: Account( + storage={ + 0: 2597 + extcodesize_cold_d, + 1: 97 + extcodesize_warm_d, + 2: 0, + } + ) + }, + }, + { + "indexes": {"data": [26, 27], "gas": -1, "value": -1}, + "network": [">=Cancun"], + "result": { + contract_0: Account( + storage={ + 0: 2597 + extcodehash_cold_d, + 1: 97 + extcodehash_warm_d, + 2: 0, + } + ) + }, + }, + { + "indexes": {"data": [38, 39], "gas": -1, "value": -1}, + "network": [">=Cancun"], + "result": { + contract_0: Account( + storage={ + 0: 2597 + extcodecopy_cold_d, + 1: 97 + extcodecopy_warm_d, + 2: 0, + } + ) + }, }, ] diff --git a/tests/ported_static/stEIP2930/test_coinbase_t01.py b/tests/ported_static/stEIP2930/test_coinbase_t01.py index b6c08afc361..754f52c4628 100644 --- a/tests/ported_static/stEIP2930/test_coinbase_t01.py +++ b/tests/ported_static/stEIP2930/test_coinbase_t01.py @@ -3,6 +3,14 @@ Ported from: state_tests/stEIP2930/coinbaseT01Filler.yml + +@manually-enhanced: Do not overwrite. The target contract measures, via +`Op.GAS`, the regular gas of a `CALL` that transfers value to the warm, +already-existing coinbase. EIP-8038 reprices the value-transfer +component (`CALL_VALUE` 9 000 -> 10 300), so the measurement grows by +`gas_costs.CALL_VALUE - 9000`. That delta is derived from the fork's +own gas model, so it is exactly 0 before EIP-8038 and tracks future +parameter changes; do not hardcode the Amsterdam number. """ import pytest @@ -111,16 +119,22 @@ def test_coinbase_t01( nonce=1, ) + # EIP-8038 reprices the value-transfer component of `CALL`; with the + # coinbase warm and already in state, the measured gas grows by the + # `CALL_VALUE` reprice alone. Derived from the fork gas model so it + # is 0 before EIP-8038. + call_value_delta = fork.gas_costs().CALL_VALUE - 9000 + expect_entries_: list[dict] = [ { "indexes": {"data": [1], "gas": -1, "value": -1}, "network": [">=Cancun"], - "result": {target: Account(storage={0: 6800})}, + "result": {target: Account(storage={0: 6800 + call_value_delta})}, }, { "indexes": {"data": [0, 2], "gas": -1, "value": -1}, "network": [">=Cancun"], - "result": {target: Account(storage={0: 6800})}, + "result": {target: Account(storage={0: 6800 + call_value_delta})}, }, ] diff --git a/tests/ported_static/stEIP2930/test_coinbase_t2.py b/tests/ported_static/stEIP2930/test_coinbase_t2.py index ea96482ef8b..5e8924e0b17 100644 --- a/tests/ported_static/stEIP2930/test_coinbase_t2.py +++ b/tests/ported_static/stEIP2930/test_coinbase_t2.py @@ -3,6 +3,14 @@ Ported from: state_tests/stEIP2930/coinbaseT2Filler.yml + +@manually-enhanced: Do not overwrite. The target contract measures, via +`Op.GAS`, the regular gas of a `CALL` that transfers value to the warm, +already-existing coinbase. EIP-8038 reprices the value-transfer +component (`CALL_VALUE` 9 000 -> 10 300), so the measurement grows by +`gas_costs.CALL_VALUE - 9000`. That delta is derived from the fork's +own gas model, so it is exactly 0 before EIP-8038 and tracks future +parameter changes; do not hardcode the Amsterdam number. """ import pytest @@ -105,16 +113,22 @@ def test_coinbase_t2( nonce=1, ) + # EIP-8038 reprices the value-transfer component of `CALL`; with the + # coinbase warm and already in state, the measured gas grows by the + # `CALL_VALUE` reprice alone. Derived from the fork gas model so it + # is 0 before EIP-8038. + call_value_delta = fork.gas_costs().CALL_VALUE - 9000 + expect_entries_: list[dict] = [ { "indexes": {"data": [0], "gas": -1, "value": -1}, "network": [">=Cancun"], - "result": {target: Account(storage={0: 6800})}, + "result": {target: Account(storage={0: 6800 + call_value_delta})}, }, { "indexes": {"data": [1], "gas": -1, "value": -1}, "network": [">=Cancun"], - "result": {target: Account(storage={0: 6800})}, + "result": {target: Account(storage={0: 6800 + call_value_delta})}, }, ] diff --git a/tests/ported_static/stEIP2930/test_manual_create.py b/tests/ported_static/stEIP2930/test_manual_create.py index 5ca118c9646..6bf26e1228a 100644 --- a/tests/ported_static/stEIP2930/test_manual_create.py +++ b/tests/ported_static/stEIP2930/test_manual_create.py @@ -6,12 +6,13 @@ @manually-enhanced: Do not overwrite. The three parametrizations of this test measure regular gas around a fresh SSTORE-set inside a -CREATE-deployed contract. EIP-8037 splits the Cancun-era SSTORE-set -base into a smaller regular portion plus 37 568 state-gas; with an -empty reservoir the full state-gas spills into regular gas and -`Op.GAS` reads +20 468 = 37 568 - 17 100 compared to Cancun. Bake -that delta into both `[">=Cancun"]` expect entries fork-conditionally -via `Op.SSTORE(new_value=1).state_cost(fork) - 17100`. +CREATE-deployed contract. EIP-8037 moves the bulk of the SSTORE-set +cost into a per-storage state-gas charge; with an empty reservoir it +spills back into regular gas, which `Op.GAS` observes. Derive the +warm and cold fresh-set deltas from the fork's own gas model so each +is exactly 0 pre-EIP-8037 and tracks parameter changes; bake the +warm delta into the declared-key entry and the cold delta into the +undeclared-key entries. """ import pytest @@ -90,12 +91,18 @@ def test_manual_create( pre[sender] = Account(balance=0x1000000000000000000, nonce=1) - # EIP-8037 SSTORE-set spillover: +20 468 regular gas per fresh set - # when the reservoir is empty. - sstore_set_delta = ( - (Op.SSTORE(new_value=1).state_cost(fork) - 17100) - if fork.is_eip_enabled(8037) - else 0 + # EIP-8037 SSTORE-set spill into regular gas (empty reservoir). + # Derive the warm and cold fresh-set deltas from the fork's own + # gas model so each is exactly 0 pre-EIP-8037. + def _sstore_delta(cancun_cost: int, **metadata: int) -> int: + op = Op.SSTORE.with_metadata(**metadata) + return op.gas_cost(fork) - cancun_cost + + warm_set_delta = _sstore_delta( + 20000, key_warm=True, current_value=0, new_value=2 + ) + cold_set_delta = _sstore_delta( + 22100, key_warm=False, current_value=0, new_value=2 ) expect_entries_: list[dict] = [ @@ -104,7 +111,7 @@ def test_manual_create( "network": [">=Cancun"], "result": { compute_create_address(address=sender, nonce=1): Account( - storage={0: 20008 + sstore_set_delta, 1: 106} + storage={0: 20008 + warm_set_delta, 1: 106} ), }, }, @@ -113,7 +120,7 @@ def test_manual_create( "network": [">=Cancun"], "result": { compute_create_address(address=sender, nonce=1): Account( - storage={0: 22108 + sstore_set_delta, 1: 106} + storage={0: 22108 + cold_set_delta, 1: 106} ), }, }, diff --git a/tests/ported_static/stEIP2930/test_storage_costs.py b/tests/ported_static/stEIP2930/test_storage_costs.py index 03bb03dd189..5e588d94abe 100644 --- a/tests/ported_static/stEIP2930/test_storage_costs.py +++ b/tests/ported_static/stEIP2930/test_storage_costs.py @@ -4,19 +4,19 @@ Ported from: state_tests/stEIP2930/storageCostsFiller.yml -@manually-enhanced: Do not overwrite. The SSTORE gas measurements in -this test were authored against the Cancun-era SSTORE-set base cost -of 20 000 (per EIP-2200). EIP-8037 splits that cost into a smaller -regular portion (~2 900) plus a per-storage state-gas charge of -`STATE_BYTES_PER_STORAGE_SET (32) * COST_PER_STATE_BYTE (1174) = -37 568`. When the state-gas reservoir is empty — as it is here, since -the tests don't pre-allocate state-gas budget — the full state-gas -spills back into regular gas, so `Op.GAS` observes -`+37 568 - 17 100 = +20 468` regular gas per fresh SSTORE-set -compared to Cancun. Bake that fork-conditional delta into the -expected post-state values for the 10 parametrizations whose measured -SSTORE writes triggered the spill; the remaining entries (SLOAD-only, -no-op SSTOREs) are unaffected. +@manually-enhanced: Do not overwrite. This test measures the regular +gas consumed by storage accesses via `Op.GAS`. EIP-8037 moves the +bulk of storage-write cost into a per-storage state-gas charge; with +an empty state-gas reservoir (these tests pre-allocate none) the full +state gas spills back into regular gas, so each measurement shifts by +its `(Amsterdam - Cancun)` cost delta. Six access classes shift: warm +and cold fresh SSTORE-sets (state-gas spill dominates), warm and cold +SSTORE writes to existing slots (clear/reset: the storage-write +component), cold value-unchanged SSTOREs, and cold SLOADs (the +`COLD_STORAGE_ACCESS` repricing). Warm reads and no-op SSTOREs are +unchanged. Each delta below is derived from the fork's own opcode gas +model, so it is exactly 0 pre-EIP-8037 and tracks future parameter +changes; do not hardcode the Amsterdam numbers. """ import pytest @@ -661,113 +661,150 @@ def test_storage_costs( address=Address(0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC), # noqa: E501 ) - # EIP-8037 splits the SSTORE-set base cost (Cancun: 20 000 regular) - # into a smaller regular portion plus per-storage state-gas. When - # the state-gas reservoir is empty for these tests, the full state - # gas spills into regular gas, so Op.GAS sees +20 468 per fresh - # SSTORE-set compared to Cancun (=37 568 state-gas - 17 100 base - # regular drop). Apply that delta to the 10 measurements that - # trigger a fresh-set spill; the SLOAD-only and no-op SSTORE - # entries below are unchanged. - sstore_set_delta = ( - (Op.SSTORE(new_value=1).state_cost(fork) - 17100) - if fork.is_eip_enabled(8037) - else 0 + # EIP-8037 moves the bulk of storage-write cost into a per-storage + # state-gas charge. These tests pre-allocate no state-gas reservoir, + # so the full state gas spills back into regular gas and `Op.GAS` + # observes each measured SSTORE/SLOAD at its combined regular + state + # cost. Every measured access therefore shifts by its + # (Amsterdam - Cancun) delta; derive each delta from the fork's own + # opcode gas model so it is exactly 0 pre-EIP-8037 and tracks future + # parameter changes. The subtracted Cancun-era pure costs are frozen + # historical values. + def _sstore_delta(cancun_cost: int, **metadata: int) -> int: + op = Op.SSTORE.with_metadata(**metadata) + return op.gas_cost(fork) - cancun_cost + + d_warm_set = _sstore_delta( + 20000, key_warm=True, original_value=0, current_value=0, new_value=2 + ) + d_cold_set = _sstore_delta( + 22100, key_warm=False, original_value=0, current_value=0, new_value=2 + ) + d_warm_write = _sstore_delta( + 2900, key_warm=True, original_value=1, current_value=1, new_value=2 + ) + d_cold_write = _sstore_delta( + 5000, key_warm=False, original_value=1, current_value=1, new_value=2 + ) + d_cold_noop = _sstore_delta( + 2200, key_warm=False, original_value=1, current_value=1, new_value=1 ) + d_cold_read = fork.gas_costs().COLD_STORAGE_ACCESS - 2100 expect_entries_: list[dict] = [ + # declaredKeyWrite: warm fresh SSTORE-set. { "indexes": {"data": [0, 35], "gas": -1, "value": -1}, "network": [">=Cancun"], "result": { - contract_0: Account( - storage={0: 2, 1: 20003 + sstore_set_delta} - ) + contract_0: Account(storage={0: 2, 1: 20003 + d_warm_set}) }, }, + # undeclaredKeyWrite: cold fresh SSTORE-set. { "indexes": {"data": [6, 12, 18], "gas": -1, "value": -1}, "network": [">=Cancun"], "result": { - contract_0: Account( - storage={0: 2, 1: 22103 + sstore_set_delta} - ) + contract_0: Account(storage={0: 2, 1: 22103 + d_cold_set}) }, }, + # declaredKeyUpdate: warm SSTORE-reset (nonzero -> nonzero). { "indexes": {"data": [3], "gas": -1, "value": -1}, "network": [">=Cancun"], - "result": {contract_3: Account(storage={0: 48879, 1: 2903})}, + "result": { + contract_3: Account(storage={0: 48879, 1: 2903 + d_warm_write}) + }, }, + # undeclaredKeyUpdate: cold SSTORE-reset (nonzero -> nonzero). { "indexes": {"data": [9, 15, 21], "gas": -1, "value": -1}, "network": [">=Cancun"], - "result": {contract_3: Account(storage={0: 48879, 1: 5003})}, + "result": { + contract_3: Account(storage={0: 48879, 1: 5003 + d_cold_write}) + }, }, + # declaredKeyNOP: warm value-unchanged SSTORE (no write). { "indexes": {"data": [4], "gas": -1, "value": -1}, "network": [">=Cancun"], "result": {contract_4: Account(storage={0: 24743, 1: 103})}, }, + # undeclaredKeyNOP: cold value-unchanged SSTORE. { "indexes": {"data": [10, 16, 22], "gas": -1, "value": -1}, "network": [">=Cancun"], - "result": {contract_4: Account(storage={0: 24743, 1: 2203})}, + "result": { + contract_4: Account(storage={0: 24743, 1: 2203 + d_cold_noop}) + }, }, + # declaredKeyNOP0: warm value-unchanged SSTORE (no write). { "indexes": {"data": [5], "gas": -1, "value": -1}, "network": [">=Cancun"], "result": {contract_5: Account(storage={1: 103})}, }, + # undeclaredKeyNOP0: cold value-unchanged SSTORE. { "indexes": {"data": [11, 17, 23], "gas": -1, "value": -1}, "network": [">=Cancun"], - "result": {contract_5: Account(storage={1: 2203})}, + "result": {contract_5: Account(storage={1: 2203 + d_cold_noop})}, }, + # declaredKeyDel: warm SSTORE-clear (nonzero -> 0). { "indexes": {"data": [2], "gas": -1, "value": -1}, "network": [">=Cancun"], - "result": {contract_2: Account(storage={0: 0, 1: 2903})}, + "result": { + contract_2: Account(storage={0: 0, 1: 2903 + d_warm_write}) + }, }, + # undeclaredKeyDel: cold SSTORE-clear (nonzero -> 0). { "indexes": {"data": [8, 14, 20], "gas": -1, "value": -1}, "network": [">=Cancun"], - "result": {contract_2: Account(storage={0: 0, 1: 5003})}, + "result": { + contract_2: Account(storage={0: 0, 1: 5003 + d_cold_write}) + }, }, + # declaredKeyRead: warm SLOAD. { "indexes": {"data": [1], "gas": -1, "value": -1}, "network": [">=Cancun"], "result": {contract_1: Account(storage={1: 100})}, }, + # undeclaredKeyRead: cold SLOAD. { "indexes": {"data": [7, 13, 19], "gas": -1, "value": -1}, "network": [">=Cancun"], - "result": {contract_1: Account(storage={1: 2100})}, + "result": {contract_1: Account(storage={1: 2100 + d_cold_read})}, }, + # postSSTORE write: key already warm/dirty, no fresh-set spill. { "indexes": {"data": [24, 25], "gas": -1, "value": -1}, "network": [">=Cancun"], "result": {contract_6: Account(storage={0: 2, 1: 103})}, }, + # postSSTORE read: key already warm. { "indexes": {"data": [26, 27], "gas": -1, "value": -1}, "network": [">=Cancun"], "result": {contract_7: Account(storage={0: 24743, 1: 100})}, }, + # postSLOAD write: SLOAD warms the key, then warm fresh SSTORE-set. { "indexes": {"data": [28, 29], "gas": -1, "value": -1}, "network": [">=Cancun"], "result": { - contract_8: Account( - storage={0: 2, 1: 20000 + sstore_set_delta} - ) + contract_8: Account(storage={0: 2, 1: 20000 + d_warm_set}) }, }, + # postSLOAD read: key already warm. { "indexes": {"data": [30, 31], "gas": -1, "value": -1}, "network": [">=Cancun"], "result": {contract_9: Account(storage={1: 97})}, }, + # declaredTo: warm SLOAD (slot 1) + warm fresh SSTORE-set (slot 2). { "indexes": {"data": [32], "gas": -1, "value": -1}, "network": [">=Cancun"], @@ -776,12 +813,13 @@ def test_storage_costs( storage={ 0: 2, 1: 100, - 2: 20000 + sstore_set_delta, + 2: 20000 + d_warm_set, 24743: 57005, } ) }, }, + # undeclaredTo: cold SLOAD (slot 1) + cold fresh SSTORE-set (slot 2). { "indexes": {"data": [33, 34], "gas": -1, "value": -1}, "network": [">=Cancun"], @@ -789,8 +827,8 @@ def test_storage_costs( contract_10: Account( storage={ 0: 2, - 1: 2100, - 2: 22100 + sstore_set_delta, + 1: 2100 + d_cold_read, + 2: 22100 + d_cold_set, 24743: 57005, } ), diff --git a/tests/ported_static/stEIP2930/test_transaction_costs.py b/tests/ported_static/stEIP2930/test_transaction_costs.py index 3695bf9a542..fb03a48592e 100644 --- a/tests/ported_static/stEIP2930/test_transaction_costs.py +++ b/tests/ported_static/stEIP2930/test_transaction_costs.py @@ -3,6 +3,16 @@ Ported from: state_tests/stEIP2930/transactionCostsFiller.yml + +@manually-enhanced: Do not overwrite. The post-state asserts the sender +balance after a STOP-only call. For Amsterdam+ it is derived from +`fork.transaction_intrinsic_cost_calculator()` (over calldata, +access_list, and sends_value) as `pre_balance - tx.value - +intrinsic_gas * gas_price`, instead of a hardcoded literal, so the +access-list-heavy cases stay correct across the EIP-2780 intrinsic +decomposition and EIP-7981/EIP-8038 access-list repricing. Pre-Amsterdam +forks (Cancun/Prague) keep their original hardcoded balances. No +21_000 baseline or SSTORE-clear constants are subtracted here. """ import pytest @@ -471,6 +481,7 @@ def test_transaction_costs( calldata=tx.data, contract_creation=tx.to is None, access_list=tx.access_list, + sends_value=bool(tx.value), ) post[sender] = Account( balance=( diff --git a/tests/ported_static/stEIP2930/test_varied_context.py b/tests/ported_static/stEIP2930/test_varied_context.py index 3c78c50a4c9..705de655138 100644 --- a/tests/ported_static/stEIP2930/test_varied_context.py +++ b/tests/ported_static/stEIP2930/test_varied_context.py @@ -4,21 +4,21 @@ Ported from: state_tests/stEIP2930/variedContextFiller.yml -@manually-enhanced: Do not overwrite. 28 parametrizations of this -test measure gas consumption around SSTORE/CALL/SELFDESTRUCT in -various access-list contexts. EIP-8037 splits the Cancun-era base -costs (SSTORE-set 20 000, CALL-new-account 25 000, SELFDESTRUCT-new- -beneficiary 25 000) into smaller regular portions plus per-storage -or per-new-account state-gas charges. When the reservoir is empty — -the case here, since no state-gas budget is pre-allocated — the -full state-gas spills back into regular gas and Op.GAS reads three -distinct deltas: - +20 468 per fresh SSTORE-set - +106 488 per NEW_ACCOUNT (CALL with value or SELFDESTRUCT) - +126 956 = both, for SELFDESTRUCT-with-write paths -Each affected post-state literal is bumped by the appropriate -delta fork-conditionally; pre-EIP-8037 forks use the original -values. +@manually-enhanced: Do not overwrite. This test measures gas +consumption around SSTORE/CALL/SELFDESTRUCT in various access-list +contexts via `Op.GAS`. EIP-8037/8038 reprice several components; +with an empty reservoir (the case here) the state-gas portion +spills back into regular gas, so each measurement shifts by its +`(Amsterdam - Cancun)` delta. Every delta below is derived from the +fork's own opcode gas model, so it is exactly 0 pre-EIP-8037 and +tracks parameter changes: warm/cold fresh SSTORE-sets, the +NEW_ACCOUNT spill for CALL-with-value and SELFDESTRUCT-to-non-alive +(the latter also gaining `ACCOUNT_WRITE` and the cold reprice), and +the cold account/storage access reprices. The `*ValidGas` +parametrizations instead forward a fixed in-bytecode gas budget that +EIP-8038 made insufficient; those budgets are bumped by the inner +SSTORE-write increase so the success path stays funded while the +under-funded cold path still runs out of gas. """ import pytest @@ -1152,9 +1152,24 @@ def test_varied_context( # { ; WRITE_INVALID_OOG WRITE_VALID_NO_OOG # (call 0x0B65 0xF114 0 0 0 0 0x20) # } + # EIP-8038 raises the inner SSTORE-write cost. Bump the "valid" gas + # these callers forward by exactly that increase so their success + # path stays funded at Amsterdam (preserving the original Cancun + # margin) while the under-funded cold "invalid" path still OOGs. + # The contract_13 inner SSTORE is a warm reset; contract_15's is a + # cold reset. Both bumps are 0 pre-EIP-8037. + _warm_reset = Op.SSTORE.with_metadata( + key_warm=True, original_value=1, current_value=1, new_value=2 + ) + _cold_reset = Op.SSTORE.with_metadata( + key_warm=False, original_value=1, current_value=1, new_value=2 + ) + valid_write_gas = 0xB65 + (_warm_reset.gas_cost(fork) - 2900) + valid_read_gas = 0x1800 + (_cold_reset.gas_cost(fork) - 5000) + contract_13 = pre.deploy_contract( # noqa: F841 code=Op.CALL( - gas=0xB65, + gas=valid_write_gas, address=0xF114, value=0x0, args_offset=0x0, @@ -1173,7 +1188,7 @@ def test_varied_context( # } contract_15 = pre.deploy_contract( # noqa: F841 code=Op.CALL( - gas=0x1800, + gas=valid_read_gas, address=0xF115, value=0x0, args_offset=0x0, @@ -1337,24 +1352,41 @@ def test_varied_context( address=Address(0x0000000000000000000000000000000000001016), # noqa: E501 ) - # EIP-8037 splits SSTORE-set, NEW_ACCOUNT call value transfer, and - # SELFDESTRUCT new-beneficiary base costs into state-gas portions. - # With an empty reservoir (the case here), the full state-gas - # spills into regular gas, which Op.GAS observes. - # sstore-set spill: +37 568 - 17 100 = +20 468 per fresh set - # new-account spill: +131 488 - 25 000 = +106 488 per CALL - # with value to a non-alive account, and - # per SELFDESTRUCT to non-alive beneficiary - # suicide-write spill: +126 956 = both deltas combined - sstore_set_delta = ( - (Op.SSTORE(new_value=1).state_cost(fork) - 17100) - if fork.is_eip_enabled(8037) - else 0 + # EIP-8037/8038 reprice several access components. With an empty + # reservoir (the case here) the state-gas portion spills back into + # regular gas, which `Op.GAS` observes. Derive each delta from the + # fork's own gas model so it is exactly 0 pre-EIP-8037 and tracks + # parameter changes. + gas_costs = fork.gas_costs() + + def _sstore_delta(cancun_cost: int, **metadata: int) -> int: + op = Op.SSTORE.with_metadata(**metadata) + return op.gas_cost(fork) - cancun_cost + + # Fresh SSTORE-set (state-gas spill dominates), warm vs cold key. + warm_set_delta = _sstore_delta( + 20000, key_warm=True, current_value=0, new_value=2 + ) + cold_set_delta = _sstore_delta( + 22100, key_warm=False, current_value=0, new_value=2 ) + # CALL value transfer to a non-alive account: the 25 000 NEW_ACCOUNT + # base becomes a spilling state-gas charge. new_account_delta = ( (fork.create_state_gas() - 25000) if fork.is_eip_enabled(8037) else 0 ) - suicide_write_delta = sstore_set_delta + new_account_delta + # Cold account access reprice (0 pre-Amsterdam). + cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 + # Cold storage access (SLOAD) reprice (0 pre-Amsterdam). + cold_storage_delta = gas_costs.COLD_STORAGE_ACCESS - 2100 + # SELFDESTRUCT to a non-alive cold beneficiary: new-account spill, + # the new ACCOUNT_WRITE charge (0 pre-Amsterdam), and the cold + # reprice. + suicide_new_delta = ( + new_account_delta + gas_costs.ACCOUNT_WRITE + cold_account_delta + ) + # callWriteSuicide measures a warm SSTORE-set then that SELFDESTRUCT. + suicide_write_delta = warm_set_delta + suicide_new_delta expect_entries_: list[dict] = [ { @@ -1362,7 +1394,7 @@ def test_varied_context( "network": [">=Cancun"], "result": { contract_0: Account( - storage={0: 2, 1: (20003 + sstore_set_delta), 2: 107} + storage={0: 2, 1: (20003 + warm_set_delta), 2: 107} ) }, }, @@ -1371,7 +1403,11 @@ def test_varied_context( "network": [">=Cancun"], "result": { contract_0: Account( - storage={0: 2, 1: (22103 + sstore_set_delta), 2: 2107} + storage={ + 0: 2, + 1: (22103 + cold_set_delta), + 2: 2107 + cold_storage_delta, + } ) }, }, @@ -1380,7 +1416,7 @@ def test_varied_context( "network": [">=Cancun"], "result": { contract_2: Account( - storage={0: 2, 1: (20003 + sstore_set_delta), 2: 107} + storage={0: 2, 1: (20003 + warm_set_delta), 2: 107} ) }, }, @@ -1389,7 +1425,11 @@ def test_varied_context( "network": [">=Cancun"], "result": { contract_2: Account( - storage={0: 2, 1: (22103 + sstore_set_delta), 2: 2107} + storage={ + 0: 2, + 1: (22103 + cold_set_delta), + 2: 2107 + cold_storage_delta, + } ) }, }, @@ -1400,8 +1440,8 @@ def test_varied_context( contract_3: Account( storage={ 0: 2, - 1: (22103 + sstore_set_delta), - 2: 2107, + 1: (22103 + cold_set_delta), + 2: 2107 + cold_storage_delta, 24743: 57005, } ) @@ -1414,7 +1454,7 @@ def test_varied_context( contract_3: Account( storage={ 0: 2, - 1: (20003 + sstore_set_delta), + 1: (20003 + warm_set_delta), 2: 107, 24743: 57005, } @@ -1424,7 +1464,9 @@ def test_varied_context( { "indexes": {"data": [6], "gas": -1, "value": -1}, "network": [">=Cancun"], - "result": {contract_4: Account(storage={0: 2107})}, + "result": { + contract_4: Account(storage={0: 2107 + cold_storage_delta}) + }, }, { "indexes": {"data": [7], "gas": -1, "value": -1}, @@ -1436,7 +1478,7 @@ def test_varied_context( "network": [">=Cancun"], "result": { contract_26: Account( - storage={0: (20003 + sstore_set_delta), 1: 100} + storage={0: (20003 + warm_set_delta), 1: 100} ) }, }, @@ -1445,7 +1487,10 @@ def test_varied_context( "network": [">=Cancun"], "result": { contract_26: Account( - storage={0: (22103 + sstore_set_delta), 1: 2100} + storage={ + 0: (22103 + cold_set_delta), + 1: 2100 + cold_storage_delta, + } ) }, }, @@ -1460,21 +1505,37 @@ def test_varied_context( "indexes": {"data": [11], "gas": -1, "value": -1}, "network": [">=Cancun"], "result": { - contract_7: Account(storage={0: (24601 + suicide_write_delta)}) + contract_7: Account( + storage={ + 0: ( + 24601 + + cold_set_delta + + suicide_new_delta + + cold_account_delta + ) + } + ) }, }, { "indexes": {"data": [12], "gas": -1, "value": -1}, "network": [">=Cancun"], "result": { - contract_9: Account(storage={0: 100 + new_account_delta}) + contract_9: Account(storage={0: 100 + suicide_new_delta}) }, }, { "indexes": {"data": [13], "gas": -1, "value": -1}, "network": [">=Cancun"], "result": { - contract_9: Account(storage={0: 4600 + new_account_delta}) + contract_9: Account( + storage={ + 0: 4600 + + suicide_new_delta + + cold_account_delta + + cold_storage_delta + } + ) }, }, { @@ -1524,7 +1585,7 @@ def test_varied_context( 268: 103, 269: 103, 270: 103, - 271: (20003 + sstore_set_delta), + 271: (20003 + warm_set_delta), 512: 100, 513: 100, 514: 100, @@ -1541,22 +1602,22 @@ def test_varied_context( 525: 100, 526: 100, 527: 100, - 768: (20003 + sstore_set_delta), - 769: (20003 + sstore_set_delta), - 770: (20003 + sstore_set_delta), - 771: (20003 + sstore_set_delta), - 772: (20003 + sstore_set_delta), - 773: (20003 + sstore_set_delta), - 774: (20003 + sstore_set_delta), - 775: (20003 + sstore_set_delta), - 776: (20003 + sstore_set_delta), - 777: (20003 + sstore_set_delta), - 778: (20003 + sstore_set_delta), - 779: (20003 + sstore_set_delta), - 780: (20003 + sstore_set_delta), - 781: (20003 + sstore_set_delta), - 782: (20003 + sstore_set_delta), - 783: (20003 + sstore_set_delta), + 768: (20003 + warm_set_delta), + 769: (20003 + warm_set_delta), + 770: (20003 + warm_set_delta), + 771: (20003 + warm_set_delta), + 772: (20003 + warm_set_delta), + 773: (20003 + warm_set_delta), + 774: (20003 + warm_set_delta), + 775: (20003 + warm_set_delta), + 776: (20003 + warm_set_delta), + 777: (20003 + warm_set_delta), + 778: (20003 + warm_set_delta), + 779: (20003 + warm_set_delta), + 780: (20003 + warm_set_delta), + 781: (20003 + warm_set_delta), + 782: (20003 + warm_set_delta), + 783: (20003 + warm_set_delta), 1024: 100, 1025: 100, 1026: 100, @@ -1617,7 +1678,7 @@ def test_varied_context( 268: 103, 269: 103, 270: 103, - 271: (22103 + sstore_set_delta), + 271: (22103 + cold_set_delta), 512: 100, 513: 100, 514: 100, @@ -1633,39 +1694,39 @@ def test_varied_context( 524: 100, 525: 100, 526: 100, - 527: 2100, - 768: (22103 + sstore_set_delta), - 769: (22103 + sstore_set_delta), - 770: (22103 + sstore_set_delta), - 771: (22103 + sstore_set_delta), - 772: (22103 + sstore_set_delta), - 773: (22103 + sstore_set_delta), - 774: (22103 + sstore_set_delta), - 775: (22103 + sstore_set_delta), - 776: (22103 + sstore_set_delta), - 777: (22103 + sstore_set_delta), - 778: (22103 + sstore_set_delta), - 779: (22103 + sstore_set_delta), - 780: (22103 + sstore_set_delta), - 781: (22103 + sstore_set_delta), - 782: (22103 + sstore_set_delta), - 783: (22103 + sstore_set_delta), - 1024: 2100, - 1025: 2100, - 1026: 2100, - 1027: 2100, - 1028: 2100, - 1029: 2100, - 1030: 2100, - 1031: 2100, - 1032: 2100, - 1033: 2100, - 1034: 2100, - 1035: 2100, - 1036: 2100, - 1037: 2100, - 1038: 2100, - 1039: 2100, + 527: 2100 + cold_storage_delta, + 768: (22103 + cold_set_delta), + 769: (22103 + cold_set_delta), + 770: (22103 + cold_set_delta), + 771: (22103 + cold_set_delta), + 772: (22103 + cold_set_delta), + 773: (22103 + cold_set_delta), + 774: (22103 + cold_set_delta), + 775: (22103 + cold_set_delta), + 776: (22103 + cold_set_delta), + 777: (22103 + cold_set_delta), + 778: (22103 + cold_set_delta), + 779: (22103 + cold_set_delta), + 780: (22103 + cold_set_delta), + 781: (22103 + cold_set_delta), + 782: (22103 + cold_set_delta), + 783: (22103 + cold_set_delta), + 1024: 2100 + cold_storage_delta, + 1025: 2100 + cold_storage_delta, + 1026: 2100 + cold_storage_delta, + 1027: 2100 + cold_storage_delta, + 1028: 2100 + cold_storage_delta, + 1029: 2100 + cold_storage_delta, + 1030: 2100 + cold_storage_delta, + 1031: 2100 + cold_storage_delta, + 1032: 2100 + cold_storage_delta, + 1033: 2100 + cold_storage_delta, + 1034: 2100 + cold_storage_delta, + 1035: 2100 + cold_storage_delta, + 1036: 2100 + cold_storage_delta, + 1037: 2100 + cold_storage_delta, + 1038: 2100 + cold_storage_delta, + 1039: 2100 + cold_storage_delta, 24743: 57005, 48879: 2, 61440: 48879, @@ -1693,7 +1754,7 @@ def test_varied_context( "network": [">=Cancun"], "result": { compute_create_address(address=contract_18, nonce=0): Account( - storage={0: 65535, 1: (20017 + sstore_set_delta)} + storage={0: 65535, 1: (20017 + warm_set_delta)} ), }, }, @@ -1702,7 +1763,7 @@ def test_varied_context( "network": [">=Cancun"], "result": { compute_create_address(address=contract_18, nonce=0): Account( - storage={0: 65535, 1: (22117 + sstore_set_delta)} + storage={0: 65535, 1: (22117 + cold_set_delta)} ), }, }, @@ -1711,7 +1772,7 @@ def test_varied_context( "network": [">=Cancun"], "result": { Address(0xD82F21135ED7D7D833A9F2A0F1CF6C3DA214B8E3): Account( - storage={0: 65535, 1: (20017 + sstore_set_delta)} + storage={0: 65535, 1: (20017 + warm_set_delta)} ), }, }, @@ -1720,7 +1781,7 @@ def test_varied_context( "network": [">=Cancun"], "result": { Address(0xD82F21135ED7D7D833A9F2A0F1CF6C3DA214B8E3): Account( - storage={0: 65535, 1: (22117 + sstore_set_delta)} + storage={0: 65535, 1: (22117 + cold_set_delta)} ), }, }, @@ -1729,7 +1790,7 @@ def test_varied_context( "network": [">=Cancun"], "result": { compute_create_address(address=contract_20, nonce=0): Account( - storage={0: 65535, 1: (20017 + sstore_set_delta)} + storage={0: 65535, 1: (20017 + warm_set_delta)} ), }, }, @@ -1738,7 +1799,7 @@ def test_varied_context( "network": [">=Cancun"], "result": { compute_create_address(address=contract_20, nonce=0): Account( - storage={0: 65535, 1: (22117 + sstore_set_delta)} + storage={0: 65535, 1: (22117 + cold_set_delta)} ), }, }, @@ -1747,7 +1808,7 @@ def test_varied_context( "network": [">=Cancun"], "result": { Address(0x530508498D2AA75D8E591612809FEC3D37A45615): Account( - storage={0: 65535, 1: (20017 + sstore_set_delta)} + storage={0: 65535, 1: (20017 + warm_set_delta)} ), }, }, @@ -1756,7 +1817,7 @@ def test_varied_context( "network": [">=Cancun"], "result": { Address(0x530508498D2AA75D8E591612809FEC3D37A45615): Account( - storage={0: 65535, 1: (22117 + sstore_set_delta)} + storage={0: 65535, 1: (22117 + cold_set_delta)} ), }, }, @@ -1765,7 +1826,7 @@ def test_varied_context( "network": [">=Cancun"], "result": { compute_create_address(address=contract_22, nonce=0): Account( - storage={0: 65535, 1: (20017 + sstore_set_delta), 2: 117} + storage={0: 65535, 1: (20017 + warm_set_delta), 2: 117} ), }, }, @@ -1774,7 +1835,7 @@ def test_varied_context( "network": [">=Cancun"], "result": { compute_create_address(address=contract_22, nonce=0): Account( - storage={0: 65535, 1: (22117 + sstore_set_delta), 2: 117} + storage={0: 65535, 1: (22117 + cold_set_delta), 2: 117} ), }, }, @@ -1783,7 +1844,7 @@ def test_varied_context( "network": [">=Cancun"], "result": { Address(0x83FBDAE70258AC0FA837B701CC63CEDF48D4B6BF): Account( - storage={0: 65535, 1: (20017 + sstore_set_delta), 2: 117} + storage={0: 65535, 1: (20017 + warm_set_delta), 2: 117} ), }, }, @@ -1792,7 +1853,7 @@ def test_varied_context( "network": [">=Cancun"], "result": { Address(0x83FBDAE70258AC0FA837B701CC63CEDF48D4B6BF): Account( - storage={0: 65535, 1: (22117 + sstore_set_delta), 2: 117} + storage={0: 65535, 1: (22117 + cold_set_delta), 2: 117} ), }, }, @@ -1801,7 +1862,7 @@ def test_varied_context( "network": [">=Cancun"], "result": { contract_25: Account( - storage={0: 24743, 1: (20017 + sstore_set_delta), 2: 117} + storage={0: 24743, 1: (20017 + warm_set_delta), 2: 117} ) }, }, @@ -1810,7 +1871,7 @@ def test_varied_context( "network": [">=Cancun"], "result": { contract_25: Account( - storage={0: 24743, 1: (22117 + sstore_set_delta), 2: 117} + storage={0: 24743, 1: (22117 + cold_set_delta), 2: 117} ) }, }, diff --git a/tests/ported_static/stEIP3651_warmcoinbase/test_coinbase_warm_account_call_gas.py b/tests/ported_static/stEIP3651_warmcoinbase/test_coinbase_warm_account_call_gas.py index 7e8250c3ad0..1316363c63c 100644 --- a/tests/ported_static/stEIP3651_warmcoinbase/test_coinbase_warm_account_call_gas.py +++ b/tests/ported_static/stEIP3651_warmcoinbase/test_coinbase_warm_account_call_gas.py @@ -3,6 +3,8 @@ Ported from: state_tests/Shanghai/stEIP3651_warmcoinbase/coinbaseWarmAccountCallGasFiller.yml +@manually-enhanced: Do not overwrite. When EIP-8038 is enabled, +EXTCODESIZE and EXTCODECOPY charge an extra warm code-read. """ import pytest @@ -278,6 +280,11 @@ def test_coinbase_warm_account_call_gas( nonce=1, ) - post = {target: Account(storage={0: 100})} + warm_access = fork.gas_costs().WARM_ACCESS + # EIP-8038 charges EXTCODESIZE (d0) and EXTCODECOPY (d1) a second + # WARM_ACCESS for the account code read; other opcodes are unchanged. + ext_code_read = d in (0, 1) and fork.is_eip_enabled(8038) + expected_gas = warm_access + (warm_access if ext_code_read else 0) + post = {target: Account(storage={0: expected_gas})} state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stMemExpandingEIP150Calls/test_call_and_callcode_consume_more_gas_then_transaction_has_with_mem_expanding_calls.py b/tests/ported_static/stMemExpandingEIP150Calls/test_call_and_callcode_consume_more_gas_then_transaction_has_with_mem_expanding_calls.py index ff44e5b435b..d9ae39a4bb4 100644 --- a/tests/ported_static/stMemExpandingEIP150Calls/test_call_and_callcode_consume_more_gas_then_transaction_has_with_mem_expanding_calls.py +++ b/tests/ported_static/stMemExpandingEIP150Calls/test_call_and_callcode_consume_more_gas_then_transaction_has_with_mem_expanding_calls.py @@ -3,6 +3,14 @@ Ported from: state_tests/stMemExpandingEIP150Calls/CallAndCallcodeConsumeMoreGasThenTransactionHasWithMemExpandingCallsFiller.json + +@manually-enhanced: Do not overwrite. The post-state asserts the +remaining-gas snapshot stored by `Op.GAS` (slot 8 == 0x8D5B6), which +fixes the post-intrinsic execution budget. So `gas_limit` is derived +from the fork as `600_000 + (intrinsic - 21_000)`: it shifts the budget +by the intrinsic delta from the pre-EIP-2780 Cancun `TX_BASE` baseline +of 21_000, keeping the budget constant across the EIP-2780 intrinsic +decomposition and EIP-8038 access repricing. Do not hardcode 600_000. """ import pytest @@ -12,6 +20,7 @@ Alloc, Bytes, Environment, + Fork, StateTestFiller, Transaction, ) @@ -31,6 +40,7 @@ def test_call_and_callcode_consume_more_gas_then_transaction_has_with_mem_expanding_calls( # noqa: E501 state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_call_and_callcode_consume_more_gas_then_transaction_has_with_m...""" # noqa: E501 coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) @@ -82,11 +92,19 @@ def test_call_and_callcode_consume_more_gas_then_transaction_has_with_mem_expand nonce=0, ) + # The original test was built against Cancun's ``TX_BASE`` of + # 21_000. EIP-2780 lowers the intrinsic for non-self non-value + # txs, so shift ``gas_limit`` by the intrinsic delta to preserve + # the post-intrinsic execution budget the Op.GAS storage + # assertion depends on. + intrinsic = fork.transaction_intrinsic_cost_calculator()() + gas_limit = 600_000 + (intrinsic - 21_000) + tx = Transaction( sender=sender, to=target, data=Bytes(""), - gas_limit=600000, + gas_limit=gas_limit, ) post = { diff --git a/tests/ported_static/stMemExpandingEIP150Calls/test_delegate_call_on_eip_with_mem_expanding_calls.py b/tests/ported_static/stMemExpandingEIP150Calls/test_delegate_call_on_eip_with_mem_expanding_calls.py index dec68916fab..7e3953c09c0 100644 --- a/tests/ported_static/stMemExpandingEIP150Calls/test_delegate_call_on_eip_with_mem_expanding_calls.py +++ b/tests/ported_static/stMemExpandingEIP150Calls/test_delegate_call_on_eip_with_mem_expanding_calls.py @@ -3,6 +3,16 @@ Ported from: state_tests/stMemExpandingEIP150Calls/DelegateCallOnEIPWithMemExpandingCallsFiller.json + +@manually-enhanced: Do not overwrite. The post-state asserts the GAS +opcode value stored at target slot 8 (0x8D5B6), which depends on the +execution budget left after the intrinsic charge. The original test +hardcoded `gas_limit` against Cancun's `TX_BASE` of 21_000; EIP-2780 +lowers the intrinsic for non-self non-value txs, so `gas_limit` is +derived from the fork as `600_000 + (intrinsic - 21_000)`, subtracting +the pre-EIP-2780 baseline 21_000 so the budget is invariant across the +intrinsic decomposition and EIP-8038 access repricing. Do not hardcode +the literal gas_limit. """ import pytest @@ -12,6 +22,7 @@ Alloc, Bytes, Environment, + Fork, StateTestFiller, Transaction, ) @@ -31,6 +42,7 @@ def test_delegate_call_on_eip_with_mem_expanding_calls( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_delegate_call_on_eip_with_mem_expanding_calls.""" coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) @@ -69,11 +81,19 @@ def test_delegate_call_on_eip_with_mem_expanding_calls( nonce=0, ) + # The original test was built against Cancun's ``TX_BASE`` of + # 21_000. EIP-2780 lowers the intrinsic for non-self non-value + # txs, so shift ``gas_limit`` by the intrinsic delta to preserve + # the post-intrinsic execution budget the Op.GAS storage + # assertion depends on. + intrinsic = fork.transaction_intrinsic_cost_calculator()() + gas_limit = 600_000 + (intrinsic - 21_000) + tx = Transaction( sender=sender, to=target, data=Bytes(""), - gas_limit=600000, + gas_limit=gas_limit, ) post = { diff --git a/tests/ported_static/stMemoryTest/test_oog.py b/tests/ported_static/stMemoryTest/test_oog.py index 5c2f1cc4f01..cd47deb6687 100644 --- a/tests/ported_static/stMemoryTest/test_oog.py +++ b/tests/ported_static/stMemoryTest/test_oog.py @@ -3,6 +3,15 @@ Ported from: state_tests/stMemoryTest/oogFiller.yml + +@manually-enhanced: Do not overwrite. Each parametrization forwards a +fixed in-bytecode gas budget to an inner operation and asserts whether +it succeeds. The `0x3E` (RETURNDATACOPY) success case routes through a +nested value-0 CALL to a cold contract; EIP-8038's cold account access +reprice consumes the budget's slack and OOGs the copy. Bump only that +budget by the fork-derived `COLD_ACCOUNT_ACCESS - 2600` so the success +path stays funded; the value is exactly 0 before EIP-8038 and all +other budgets are untouched. """ import pytest @@ -297,6 +306,11 @@ def test_oog( v: int, ) -> None: """Ori Pomerantz qbzzt1@gmail.""" + # EIP-8038 cold account access reprice; 0 before EIP-8038. The + # `0x3E` RETURNDATACOPY success case forwards just enough gas for a + # nested CALL to a cold contract plus the copy; the reprice eats the + # slack, so add it back to that one budget. + cold_account_delta = fork.gas_costs().COLD_ACCOUNT_ACCESS - 2600 coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) contract_0 = Address(0x0000000000000000000000000000000000010020) contract_1 = Address(0x0000000000000000000000000000000000010037) @@ -753,7 +767,7 @@ def test_oog( Bytes("1a8451e6") + Hash(0x3C) + Hash(0xFFFF), Bytes("1a8451e6") + Hash(0x3C) + Hash(0x2BC), Bytes("1a8451e6") + Hash(0x3E) + Hash(0xFFFF), - Bytes("1a8451e6") + Hash(0x3E) + Hash(0xC02), + Bytes("1a8451e6") + Hash(0x3E) + Hash(0xC02 + cold_account_delta), Bytes("1a8451e6") + Hash(0x3E) + Hash(0x7D0), Bytes("1a8451e6") + Hash(0x3E) + Hash(0xC01), Bytes("1a8451e6") + Hash(0x51) + Hash(0xFFFF), diff --git a/tests/ported_static/stNonZeroCallsTest/test_non_zero_value_call_to_non_non_zero_balance.py b/tests/ported_static/stNonZeroCallsTest/test_non_zero_value_call_to_non_non_zero_balance.py index 462c93ae2ae..5df9cd42733 100644 --- a/tests/ported_static/stNonZeroCallsTest/test_non_zero_value_call_to_non_non_zero_balance.py +++ b/tests/ported_static/stNonZeroCallsTest/test_non_zero_value_call_to_non_non_zero_balance.py @@ -3,6 +3,15 @@ Ported from: state_tests/stNonZeroCallsTest/NonZeroValue_CALL_ToNonNonZeroBalanceFiller.json + +@manually-enhanced: Do not overwrite. The measured slot captures the +regular gas of a value-1 CALL to a cold, alive EOA plus the SSTORE +storing the (success) result. EIP-8038 reprices the CALL's cold +account access and value transfer, and reprices the cold +value-unchanged SSTORE. The delta is therefore +`(COLD_ACCOUNT_ACCESS - 2600) + (CALL_VALUE - 9000)` plus the cold +SSTORE reprice, each derived from the fork and exactly 0 before +EIP-8038. """ import pytest @@ -15,6 +24,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -31,8 +41,24 @@ def test_non_zero_value_call_to_non_non_zero_balance( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_non_zero_value_call_to_non_non_zero_balance.""" + # EIP-8038 deltas, each 0 before EIP-8038. The CALL pays the cold + # account reprice and the value-transfer reprice; the cold + # value-unchanged SSTORE gains its own reprice. + gas_costs = fork.gas_costs() + cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 + call_value_delta = gas_costs.CALL_VALUE - 9000 + cold_noop_sstore_delta = ( + Op.SSTORE.with_metadata( + key_warm=False, original_value=0, current_value=0, new_value=0 + ).gas_cost(fork) + - 2200 + ) + call_measure_delta = ( + cold_account_delta + call_value_delta + cold_noop_sstore_delta + ) coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) sender = pre.fund_eoa(amount=0xE8D4A51000) @@ -76,7 +102,7 @@ def test_non_zero_value_call_to_non_non_zero_balance( post = { addr: Account(balance=100), - target: Account(storage={100: 11535}), + target: Account(storage={100: 11535 + call_measure_delta}), } state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stNonZeroCallsTest/test_non_zero_value_callcode_to_non_non_zero_balance.py b/tests/ported_static/stNonZeroCallsTest/test_non_zero_value_callcode_to_non_non_zero_balance.py index 20ecf378e6b..a55087af42b 100644 --- a/tests/ported_static/stNonZeroCallsTest/test_non_zero_value_callcode_to_non_non_zero_balance.py +++ b/tests/ported_static/stNonZeroCallsTest/test_non_zero_value_callcode_to_non_non_zero_balance.py @@ -3,6 +3,15 @@ Ported from: state_tests/stNonZeroCallsTest/NonZeroValue_CALLCODE_ToNonNonZeroBalanceFiller.json + +@manually-enhanced: Do not overwrite. The measured slot captures the +regular gas of a value-1 CALLCODE to a cold, alive EOA plus the SSTORE +storing the (success) result. EIP-8038 reprices the CALLCODE's cold +account access and value transfer, and reprices the cold +value-unchanged SSTORE. The delta is therefore +`(COLD_ACCOUNT_ACCESS - 2600) + (CALL_VALUE - 9000)` plus the cold +SSTORE reprice, each derived from the fork and exactly 0 before +EIP-8038. """ import pytest @@ -15,6 +24,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -31,8 +41,24 @@ def test_non_zero_value_callcode_to_non_non_zero_balance( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_non_zero_value_callcode_to_non_non_zero_balance.""" + # EIP-8038 deltas, each 0 before EIP-8038. The CALLCODE pays the + # cold account reprice and the value-transfer reprice; the cold + # value-unchanged SSTORE gains its own reprice. + gas_costs = fork.gas_costs() + cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 + call_value_delta = gas_costs.CALL_VALUE - 9000 + cold_noop_sstore_delta = ( + Op.SSTORE.with_metadata( + key_warm=False, original_value=0, current_value=0, new_value=0 + ).gas_cost(fork) + - 2200 + ) + call_measure_delta = ( + cold_account_delta + call_value_delta + cold_noop_sstore_delta + ) coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) sender = pre.fund_eoa(amount=0xE8D4A51000) @@ -76,7 +102,7 @@ def test_non_zero_value_callcode_to_non_non_zero_balance( post = { addr: Account(balance=100), - target: Account(storage={100: 11535}), + target: Account(storage={100: 11535 + call_measure_delta}), } state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stPreCompiledContracts/test_precomps_eip2929_cancun.py b/tests/ported_static/stPreCompiledContracts/test_precomps_eip2929_cancun.py index 27dbb54b3af..cbd90e4a2a0 100644 --- a/tests/ported_static/stPreCompiledContracts/test_precomps_eip2929_cancun.py +++ b/tests/ported_static/stPreCompiledContracts/test_precomps_eip2929_cancun.py @@ -8,12 +8,14 @@ test measure the regular gas consumed by a CALL with value to an inactive precompile address. EIP-8037 replaces the Cancun-era CALL_NEW_ACCOUNT cost of 25 000 with a per-new-account state-gas -charge of `STATE_BYTES_PER_NEW_ACCOUNT (112) * COST_PER_STATE_BYTE -(1174) = 131 488`. With an empty reservoir (the case here), the -full state-gas spills back into regular gas, so `Op.GAS` reads -+106 488 (= 131 488 - 25 000) compared to Cancun. Bake that delta -into the two affected `[">=Cancun"]` expect-entries fork-condition- -ally; the third entry is gated to `["Cancun"]` only and unchanged. +charge that, with an empty reservoir (the case here), spills back +into regular gas; EIP-8038 also reprices the cold account access +from 2 600 to 3 000. `Op.GAS` therefore reads +`fork.create_state_gas() - 25 000 + COLD_ACCOUNT_ACCESS - 2 600` +extra regular gas compared to Cancun. Derive that delta from the +fork so it is 0 pre-EIP-8037 and tracks parameter changes; bake it +into the two affected `[">=Cancun"]` expect-entries. The third +entry is gated to `["Cancun"]` only and unchanged. """ import pytest @@ -3591,12 +3593,18 @@ def test_precomps_eip2929_cancun( nonce=1, ) - # EIP-8037 replaces the 25 000 CALL_NEW_ACCOUNT base cost with a - # 131 488 state-gas charge. With an empty reservoir the full - # state-gas spills into regular gas, so Op.GAS reads +106 488. + # These measurements isolate repriced components applied per + # expect-entry below: EIP-8037 replaces the 25 000 CALL_NEW_ACCOUNT + # base cost with a state-gas charge that, with an empty reservoir, + # spills back into regular gas; EIP-8038 separately reprices a cold + # account access from 2 600 to 3 000. Derive both from the fork so + # they are 0 pre-EIP-8037 and track parameter changes. `new` + # entries shift by the account delta, `no` entries by the cold + # delta, and `all` entries by both. new_account_delta = ( (fork.create_state_gas() - 25000) if fork.is_eip_enabled(8037) else 0 ) + cold_account_delta = fork.gas_costs().COLD_ACCOUNT_ACCESS - 2600 expect_entries_: list[dict] = [ { @@ -4060,7 +4068,9 @@ def test_precomps_eip2929_cancun( "value": -1, }, "network": [">=Cancun"], - "result": {target: Account(storage={0: 0, 1: 2500})}, + "result": { + target: Account(storage={0: 0, 1: 2500 + cold_account_delta}) + }, }, { "indexes": { @@ -4248,7 +4258,12 @@ def test_precomps_eip2929_cancun( }, "network": [">=Cancun"], "result": { - target: Account(storage={0: 0, 1: 27500 + new_account_delta}) + target: Account( + storage={ + 0: 0, + 1: 27500 + new_account_delta + cold_account_delta, + } + ) }, }, { diff --git a/tests/ported_static/stRefundTest/test_refund50_1.py b/tests/ported_static/stRefundTest/test_refund50_1.py index 1135b777d09..1dbb8c5ccb7 100644 --- a/tests/ported_static/stRefundTest/test_refund50_1.py +++ b/tests/ported_static/stRefundTest/test_refund50_1.py @@ -3,6 +3,16 @@ Ported from: state_tests/stRefundTest/refund50_1Filler.json + +@manually-enhanced: Do not overwrite. The post-state asserts the sender +balance, which equals its start minus `gas_used * gas_price`. The +contract clears five cold storage slots; EIP-8038 raises each cold +SSTORE-clear charge from 5000 to 13000. The EIP-3529 refund cap +(`gas_used // 5`) binds at both forks (the clear refunds far exceed a +fifth of gas used), so the extra charge raises `gas_used` by exactly +four fifths of itself. Derive the per-clear charge delta from the fork +gas model (0 pre-EIP-8037) and subtract `gas_price * 5 * delta * 4 // 5` +from the Cancun balance; do not hardcode the Amsterdam value. """ import pytest @@ -15,6 +25,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -29,6 +40,7 @@ def test_refund50_1( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_refund50_1.""" coinbase = Address(0xEB201D2887816E041F6E807E804F64F3A7A226FE) @@ -65,10 +77,23 @@ def test_refund50_1( gas_limit=100000, ) + # EIP-8038 raises each cold SSTORE-clear charge and EIP-2780 + # shifts the tx intrinsic. With the EIP-3529 refund cap binding, + # gas_used rises by 4/5 of the gross-gas delta. + cold_clear_delta = ( + Op.SSTORE.with_metadata( + key_warm=False, original_value=1, current_value=1, new_value=0 + ).gas_cost(fork) + - 5000 + ) + intrinsic_delta = fork.transaction_intrinsic_cost_calculator()() - 21_000 + gross_delta = 5 * cold_clear_delta + intrinsic_delta + extra_gas_used = gross_delta * 4 // 5 + post = { target: Account(storage={}), coinbase: Account(balance=0), - sender: Account(balance=0x92F810, nonce=1), + sender: Account(balance=0x92F810 - 10 * extra_gas_used, nonce=1), } state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stRefundTest/test_refund_call_a_not_enough_gas_in_call.py b/tests/ported_static/stRefundTest/test_refund_call_a_not_enough_gas_in_call.py index def38bc9f7b..eaf1f5a8c97 100644 --- a/tests/ported_static/stRefundTest/test_refund_call_a_not_enough_gas_in_call.py +++ b/tests/ported_static/stRefundTest/test_refund_call_a_not_enough_gas_in_call.py @@ -3,6 +3,16 @@ Ported from: state_tests/stRefundTest/refund_CallA_notEnoughGasInCallFiller.json + +@manually-enhanced: Do not overwrite. The post-state asserts the sender +balance, which equals its start minus `gas_used * gas_price`. The inner +CALL is starved of gas so its SSTORE clear always reverts (no refund +survives); the only surviving repricing is in the outer frame, where +EIP-8038 raises the cold account-access charged by the CALL (2600 -> +3000) and the cold no-op SSTORE of slot 0 (2200 -> 3000). Derive both +deltas from the fork gas model (0 pre-EIP-8037) and subtract +`gas_price * (call_access_delta + outer_sstore_delta)` from the Cancun +balance; do not hardcode the Amsterdam value. """ import pytest @@ -15,6 +25,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -29,6 +40,7 @@ def test_refund_call_a_not_enough_gas_in_call( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_refund_call_a_not_enough_gas_in_call.""" coinbase = Address(0xEB201D2887816E041F6E807E804F64F3A7A226FE) @@ -81,10 +93,24 @@ def test_refund_call_a_not_enough_gas_in_call( value=10, ) + # The inner SSTORE clear always reverts (gas-starved), so its refund + # never survives. Only the outer frame reprices under EIP-8038: the + # cold account access charged by the CALL and the cold no-op SSTORE + # of slot 0 (original == current == new == 0). + gas_costs = fork.gas_costs() + call_access_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 + outer_sstore_delta = ( + Op.SSTORE.with_metadata( + key_warm=False, original_value=0, current_value=0, new_value=0 + ).gas_cost(fork) + - 2200 + ) + gas_used_delta = call_access_delta + outer_sstore_delta + post = { target: Account(storage={1: 1}, balance=0xDE0B6B3A764000A), coinbase: Account(balance=0), - sender: Account(balance=0xA8DF4, nonce=1), + sender: Account(balance=0xA8DF4 - 10 * gas_used_delta, nonce=1), addr: Account(storage={1: 1}), } diff --git a/tests/ported_static/stRefundTest/test_refund_change_non_zero_storage.py b/tests/ported_static/stRefundTest/test_refund_change_non_zero_storage.py index b82dcefae41..800ea8835ea 100644 --- a/tests/ported_static/stRefundTest/test_refund_change_non_zero_storage.py +++ b/tests/ported_static/stRefundTest/test_refund_change_non_zero_storage.py @@ -3,6 +3,16 @@ Ported from: state_tests/stRefundTest/refund_changeNonZeroStorageFiller.json + +@manually-enhanced: Do not overwrite. The post-state asserts the sender +balance, which equals its start minus `gas_used * gas_price`. The +contract resets one warm-after-cold storage slot from a non-zero value +to another non-zero value (1 -> 23); EIP-8038 raises this cold +SSTORE-reset charge from 5000 to 13000. There is no storage-clear +refund, so `gas_used` rises by exactly the charge delta. Derive that +delta from the fork gas model (0 pre-EIP-8037) and subtract +`gas_price * delta` from the Cancun balance; do not hardcode the +Amsterdam value. """ import pytest @@ -15,6 +25,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -29,6 +40,7 @@ def test_refund_change_non_zero_storage( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_refund_change_non_zero_storage.""" coinbase = Address(0xEB201D2887816E041F6E807E804F64F3A7A226FE) @@ -61,10 +73,19 @@ def test_refund_change_non_zero_storage( value=10, ) + # EIP-8038 raises the cold SSTORE-reset charge (non-zero to non-zero); + # with no storage-clear refund, gas_used rises by the full delta. + cold_reset_delta = ( + Op.SSTORE.with_metadata( + key_warm=False, original_value=1, current_value=1, new_value=23 + ).gas_cost(fork) + - 5000 + ) + post = { target: Account(storage={1: 23}, balance=0xDE0B6B3A764000A), coinbase: Account(balance=0), - sender: Account(balance=0x3C2F689A, nonce=1), + sender: Account(balance=0x3C2F689A - 10 * cold_reset_delta, nonce=1), } state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stRefundTest/test_refund_ff.py b/tests/ported_static/stRefundTest/test_refund_ff.py index 06c1e9435f2..e0535dc9ab8 100644 --- a/tests/ported_static/stRefundTest/test_refund_ff.py +++ b/tests/ported_static/stRefundTest/test_refund_ff.py @@ -3,6 +3,16 @@ Ported from: state_tests/stRefundTest/refundFFFiller.yml + +@manually-enhanced: Do not overwrite. The post-state asserts the sender +balance, which equals its start minus `gas_used * gas_price`. The +contract self-destructs and sends its (zero) balance to a cold, already +existing beneficiary; EIP-8038 raises the cold account-access surcharge +from 2600 to 3000. No positive balance is moved, so no `ACCOUNT_WRITE` +applies and there is no refund, so `gas_used` rises by exactly the +SELFDESTRUCT charge delta. Derive that delta from the fork gas model +(0 pre-EIP-8037) and subtract `gas_price * delta` from the Cancun +balance; do not hardcode the Amsterdam value. """ import pytest @@ -15,6 +25,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -29,6 +40,7 @@ def test_refund_ff( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Ori Pomerantz qbzzt1@gmail.""" coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) @@ -64,6 +76,21 @@ def test_refund_ff( access_list=[], ) - post = {sender: Account(balance=0xE8D4A51000)} + # EIP-8038 raises the cold account-access surcharge applied by + # SELFDESTRUCT; with no balance transfer and no refund, gas_used + # rises by exactly this charge delta. + selfdestruct_delta = ( + Op.SELFDESTRUCT.with_metadata( + address_warm=False, account_new=False + ).gas_cost(fork) + - 7600 + ) + # EIP-2780 lowers the intrinsic for non-self non-value txs; the + # delta is negative on Amsterdam, so it reduces ``gas_used`` and + # raises the sender balance correspondingly. + intrinsic_delta = fork.transaction_intrinsic_cost_calculator()() - 21_000 + gas_used_delta = selfdestruct_delta + intrinsic_delta + + post = {sender: Account(balance=0xE8D4A51000 - 1000 * gas_used_delta)} state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stRefundTest/test_refund_get_ether_back.py b/tests/ported_static/stRefundTest/test_refund_get_ether_back.py index 81633765711..2411e062abd 100644 --- a/tests/ported_static/stRefundTest/test_refund_get_ether_back.py +++ b/tests/ported_static/stRefundTest/test_refund_get_ether_back.py @@ -3,6 +3,18 @@ Ported from: state_tests/stRefundTest/refund_getEtherBackFiller.json + +@manually-enhanced: Do not overwrite. The post-state asserts the sender +balance, which equals its start minus `gas_used * gas_price`. The +contract clears one cold storage slot (1 -> 0); EIP-8038 raises the cold +SSTORE-clear charge from 5000 to 13000 and the storage-clear refund from +4800 to 12480. The EIP-3529 refund cap (`gas_used // 5`) does not bind at +Cancun but does at Amsterdam, so the shift is modeled from the fork gas +model: reconstruct the cap-bounded `gas_used` from the fork-invariant +non-SSTORE gross gas plus the fork SSTORE charge minus the capped refund, +and subtract the same expression evaluated with the pre-repricing Cancun +charges (so the adjustment is exactly 0 pre-EIP-8037). Do not hardcode +the Amsterdam value. """ import pytest @@ -15,6 +27,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -29,6 +42,7 @@ def test_refund_get_ether_back( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_refund_get_ether_back.""" coinbase = Address(0xEB201D2887816E041F6E807E804F64F3A7A226FE) @@ -61,10 +75,41 @@ def test_refund_get_ether_back( value=10, ) + # Gas used = gross gas minus the capped storage-clear refund. The + # non-SSTORE gross gas comes from the fork's intrinsic calculator + # (covers TX_BASE and any EIP-2780 recipient/value surcharges) + # plus the two PUSH1s that feed the single SSTORE (STOP is free). + gas_costs = fork.gas_costs() + # ``return_cost_deducted_prior_execution=True`` returns the + # upfront-deducted intrinsic only (Prague's calc would otherwise + # return ``max(intrinsic, EIP-7623 floor)``). + intrinsic = fork.transaction_intrinsic_cost_calculator()( + sends_value=bool(tx.value), + return_cost_deducted_prior_execution=True, + ) + base_gross = intrinsic + 2 * gas_costs.VERY_LOW + cancun_base_gross = 21_000 + 2 * gas_costs.VERY_LOW + + def clear_gas_used( + sstore_charge: int, clear_refund: int, gross_base: int + ) -> int: + gross = gross_base + sstore_charge + return gross - min(clear_refund, gross // 5) + + sstore_charge = Op.SSTORE.with_metadata( + key_warm=False, original_value=1, current_value=1, new_value=0 + ).gas_cost(fork) + # Cancun charges 5000 for the clear and refunds 4800; subtracting the + # same model evaluated at those constants and the Cancun base makes + # this exactly 0 before the EIP-8037/8038 repricing. + gas_used_delta = clear_gas_used( + sstore_charge, gas_costs.REFUND_STORAGE_CLEAR, base_gross + ) - clear_gas_used(5000, 4800, cancun_base_gross) + post = { target: Account(storage={}, balance=0xDE0B6B3A764000A), coinbase: Account(balance=0), - sender: Account(balance=0x3CF4376A, nonce=1), + sender: Account(balance=0x3CF4376A - 10 * gas_used_delta, nonce=1), } state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stRefundTest/test_refund_max.py b/tests/ported_static/stRefundTest/test_refund_max.py index 4924bc77fa2..ac549958112 100644 --- a/tests/ported_static/stRefundTest/test_refund_max.py +++ b/tests/ported_static/stRefundTest/test_refund_max.py @@ -3,6 +3,16 @@ Ported from: state_tests/stRefundTest/refundMaxFiller.yml + +@manually-enhanced: Do not overwrite. The post-state asserts the sender +balance, which equals its start minus `gas_used * gas_price`. The +contract clears eight cold storage slots; EIP-8038 raises each cold +SSTORE-clear charge from 5000 to 13000. The EIP-3529 refund cap +(`gas_used // 5`) binds at both forks (the clear refunds far exceed a +fifth of gas used), so the extra charge raises `gas_used` by exactly +four fifths of itself. Derive the per-clear charge delta from the fork +gas model (0 pre-EIP-8037) and subtract `gas_price * 8 * delta * 4 // 5` +from the Cancun balance; do not hardcode the Amsterdam value. """ import pytest @@ -15,6 +25,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -29,6 +40,7 @@ def test_refund_max( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Ori Pomerantz qbzzt1@gmail.""" coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) @@ -95,6 +107,19 @@ def test_refund_max( access_list=[], ) - post = {sender: Account(balance=0xE8D55F7E90)} + # EIP-8038 raises each cold SSTORE-clear charge and EIP-2780 + # shifts the tx intrinsic. With the EIP-3529 refund cap binding, + # gas_used rises by 4/5 of the gross-gas delta. + cold_clear_delta = ( + Op.SSTORE.with_metadata( + key_warm=False, original_value=1, current_value=1, new_value=0 + ).gas_cost(fork) + - 5000 + ) + intrinsic_delta = fork.transaction_intrinsic_cost_calculator()() - 21_000 + gross_delta = 8 * cold_clear_delta + intrinsic_delta + extra_gas_used = gross_delta * 4 // 5 + + post = {sender: Account(balance=0xE8D55F7E90 - 1000 * extra_gas_used)} state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stRefundTest/test_refund_multimple_suicide.py b/tests/ported_static/stRefundTest/test_refund_multimple_suicide.py index 015e02e5d2a..97571fbd22c 100644 --- a/tests/ported_static/stRefundTest/test_refund_multimple_suicide.py +++ b/tests/ported_static/stRefundTest/test_refund_multimple_suicide.py @@ -3,6 +3,15 @@ Ported from: state_tests/stRefundTest/refund_multimpleSuicideFiller.json + +@manually-enhanced: Do not overwrite. The post-state asserts the sender +balance, which the original fixture hardcoded as 0x61EC43A. EIP-2780 +decomposes the intrinsic cost and lowers it for non-self, non-value +txs, so the balance is derived from the fork model instead: take +`fork.transaction_intrinsic_cost_calculator()()` minus the pre-EIP-2780 +baseline 21_000, then add `gas_price (10) * |delta|` back to the sender +(the delta is negative on Amsterdam). This keeps the adjustment exactly +0 pre-EIP-2780. Do not hardcode the Amsterdam value. """ import pytest @@ -15,6 +24,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -29,6 +39,7 @@ def test_refund_multimple_suicide( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_refund_multimple_suicide.""" coinbase = Address(0xEB201D2887816E041F6E807E804F64F3A7A226FE) @@ -151,10 +162,14 @@ def test_refund_multimple_suicide( gas_limit=300000, ) + # EIP-2780 lowers the intrinsic for non-self non-value txs; the + # delta is negative on Amsterdam and raises the sender balance by + # ``gas_price * |delta|``. + intrinsic_delta = fork.transaction_intrinsic_cost_calculator()() - 21_000 post = { target: Account(balance=0xDE0B6B3A7640000), coinbase: Account(balance=0), - sender: Account(balance=0x61EC43A, nonce=1), + sender: Account(balance=0x61EC43A - 10 * intrinsic_delta, nonce=1), } state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stRefundTest/test_refund_no_oog_1.py b/tests/ported_static/stRefundTest/test_refund_no_oog_1.py index 79eb43dc804..b74b450caf3 100644 --- a/tests/ported_static/stRefundTest/test_refund_no_oog_1.py +++ b/tests/ported_static/stRefundTest/test_refund_no_oog_1.py @@ -3,6 +3,17 @@ Ported from: state_tests/stRefundTest/refund_NoOOG_1Filler.json + +@manually-enhanced: Do not overwrite. The transaction supplies exactly +enough gas to clear one cold storage slot (1 -> 0) and no more (the "no +out-of-gas" boundary). EIP-8038 raises the cold SSTORE-clear charge from +5000 to 13000, so the gas limit must rise by that charge delta to keep +the slot clearing instead of running out of gas. The asserted sender +balance equals its start minus `gas_used * gas_price`, and `gas_used` +is the gross gas minus the storage-clear refund (capped by EIP-3529 only +at Amsterdam). Both the gas limit bump and the balance shift are derived +from the fork gas model and are exactly 0 pre-EIP-8037; do not hardcode +the Amsterdam values. """ import pytest @@ -15,6 +26,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -29,6 +41,7 @@ def test_refund_no_oog_1( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_refund_no_oog_1.""" coinbase = Address(0xEB201D2887816E041F6E807E804F64F3A7A226FE) @@ -53,17 +66,52 @@ def test_refund_no_oog_1( nonce=0, ) + # EIP-8038 raises the cold SSTORE-clear charge and EIP-2780 shifts + # the tx intrinsic; bump the gas limit by both deltas so the clear + # still lands exactly at the limit (the "no out-of-gas" boundary) + # instead of running out of gas. + sstore_charge = Op.SSTORE.with_metadata( + key_warm=False, original_value=1, current_value=1, new_value=0 + ).gas_cost(fork) + cold_clear_delta = sstore_charge - 5000 + # ``return_cost_deducted_prior_execution=True`` returns the + # upfront-deducted intrinsic only (Prague's calc would otherwise + # return ``max(intrinsic, EIP-7623 floor)``). + intrinsic = fork.transaction_intrinsic_cost_calculator()( + return_cost_deducted_prior_execution=True, + ) + intrinsic_delta = intrinsic - 21_000 + tx = Transaction( sender=sender, to=target, data=Bytes(""), - gas_limit=26006, + gas_limit=26006 + cold_clear_delta + intrinsic_delta, ) + # Gas used = gross gas minus the capped storage-clear refund. The + # non-SSTORE gross gas comes from the fork's intrinsic calculator + # (covers TX_BASE and any EIP-2780 recipient surcharge) plus the + # two PUSH1s that feed the single SSTORE (STOP is free). + gas_costs = fork.gas_costs() + base_gross = intrinsic + 2 * gas_costs.VERY_LOW + cancun_base_gross = 21_000 + 2 * gas_costs.VERY_LOW + + def clear_gas_used(charge: int, clear_refund: int, gross_base: int) -> int: + gross = gross_base + charge + return gross - min(clear_refund, gross // 5) + + # Cancun charges 5000 for the clear and refunds 4800; subtracting the + # same model evaluated at those constants and the Cancun base makes + # this exactly 0 before the EIP-8037/8038 repricing. + gas_used_delta = clear_gas_used( + sstore_charge, gas_costs.REFUND_STORAGE_CLEAR, base_gross + ) - clear_gas_used(5000, 4800, cancun_base_gross) + post = { target: Account(storage={}), coinbase: Account(balance=0), - sender: Account(balance=0x9D0314, nonce=1), + sender: Account(balance=0x9D0314 - 10 * gas_used_delta, nonce=1), } state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stRefundTest/test_refund_single_suicide.py b/tests/ported_static/stRefundTest/test_refund_single_suicide.py index 4cee829af0d..a1da9e3e582 100644 --- a/tests/ported_static/stRefundTest/test_refund_single_suicide.py +++ b/tests/ported_static/stRefundTest/test_refund_single_suicide.py @@ -3,6 +3,15 @@ Ported from: state_tests/stRefundTest/refund_singleSuicideFiller.json + +@manually-enhanced: Do not overwrite. The post-state asserts the sender +balance, which the original fixture hardcoded. EIP-2780 decomposes the +intrinsic and lowers it for this non-self, non-value tx, so the balance +is derived from the fork: ``intrinsic_delta`` subtracts the pre-EIP-2780 +baseline intrinsic 21_000 from the fork's intrinsic calculator (the +literal 21_000 is the old TX_BASE), making the delta 0 pre-EIP-2780 and +negative on Amsterdam. The sender balance is then adjusted by +``gas_price * intrinsic_delta`` (base fee 10). Do not hardcode it. """ import pytest @@ -15,6 +24,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -29,6 +39,7 @@ def test_refund_single_suicide( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_refund_single_suicide.""" coinbase = Address(0xEB201D2887816E041F6E807E804F64F3A7A226FE) @@ -126,10 +137,14 @@ def test_refund_single_suicide( gas_limit=300000, ) + # EIP-2780 lowers the intrinsic for non-self non-value txs; the + # delta is negative on Amsterdam and raises the sender balance by + # ``gas_price * |delta|``. + intrinsic_delta = fork.transaction_intrinsic_cost_calculator()() - 21_000 post = { target: Account(balance=0xDE0B6B3A7640000), coinbase: Account(balance=0), - sender: Account(balance=0x1C5AF34, nonce=1), + sender: Account(balance=0x1C5AF34 - 10 * intrinsic_delta, nonce=1), } state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stRefundTest/test_refund_sstore.py b/tests/ported_static/stRefundTest/test_refund_sstore.py index 9a8cd6f7ddc..732f909022e 100644 --- a/tests/ported_static/stRefundTest/test_refund_sstore.py +++ b/tests/ported_static/stRefundTest/test_refund_sstore.py @@ -3,6 +3,18 @@ Ported from: state_tests/stRefundTest/refundSSTOREFiller.yml + +@manually-enhanced: Do not overwrite. The post-state asserts the sender +balance, which equals its start minus `gas_used * gas_price`. The +contract clears one cold storage slot (non-zero -> 0); EIP-8038 raises +the cold SSTORE-clear charge from 5000 to 13000 and the storage-clear +refund from 4800 to 12480. The EIP-3529 refund cap (`gas_used // 5`) does +not bind at Cancun but does at Amsterdam, so the shift is modeled from +the fork gas model: reconstruct the cap-bounded `gas_used` from the +fork-invariant non-SSTORE gross gas plus the fork SSTORE charge minus the +capped refund, and subtract the same expression evaluated with the +pre-repricing Cancun charges (so the adjustment is exactly 0 +pre-EIP-8037). Do not hardcode the Amsterdam value. """ import pytest @@ -15,6 +27,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -29,6 +42,7 @@ def test_refund_sstore( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Ori Pomerantz qbzzt1@gmail.""" coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) @@ -65,6 +79,42 @@ def test_refund_sstore( access_list=[], ) - post = {sender: Account(balance=0xE8D4EE4E00)} + # Gas used = gross gas minus the capped storage-clear refund. The + # non-SSTORE gross gas comes from the fork's intrinsic calculator + # (covers TX_BASE, calldata, and any EIP-2780 recipient surcharge) + # plus the PUSH1 and DUP1 that feed the SSTORE (STOP is free). + gas_costs = fork.gas_costs() + # ``return_cost_deducted_prior_execution=True`` returns the + # upfront-deducted intrinsic only. Without it, Prague's + # ``intrinsic_calc`` returns ``max(intrinsic, EIP-7623 floor)`` — + # the floor only binds for data-heavy txs with little execution, + # which is not the case here. + intrinsic = fork.transaction_intrinsic_cost_calculator()( + calldata=tx.data, + return_cost_deducted_prior_execution=True, + ) + base_gross = intrinsic + 2 * gas_costs.VERY_LOW + # Cancun's intrinsic for this tx shape was 21_004 (TX_BASE + + # single zero-byte). Capture it as the baseline so the Cancun + # branch of ``gas_used_delta`` evaluates at the original base. + cancun_base_gross = 21_004 + 2 * gas_costs.VERY_LOW + + def clear_gas_used( + sstore_charge: int, clear_refund: int, gross_base: int + ) -> int: + gross = gross_base + sstore_charge + return gross - min(clear_refund, gross // 5) + + sstore_charge = Op.SSTORE.with_metadata( + key_warm=False, original_value=24743, current_value=24743, new_value=0 + ).gas_cost(fork) + # Cancun charges 5000 for the clear and refunds 4800; subtracting the + # same model evaluated at those constants and the Cancun base makes + # this exactly 0 before the EIP-8037/8038 repricing. + gas_used_delta = clear_gas_used( + sstore_charge, gas_costs.REFUND_STORAGE_CLEAR, base_gross + ) - clear_gas_used(5000, 4800, cancun_base_gross) + + post = {sender: Account(balance=0xE8D4EE4E00 - 1000 * gas_used_delta)} state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stRevertTest/test_revert_opcode_calls.py b/tests/ported_static/stRevertTest/test_revert_opcode_calls.py index 1089825aeca..e8dd0e77288 100644 --- a/tests/ported_static/stRevertTest/test_revert_opcode_calls.py +++ b/tests/ported_static/stRevertTest/test_revert_opcode_calls.py @@ -5,7 +5,13 @@ state_tests/stRevertTest/RevertOpcodeCallsFiller.json @manually-enhanced: Do not overwrite. Gas bumped fork-conditionally to cover EIP-8037 state-gas spill into regular gas; pre-EIP-8037 -behavior unchanged. +behavior unchanged. The d3 call chain ends in a fresh SSTORE-set in +the outermost (transaction) frame; with an empty state-gas reservoir +that set's state gas spills into regular gas, so the success path +(g=0) runs out at the final `SSTORE` unless the outer budget absorbs +the spill. Lift `tx_gas[0]` by one fresh-set SSTORE state cost via +`fork.oog_budget_lift`, which is exactly 0 pre-EIP-8037 and tracks +the parameter. g=1 (the OoG case) keeps the original budget. """ @@ -334,7 +340,12 @@ def test_revert_opcode_calls( Hash(addr_3, left_padding=True), Hash(addr_4, left_padding=True), ] - tx_gas = [460000, 83622] + # The g=0 success path bottoms out on a fresh SSTORE-set in the + # transaction frame whose EIP-8037 state gas spills (empty + # reservoir). Lift the outer budget by that spilled state cost so + # the chain still completes on Amsterdam; 0 pre-EIP-8037. + g0_lift = fork.oog_budget_lift(sstores_before_oog=1) + tx_gas = [460000 + g0_lift, 83622] tx = Transaction( sender=sender, diff --git a/tests/ported_static/stSpecialTest/test_eoa_empty_paris.py b/tests/ported_static/stSpecialTest/test_eoa_empty_paris.py index 5c93c366823..b6e28c96397 100644 --- a/tests/ported_static/stSpecialTest/test_eoa_empty_paris.py +++ b/tests/ported_static/stSpecialTest/test_eoa_empty_paris.py @@ -3,6 +3,14 @@ Ported from: state_tests/stSpecialTest/eoaEmptyParisFiller.yml + +@manually-enhanced: Do not overwrite. Two measured slots shift under +EIP-8038. Slot 0xF1 times a CALL that forwards `value` to the (warm) +origin EOA: when `value` is nonzero it gains the value-transfer +reprice `CALL_VALUE - 9000`; the value-0 cases are unchanged. Slot +0xFF times a value-0 CALL to a cold contract and gains the cold +account reprice `COLD_ACCOUNT_ACCESS - 2600`. Both deltas come from +the fork's own gas model, so each is exactly 0 before EIP-8038. """ import pytest @@ -97,6 +105,13 @@ def test_eoa_empty_paris( v: int, ) -> None: """Test_eoa_empty_paris.""" + # EIP-8038 deltas, each 0 before EIP-8038. Slot 0xF1's value-bearing + # CALL to the warm origin gains the value-transfer reprice; slot + # 0xFF's value-0 CALL to a cold contract gains the cold account + # reprice. + gas_costs = fork.gas_costs() + call_value_delta = gas_costs.CALL_VALUE - 9000 + cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) contract_0 = Address(0x000000000000000000000000000000000000BAD1) contract_1 = Address(0x000000000000000000000000000000000000BAD2) @@ -244,7 +259,7 @@ def test_eoa_empty_paris( 59: 0, 63: 0xC5D2460186F7233C927E7DB2DCC703C0E500B653CA82273B7BFAD8045D85A470, # noqa: E501 241: 118, - 255: 7626, + 255: 7626 + cold_account_delta, 319: 0, 47825: 0xC5D2460186F7233C927E7DB2DCC703C0E500B653CA82273B7BFAD8045D85A470, # noqa: E501 47826: 0xC5D2460186F7233C927E7DB2DCC703C0E500B653CA82273B7BFAD8045D85A470, # noqa: E501 @@ -265,8 +280,8 @@ def test_eoa_empty_paris( 49: 0, 59: 0, 63: 0xC5D2460186F7233C927E7DB2DCC703C0E500B653CA82273B7BFAD8045D85A470, # noqa: E501 - 241: 6818, - 255: 7626, + 241: 6818 + call_value_delta, + 255: 7626 + cold_account_delta, 319: 0, 47825: 0xC5D2460186F7233C927E7DB2DCC703C0E500B653CA82273B7BFAD8045D85A470, # noqa: E501 47826: 0xC5D2460186F7233C927E7DB2DCC703C0E500B653CA82273B7BFAD8045D85A470, # noqa: E501 @@ -296,7 +311,7 @@ def test_eoa_empty_paris( 59: 0, 63: 0xC5D2460186F7233C927E7DB2DCC703C0E500B653CA82273B7BFAD8045D85A470, # noqa: E501 241: 118, - 255: 7626, + 255: 7626 + cold_account_delta, 319: 0, 47825: 0xC5D2460186F7233C927E7DB2DCC703C0E500B653CA82273B7BFAD8045D85A470, # noqa: E501 47826: 0xC5D2460186F7233C927E7DB2DCC703C0E500B653CA82273B7BFAD8045D85A470, # noqa: E501 @@ -317,8 +332,8 @@ def test_eoa_empty_paris( 49: 100, 59: 0, 63: 0xC5D2460186F7233C927E7DB2DCC703C0E500B653CA82273B7BFAD8045D85A470, # noqa: E501 - 241: 6818, - 255: 7626, + 241: 6818 + call_value_delta, + 255: 7626 + cold_account_delta, 319: 0, 47825: 0xC5D2460186F7233C927E7DB2DCC703C0E500B653CA82273B7BFAD8045D85A470, # noqa: E501 47826: 0xC5D2460186F7233C927E7DB2DCC703C0E500B653CA82273B7BFAD8045D85A470, # noqa: E501 @@ -340,7 +355,7 @@ def test_eoa_empty_paris( 59: 0, 63: 0xC5D2460186F7233C927E7DB2DCC703C0E500B653CA82273B7BFAD8045D85A470, # noqa: E501 241: 118, - 255: 7626, + 255: 7626 + cold_account_delta, 319: 0, 47825: 0xC5D2460186F7233C927E7DB2DCC703C0E500B653CA82273B7BFAD8045D85A470, # noqa: E501 47826: 0xC5D2460186F7233C927E7DB2DCC703C0E500B653CA82273B7BFAD8045D85A470, # noqa: E501 @@ -361,8 +376,8 @@ def test_eoa_empty_paris( 49: 0, 59: 0, 63: 0xC5D2460186F7233C927E7DB2DCC703C0E500B653CA82273B7BFAD8045D85A470, # noqa: E501 - 241: 6818, - 255: 7626, + 241: 6818 + call_value_delta, + 255: 7626 + cold_account_delta, 319: 0, 47825: 0xC5D2460186F7233C927E7DB2DCC703C0E500B653CA82273B7BFAD8045D85A470, # noqa: E501 47826: 0xC5D2460186F7233C927E7DB2DCC703C0E500B653CA82273B7BFAD8045D85A470, # noqa: E501 diff --git a/tests/ported_static/stStaticCall/test_static_call_change_revert.py b/tests/ported_static/stStaticCall/test_static_call_change_revert.py index 2d6b7295118..c13ba330562 100644 --- a/tests/ported_static/stStaticCall/test_static_call_change_revert.py +++ b/tests/ported_static/stStaticCall/test_static_call_change_revert.py @@ -3,22 +3,18 @@ Ported from: state_tests/stStaticCall/static_callChangeRevertFiller.json + +@manually-enhanced: Do not overwrite. """ import pytest from execution_testing import ( Account, - Address, Alloc, - Environment, - Hash, StateTestFiller, + Storage, Transaction, ) -from execution_testing.forks import Fork -from execution_testing.specs.static_state.expect_section import ( - resolve_expect_post, -) from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -29,267 +25,55 @@ ["state_tests/stStaticCall/static_callChangeRevertFiller.json"], ) @pytest.mark.valid_from("Cancun") -@pytest.mark.slow @pytest.mark.parametrize( - "d, g, v", + "sstore_in_static,oog", [ - pytest.param( - 0, - 0, - 0, - id="d0", - ), - pytest.param( - 1, - 0, - 0, - id="d1", - ), - pytest.param( - 2, - 0, - 0, - id="d2", - ), + pytest.param(False, False), + pytest.param(False, True), + pytest.param(True, False), ], ) -@pytest.mark.pre_alloc_mutable def test_static_call_change_revert( state_test: StateTestFiller, pre: Alloc, - fork: Fork, - d: int, - g: int, - v: int, + sstore_in_static: bool, + oog: bool, ) -> None: """Test_static_call_change_revert.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - sender = pre.fund_eoa(amount=0xDE0B6B3A7640000) + sender = pre.fund_eoa() - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=10000000, - ) + subcall_code = Op.MSTORE(offset=0x1, value=0x1) + if sstore_in_static: + subcall_code += Op.SSTORE(key=0x1, value=Op.SLOAD(key=0x1)) + subcall_code += Op.STOP + subcall_contract = pre.deploy_contract(subcall_code) - # Source: lll - # { (CALL 350000 (CALLDATALOAD 0) 0 0 0 0 0) } - target = pre.deploy_contract( # noqa: F841 - code=Op.CALL( - gas=0x55730, - address=Op.CALLDATALOAD(offset=0x0), - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) - + Op.STOP, - balance=0xDE0B6B3A7640000, - nonce=0, - address=Address(0x492BB18ADCE7DA2BED3592742FB4E3DF9086FB4C), # noqa: E501 - ) - # Source: lll - # { (MSTORE 1 1) } - addr_2 = pre.deploy_contract( # noqa: F841 - code=Op.MSTORE(offset=0x1, value=0x1) + Op.STOP, - nonce=0, - address=Address(0xC031FC0AA7B61A5D7D962AFEE8838DEC6948ABB7), # noqa: E501 - ) - # Source: lll - # { (MSTORE 1 1) (SSTORE 1 (SLOAD 1)) } - addr_5 = pre.deploy_contract( # noqa: F841 - code=Op.MSTORE(offset=0x1, value=0x1) - + Op.SSTORE(key=0x1, value=Op.SLOAD(key=0x1)) - + Op.STOP, - nonce=0, - address=Address(0x47C4ED3D93429CB8304737E2327B522E8928C9F3), # noqa: E501 - ) - # Source: lll - # { [[ 0 ]] (CALL 100000 1 0 0 0 0) [[ 1 ]] (STATICCALL 100000 0 0 0 0) [[ 2 ]] (CALL 100000 1 0 0 0 0) } # noqa: E501 - addr = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE( - key=0x0, - value=Op.CALL( - gas=0x186A0, - address=0xC031FC0AA7B61A5D7D962AFEE8838DEC6948ABB7, - value=0x1, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ), - ) - + Op.SSTORE( - key=0x1, - value=Op.STATICCALL( - gas=0x186A0, - address=0xC031FC0AA7B61A5D7D962AFEE8838DEC6948ABB7, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ), - ) - + Op.SSTORE( - key=0x2, - value=Op.CALL( - gas=0x186A0, - address=0xC031FC0AA7B61A5D7D962AFEE8838DEC6948ABB7, - value=0x1, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ), - ) - + Op.STOP, - balance=0xDE0B6B3A7640000, - nonce=0, - address=Address(0xE6F1FDAA1C99007971C641E10AF3A8FAC0B641C8), # noqa: E501 - ) - # Source: lll - # { [[ 0 ]] (CALL 100000 1 0 0 0 0) [[ 1 ]] (STATICCALL 100000 0 0 0 0) [[ 2 ]] (CALL 100000 1 0 0 0 0) (def 'i 0x80) (for {} (< @i 50000) [i](+ @i 1) (EXTCODESIZE 1)) } # noqa: E501 - addr_3 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE( - key=0x0, - value=Op.CALL( - gas=0x186A0, - address=0xC031FC0AA7B61A5D7D962AFEE8838DEC6948ABB7, - value=0x1, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ), + caller_storage = Storage() + caller_code = ( + Op.SSTORE( + key=caller_storage.store_next(not oog), + value=Op.CALL(address=subcall_contract, value=0x1), ) + Op.SSTORE( - key=0x1, - value=Op.STATICCALL( - gas=0x186A0, - address=0xC031FC0AA7B61A5D7D962AFEE8838DEC6948ABB7, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ), + key=caller_storage.store_next(not oog and not sstore_in_static), + value=Op.STATICCALL(address=subcall_contract), ) + Op.SSTORE( - key=0x2, - value=Op.CALL( - gas=0x186A0, - address=0xC031FC0AA7B61A5D7D962AFEE8838DEC6948ABB7, - value=0x1, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ), - ) - + Op.JUMPDEST - + Op.JUMPI( - pc=0x8F, condition=Op.ISZERO(Op.LT(Op.MLOAD(offset=0x80), 0xC350)) + key=caller_storage.store_next(not oog), + value=Op.CALL(address=subcall_contract, value=0x1), ) - + Op.POP(Op.EXTCODESIZE(address=0x1)) - + Op.MSTORE(offset=0x80, value=Op.ADD(Op.MLOAD(offset=0x80), 0x1)) - + Op.JUMP(pc=0x73) - + Op.JUMPDEST - + Op.STOP, - balance=0xDE0B6B3A7640000, - nonce=0, - address=Address(0xEA22EC955AC71D8E4380541212BD20818D704567), # noqa: E501 - ) - # Source: lll - # { [[ 0 ]] (CALL 100000 1 0 0 0 0) [[ 1 ]] (STATICCALL 100000 0 0 0 0) [[ 2 ]] (CALL 100000 1 0 0 0 0) } # noqa: E501 - addr_4 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE( - key=0x0, - value=Op.CALL( - gas=0x186A0, - address=0x47C4ED3D93429CB8304737E2327B522E8928C9F3, - value=0x1, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ), - ) - + Op.SSTORE( - key=0x1, - value=Op.STATICCALL( - gas=0x186A0, - address=0x47C4ED3D93429CB8304737E2327B522E8928C9F3, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ), - ) - + Op.SSTORE( - key=0x2, - value=Op.CALL( - gas=0x186A0, - address=0x47C4ED3D93429CB8304737E2327B522E8928C9F3, - value=0x1, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ), - ) - + Op.STOP, - balance=0xDE0B6B3A7640000, - nonce=0, - address=Address(0x2C004389EDAAE817E664B6D660F46735756B56D3), # noqa: E501 ) + if oog: + caller_code += Op.MLOAD(2**256 - 1) + caller_code += Op.STOP - expect_entries_: list[dict] = [ - { - "indexes": {"data": 0, "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - addr: Account(storage={0: 1, 1: 1, 2: 1}), - addr_2: Account(balance=2), - }, - }, - { - "indexes": {"data": 1, "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - addr_3: Account(storage={0: 0, 1: 0, 2: 0}), - addr_2: Account(balance=0), - }, - }, - { - "indexes": {"data": 2, "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - addr_4: Account(storage={0: 1, 1: 0, 2: 1}), - addr_5: Account(balance=2), - }, - }, - ] + caller_contract = pre.deploy_contract(caller_code, balance=2) - post, _exc = resolve_expect_post(expect_entries_, d, g, v, fork) + post = { + caller_contract: Account(storage=caller_storage), + subcall_contract: Account(balance=2 if not oog else 0), + } - tx_data = [ - Hash(addr, left_padding=True), - Hash(addr_3, left_padding=True), - Hash(addr_4, left_padding=True), - ] - tx_gas = [1000000] - tx_value = [100000] - - tx = Transaction( - sender=sender, - to=target, - data=tx_data[d], - gas_limit=tx_gas[g], - value=tx_value[v], - error=_exc, - ) + tx = Transaction(sender=sender, to=caller_contract) - state_test(env=env, pre=pre, post=post, tx=tx) + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stStaticCall/test_static_make_money.py b/tests/ported_static/stStaticCall/test_static_make_money.py index add8a413112..4a921cd29f6 100644 --- a/tests/ported_static/stStaticCall/test_static_make_money.py +++ b/tests/ported_static/stStaticCall/test_static_make_money.py @@ -3,18 +3,12 @@ Ported from: state_tests/stStaticCall/static_makeMoneyFiller.json + +@manually-enhanced: Do not overwrite. """ import pytest -from execution_testing import ( - Account, - Address, - Alloc, - Bytes, - Environment, - StateTestFiller, - Transaction, -) +from execution_testing import Account, Alloc, StateTestFiller, Transaction from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -25,65 +19,47 @@ ["state_tests/stStaticCall/static_makeMoneyFiller.json"], ) @pytest.mark.valid_from("Cancun") -@pytest.mark.slow -@pytest.mark.pre_alloc_mutable def test_static_make_money( state_test: StateTestFiller, pre: Alloc, ) -> None: """Test_static_make_money.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - sender = pre.fund_eoa(amount=0x5F5E100) + sender = pre.fund_eoa() - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=1000000, - ) + contracts_starting_balance = 1 - # Source: raw - # 0x600160015532600255 - addr = pre.deploy_contract( # noqa: F841 + subcall_contract = pre.deploy_contract( # noqa: F841 code=Op.SSTORE(key=0x1, value=0x1) + Op.SSTORE(key=0x2, value=Op.ORIGIN), - balance=0xDE0B6B3A7640000, - nonce=0, + balance=contracts_starting_balance, ) - # Source: lll - # { (MSTORE 0 0x601080600c6000396000f20060003554156009570060203560003555) (STATICCALL 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffec 0 0 0 0) } # noqa: E501 - target = pre.deploy_contract( # noqa: F841 + entry_contract = pre.deploy_contract( # noqa: F841 code=Op.MSTORE( offset=0x0, value=0x601080600C6000396000F20060003554156009570060203560003555, ) + Op.STATICCALL( - gas=0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEC, # noqa: E501 - address=addr, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, + gas=2**256 - 20, + address=subcall_contract, ) + Op.STOP, - balance=0xDE0B6B3A7640000, - nonce=0, + balance=contracts_starting_balance, ) - tx = Transaction( - sender=sender, - to=target, - data=Bytes(""), - gas_limit=228500, - value=10, - ) + tx_value = 1 + tx = Transaction(sender=sender, to=entry_contract, value=tx_value) post = { - target: Account(balance=0xDE0B6B3A764000A), - sender: Account(balance=0x5D38038), - addr: Account(balance=0xDE0B6B3A7640000), + entry_contract: Account( + balance=contracts_starting_balance + tx_value, + ), + subcall_contract: Account( + balance=contracts_starting_balance, + storage={ + 1: 0, + 2: 0, + }, + ), } - state_test(env=env, pre=pre, post=post, tx=tx) + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stStaticCall/test_static_raw_call_gas_ask.py b/tests/ported_static/stStaticCall/test_static_raw_call_gas_ask.py index 0ea735ae867..fa31ff1b4e6 100644 --- a/tests/ported_static/stStaticCall/test_static_raw_call_gas_ask.py +++ b/tests/ported_static/stStaticCall/test_static_raw_call_gas_ask.py @@ -8,17 +8,12 @@ import pytest from execution_testing import ( Account, - Address, Alloc, - Environment, - Hash, + CodeGasMeasure, StateTestFiller, Transaction, ) from execution_testing.forks import Fork -from execution_testing.specs.static_state.expect_section import ( - resolve_expect_post, -) from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -31,198 +26,48 @@ @pytest.mark.valid_from("Cancun") @pytest.mark.slow @pytest.mark.parametrize( - "d, g, v", + "mem_expansion", [ - pytest.param( - 0, - 0, - 0, - id="d0", - ), - pytest.param( - 1, - 0, - 0, - id="d1", - ), - pytest.param( - 2, - 0, - 0, - id="d2", - ), - pytest.param( - 3, - 0, - 0, - id="d3", - ), + pytest.param(False, id="without_mem_expansion"), + pytest.param(True, id="with_mem_expansion"), ], ) @pytest.mark.pre_alloc_mutable def test_static_raw_call_gas_ask( state_test: StateTestFiller, pre: Alloc, + mem_expansion: bool, fork: Fork, - d: int, - g: int, - v: int, ) -> None: """Test_static_raw_call_gas_ask.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - contract_0 = Address(0x094F5374FCE5EDBC8E2A8697C15331677E6EBF0B) - contract_1 = Address(0x1000000000000000000000000000000000000000) - contract_2 = Address(0x1000000000000000000000000000000000000001) - contract_3 = Address(0x2000000000000000000000000000000000000001) - contract_4 = Address(0x3000000000000000000000000000000000000001) - contract_5 = Address(0x4000000000000000000000000000000000000001) - sender = pre.fund_eoa(amount=0xE8D4A51000) + sender = pre.fund_eoa() - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=10000000, - ) + subcall_code = Op.MSTORE(0, Op.GAS, new_memory_size=32) + Op.STOP + subcall_contract = pre.deploy_contract(code=subcall_code) - # Source: lll - # { (MSTORE 0 (GAS)) } - contract_0 = pre.deploy_contract( # noqa: F841 - code=Op.MSTORE(offset=0x0, value=Op.GAS) + Op.STOP, - nonce=0, - address=Address(0x094F5374FCE5EDBC8E2A8697C15331677E6EBF0B), # noqa: E501 - ) - # Source: lll - # { (CALL (GAS) (CALLDATALOAD 0) 0 0 0 0 0) } - contract_1 = pre.deploy_contract( # noqa: F841 - code=Op.CALL( - gas=Op.GAS, - address=Op.CALLDATALOAD(offset=0x0), - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) - + Op.STOP, - balance=0xE8D4A51000, - nonce=0, - address=Address(0x1000000000000000000000000000000000000000), # noqa: E501 - ) - # Source: lll - # { (STATICCALL 130000 0x094f5374fce5edbc8e2a8697c15331677e6ebf0b 0 0 0 0) [[1]] (GAS) } # noqa: E501 - contract_3 = pre.deploy_contract( # noqa: F841 - code=Op.POP( - Op.STATICCALL( - gas=0x1FBD0, - address=0x94F5374FCE5EDBC8E2A8697C15331677E6EBF0B, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) + mem_expansion_size = 0x1F40 + static_call_code = ( + Op.STATICCALL( + address=subcall_contract, + args_size=mem_expansion_size, + ret_size=mem_expansion_size, + new_memory_size=mem_expansion_size, ) - + Op.SSTORE(key=0x1, value=Op.GAS) - + Op.STOP, - nonce=0, - address=Address(0x2000000000000000000000000000000000000001), # noqa: E501 - ) - # Source: lll - # { (STATICCALL 130000 0x094f5374fce5edbc8e2a8697c15331677e6ebf0b 0 8000 0 8000) [[1]] (GAS) } # noqa: E501 - contract_5 = pre.deploy_contract( # noqa: F841 - code=Op.POP( - Op.STATICCALL( - gas=0x1FBD0, - address=0x94F5374FCE5EDBC8E2A8697C15331677E6EBF0B, - args_offset=0x0, - args_size=0x1F40, - ret_offset=0x0, - ret_size=0x1F40, - ) - ) - + Op.SSTORE(key=0x1, value=Op.GAS) - + Op.STOP, - nonce=0, - address=Address(0x4000000000000000000000000000000000000001), # noqa: E501 - ) - # Source: lll - # { (STATICCALL 3000000 0x094f5374fce5edbc8e2a8697c15331677e6ebf0b 0 8000 0 8000) [[1]] (GAS) } # noqa: E501 - contract_4 = pre.deploy_contract( # noqa: F841 - code=Op.POP( - Op.STATICCALL( - gas=0x2DC6C0, - address=0x94F5374FCE5EDBC8E2A8697C15331677E6EBF0B, - args_offset=0x0, - args_size=0x1F40, - ret_offset=0x0, - ret_size=0x1F40, - ) + if mem_expansion + else Op.STATICCALL( + address=subcall_contract, ) - + Op.SSTORE(key=0x1, value=Op.GAS) - + Op.STOP, - nonce=0, - address=Address(0x3000000000000000000000000000000000000001), # noqa: E501 ) - # Source: lll - # { (STATICCALL 3000000 0x094f5374fce5edbc8e2a8697c15331677e6ebf0b 0 0 0 0) [[1]] (GAS) } # noqa: E501 - contract_2 = pre.deploy_contract( # noqa: F841 - code=Op.POP( - Op.STATICCALL( - gas=0x2DC6C0, - address=0x94F5374FCE5EDBC8E2A8697C15331677E6EBF0B, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) - ) - + Op.SSTORE(key=0x1, value=Op.GAS) - + Op.STOP, - nonce=0, - address=Address(0x1000000000000000000000000000000000000001), # noqa: E501 + static_call_contract = pre.deploy_contract( + code=CodeGasMeasure( + code=static_call_code, + extra_stack_items=1, + sstore_key=1, + ), ) - expect_entries_: list[dict] = [ - { - "indexes": {"data": 0, "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": {contract_2: Account(storage={1: 0xE9F83})}, - }, - { - "indexes": {"data": 1, "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": {contract_3: Account(storage={1: 0xE9F83})}, - }, - { - "indexes": {"data": 2, "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": {contract_4: Account(storage={1: 0xE9C1B})}, - }, - { - "indexes": {"data": 3, "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": {contract_5: Account(storage={1: 0xE9C1B})}, - }, - ] - - post, _exc = resolve_expect_post(expect_entries_, d, g, v, fork) - - tx_data = [ - Hash(contract_2, left_padding=True), - Hash(contract_3, left_padding=True), - Hash(contract_4, left_padding=True), - Hash(contract_5, left_padding=True), - ] - tx_gas = [1000000] - - tx = Transaction( - sender=sender, - to=contract_1, - data=tx_data[d], - gas_limit=tx_gas[g], - error=_exc, - ) + gas_cost = static_call_code.gas_cost(fork) + subcall_code.gas_cost(fork) + post = {static_call_contract: Account(storage={1: gas_cost})} + tx = Transaction(sender=sender, to=static_call_contract) - state_test(env=env, pre=pre, post=post, tx=tx) + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stTransactionTest/test_contract_store_clears_success.py b/tests/ported_static/stTransactionTest/test_contract_store_clears_success.py index 90f1ca0d067..a61f620fb02 100644 --- a/tests/ported_static/stTransactionTest/test_contract_store_clears_success.py +++ b/tests/ported_static/stTransactionTest/test_contract_store_clears_success.py @@ -3,6 +3,18 @@ Ported from: state_tests/stTransactionTest/ContractStoreClearsSuccessFiller.json + +@manually-enhanced: Do not overwrite. The contract clears 10 cold +storage slots (each 12 -> 0) and the transaction sends value alongside, +so the asserted post is the cleared storage plus the received value. +EIP-8038 raises the cold SSTORE-clear charge from 5000 to 13000, so the +10 clears no longer fit in the original gas limit and the contract runs +out of gas before clearing the storage or keeping the transfer. Bump the +gas limit by the per-clear charge delta times the 10 clears so every +clear still lands at Amsterdam. The delta is derived from the fork gas +model and is exactly 0 pre-EIP-8037; do not hardcode the Amsterdam +value. The post asserts only the target account (cleared storage and the +received value), which holds at every fork once the gas fits. """ import pytest @@ -15,6 +27,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -29,6 +42,7 @@ def test_contract_store_clears_success( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_contract_store_clears_success.""" coinbase = Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B) @@ -72,11 +86,21 @@ def test_contract_store_clears_success( nonce=0, ) + # EIP-8038 raises the cold SSTORE-clear charge; bump the gas limit by + # the per-clear charge delta times the 10 clears so all of them still + # land instead of running out of gas before clearing the storage. + cold_clear_delta = ( + Op.SSTORE.with_metadata( + key_warm=False, original_value=1, current_value=1, new_value=0 + ).gas_cost(fork) + - 5000 + ) + tx = Transaction( sender=sender, to=target, data=Bytes(""), - gas_limit=130000, + gas_limit=130000 + 10 * cold_clear_delta, value=10, ) diff --git a/tests/ported_static/stTransactionTest/test_high_gas_limit.py b/tests/ported_static/stTransactionTest/test_high_gas_limit.py index db5ce28e428..60069018353 100644 --- a/tests/ported_static/stTransactionTest/test_high_gas_limit.py +++ b/tests/ported_static/stTransactionTest/test_high_gas_limit.py @@ -3,6 +3,13 @@ Ported from: state_tests/stTransactionTest/HighGasLimitFiller.json + +@manually-enhanced: Do not overwrite. The tx sends value to an empty +recipient, so EIP-2780 charges ``NEW_ACCOUNT`` state gas at the top +frame; with the default zero state-gas reservoir that charge spills into +regular gas. Instead of the original hardcoded ``gas_limit``, lift the +100000 base by ``fork.transaction_top_frame_state_gas`` so the budget +covers the spillover and stays exactly 0 on pre-EIP-2780 forks. """ import pytest @@ -13,6 +20,8 @@ Alloc, Bytes, Environment, + Fork, + RecipientType, StateTestFiller, Transaction, ) @@ -29,6 +38,7 @@ def test_high_gas_limit( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_high_gas_limit.""" coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) @@ -49,11 +59,19 @@ def test_high_gas_limit( balance=0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF # noqa: E501 ) + # EIP-2780 charges ``NEW_ACCOUNT`` state gas at the top frame when + # value is sent to an empty recipient; with the default zero + # state-gas reservoir that charge spills into regular gas, so lift + # ``gas_limit`` by exactly that amount (0 on pre-EIP-2780 forks). + top_frame_state_gas = fork.transaction_top_frame_state_gas( + recipient_type=RecipientType.EMPTY_ACCOUNT, + sends_value=True, + ) tx = Transaction( sender=sender, to=Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B), data=Bytes("3240349548983454"), - gas_limit=100000, + gas_limit=100000 + top_frame_state_gas, value=900, ) diff --git a/tests/ported_static/stTransactionTest/test_internal_call_store_clears_success.py b/tests/ported_static/stTransactionTest/test_internal_call_store_clears_success.py index bdd47e999a2..e699a350a8d 100644 --- a/tests/ported_static/stTransactionTest/test_internal_call_store_clears_success.py +++ b/tests/ported_static/stTransactionTest/test_internal_call_store_clears_success.py @@ -3,6 +3,20 @@ Ported from: state_tests/stTransactionTest/InternalCallStoreClearsSuccessFiller.json + +@manually-enhanced: Do not overwrite. The `target` contract forwards a +fixed `CALL` gas budget (0x186A0) to `addr`, which clears 10 cold +storage slots (12 -> 0). EIP-8038 raises the cold SSTORE-clear charge +from 5000 to 13000, so the 10 clears jump from 50000 to 130000 gas and +no longer fit in the forwarded budget or the transaction gas limit: +`addr` runs out of gas, its slots stay set, and the inner value +transfer rolls back, defeating the "store clears success" intent. Both +the inner `CALL` gas argument and the transaction gas limit are raised +by `10 * cold_clear_delta` so all 10 clears still succeed. The per-clear +delta is derived from the fork gas model and is exactly 0 pre-EIP-8037; +do not hardcode the Amsterdam values. The asserted balances are +fork-invariant once the clears land, and the post does not assert the +sender balance, so no balance adjustment is needed. """ import pytest @@ -15,6 +29,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -31,6 +46,7 @@ def test_internal_call_store_clears_success( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_internal_call_store_clears_success.""" coinbase = Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B) @@ -45,6 +61,18 @@ def test_internal_call_store_clears_success( gas_limit=1000000, ) + # EIP-8038 raises the cold SSTORE-clear charge; bump the forwarded + # CALL gas and the transaction gas limit by the per-clear delta times + # the 10 clears so every clear still lands instead of running out of + # gas. The delta is 0 before the EIP-8037/8038 repricing. + cold_clear_delta = ( + Op.SSTORE.with_metadata( + key_warm=False, original_value=1, current_value=1, new_value=0 + ).gas_cost(fork) + - 5000 + ) + clears_gas_bump = 10 * cold_clear_delta + # Source: lll # {(SSTORE 0 0)(SSTORE 1 0)(SSTORE 2 0)(SSTORE 3 0)(SSTORE 4 0)(SSTORE 5 0)(SSTORE 6 0)(SSTORE 7 0)(SSTORE 8 0)(SSTORE 9 0)} # noqa: E501 addr = pre.deploy_contract( # noqa: F841 @@ -77,7 +105,7 @@ def test_internal_call_store_clears_success( # { (CALL 100000 1 0 0 0 0) } # noqa: E501 target = pre.deploy_contract( # noqa: F841 code=Op.CALL( - gas=0x186A0, + gas=0x186A0 + clears_gas_bump, address=addr, value=0x1, args_offset=0x0, @@ -94,7 +122,7 @@ def test_internal_call_store_clears_success( sender=sender, to=target, data=Bytes(""), - gas_limit=160000, + gas_limit=160000 + clears_gas_bump, value=10, ) diff --git a/tests/ported_static/stTransactionTest/test_store_clears_and_internal_call_store_clears_success.py b/tests/ported_static/stTransactionTest/test_store_clears_and_internal_call_store_clears_success.py index 3af9a80d962..163c044ce66 100644 --- a/tests/ported_static/stTransactionTest/test_store_clears_and_internal_call_store_clears_success.py +++ b/tests/ported_static/stTransactionTest/test_store_clears_and_internal_call_store_clears_success.py @@ -3,6 +3,19 @@ Ported from: state_tests/stTransactionTest/StoreClearsAndInternalCallStoreClearsSuccessFiller.json + +@manually-enhanced: Do not overwrite. The outer contract `target` clears 4 +cold storage slots then `CALL`s the inner contract `addr`, which clears 10 +cold storage slots; the value transfer and clears must all succeed. +EIP-8037/8038 raise the cold SSTORE-clear charge from 5000 to 13000 at +Amsterdam, so both gas budgets must rise by that charge delta or the inner +frame runs out of gas (clearing only 4 of its 10 slots) and the value +transfer rolls back. The inner `CALL` only forwards a fixed gas amount, so +its budget is bumped by the 10 inner clears; the transaction gas limit is +bumped by all 14 clears (10 inner plus 4 outer) so the outer frame can both +pay its own clears and forward the larger amount. Both bumps are derived +from the fork gas model and are exactly 0 pre-EIP-8037; do not hardcode the +Amsterdam values. """ import pytest @@ -15,6 +28,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -31,11 +45,19 @@ def test_store_clears_and_internal_call_store_clears_success( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_store_clears_and_internal_call_store_clears_success.""" coinbase = Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B) sender = pre.fund_eoa(amount=0x1DCD6500) + # EIP-8037/8038 raise the cold SSTORE-clear charge; derive the per-clear + # delta (0 pre-EIP-8037) so both gas budgets keep every clear landing. + sstore_charge = Op.SSTORE.with_metadata( + key_warm=False, original_value=1, current_value=1, new_value=0 + ).gas_cost(fork) + cold_clear_delta = sstore_charge - 5000 + env = Environment( fee_recipient=coinbase, number=1, @@ -81,7 +103,10 @@ def test_store_clears_and_internal_call_store_clears_success( + Op.SSTORE(key=0x2, value=0x0) + Op.SSTORE(key=0x3, value=0x0) + Op.CALL( - gas=0xC350, + # The inner frame clears 10 cold slots; forward its extra + # charge so all 10 clears land at Amsterdam (delta is 0 + # pre-EIP-8037). + gas=0xC350 + 10 * cold_clear_delta, address=addr, value=0x1, args_offset=0x0, @@ -99,7 +124,10 @@ def test_store_clears_and_internal_call_store_clears_success( sender=sender, to=target, data=Bytes(""), - gas_limit=200000, + # The whole transaction clears 14 cold slots (4 in the outer frame, + # 10 in the inner frame); bump the limit by all of them so the outer + # frame can pay its own clears and forward the larger inner budget. + gas_limit=200000 + 14 * cold_clear_delta, value=10, ) diff --git a/tests/ported_static/stTransactionTest/test_transaction_sending_to_zero.py b/tests/ported_static/stTransactionTest/test_transaction_sending_to_zero.py index 77749f1463e..1fbe1129c26 100644 --- a/tests/ported_static/stTransactionTest/test_transaction_sending_to_zero.py +++ b/tests/ported_static/stTransactionTest/test_transaction_sending_to_zero.py @@ -3,6 +3,14 @@ Ported from: state_tests/stTransactionTest/TransactionSendingToZeroFiller.json + +@manually-enhanced: Do not overwrite. The tx sends value 1 to the empty +zero address, so EIP-2780 charges NEW_ACCOUNT state gas at the top frame; +with the default zero reservoir that charge spills into regular gas. The +`gas_limit` is lifted by `fork.transaction_top_frame_state_gas` for an +EMPTY_ACCOUNT recipient with `sends_value=True` (0 on pre-EIP-2780 +forks), so the literal 25000 budget stays valid across the repricing. Do +not collapse the lift back to a hardcoded gas_limit. """ import pytest @@ -13,6 +21,8 @@ Alloc, Bytes, Environment, + Fork, + RecipientType, StateTestFiller, Transaction, ) @@ -29,6 +39,7 @@ def test_transaction_sending_to_zero( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_transaction_sending_to_zero.""" coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) @@ -47,11 +58,19 @@ def test_transaction_sending_to_zero( pre[sender] = Account(balance=0x5F5E100) + # EIP-2780 charges ``NEW_ACCOUNT`` state gas at the top frame when + # value is sent to an empty recipient; with the default zero + # state-gas reservoir that charge spills into regular gas, so lift + # ``gas_limit`` by exactly that amount (0 on pre-EIP-2780 forks). + top_frame_state_gas = fork.transaction_top_frame_state_gas( + recipient_type=RecipientType.EMPTY_ACCOUNT, + sends_value=True, + ) tx = Transaction( sender=sender, to=Address(0x0000000000000000000000000000000000000000), data=Bytes(""), - gas_limit=25000, + gas_limit=25000 + top_frame_state_gas, value=1, ) diff --git a/tests/ported_static/stTransactionTest/test_transaction_to_addressh160minus_one.py b/tests/ported_static/stTransactionTest/test_transaction_to_addressh160minus_one.py index 0a27a5403dd..c8601268898 100644 --- a/tests/ported_static/stTransactionTest/test_transaction_to_addressh160minus_one.py +++ b/tests/ported_static/stTransactionTest/test_transaction_to_addressh160minus_one.py @@ -3,6 +3,14 @@ Ported from: state_tests/stTransactionTest/TransactionToAddressh160minusOneFiller.json + +@manually-enhanced: Do not overwrite. Sending value to the empty 0xff..ff +recipient triggers EIP-2780's NEW_ACCOUNT top-frame state-gas charge. +Both the tx and block ``gas_limit`` are lifted by +``fork.transaction_top_frame_state_gas(EMPTY_ACCOUNT, sends_value=True)`` +so the charge (which spills into regular gas via the zero reservoir) +fits the budget; this derived value is 0 on pre-EIP-2780 forks, keeping +the original hardcoded 22000/100000 limits intact there. """ import pytest @@ -13,6 +21,8 @@ Alloc, Bytes, Environment, + Fork, + RecipientType, StateTestFiller, Transaction, ) @@ -31,6 +41,7 @@ def test_transaction_to_addressh160minus_one( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_transaction_to_addressh160minus_one.""" coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) @@ -38,22 +49,31 @@ def test_transaction_to_addressh160minus_one( key=0xF79127A3004ABDE26A4CBD80C428CB10F829FA11B54D36E7B326F4F4A5927ACF ) + pre[sender] = Account(balance=0x3B9ACA00) + + # EIP-2780 charges ``NEW_ACCOUNT`` state gas at the top frame when + # value is sent to an empty recipient; with the default zero + # state-gas reservoir that charge spills into regular gas, so lift + # ``gas_limit`` by exactly that amount (0 on pre-EIP-2780 forks). + # The block ``gas_limit`` must also accommodate the lifted tx. + top_frame_state_gas = fork.transaction_top_frame_state_gas( + recipient_type=RecipientType.EMPTY_ACCOUNT, + sends_value=True, + ) + env = Environment( fee_recipient=coinbase, number=1, timestamp=1000, prev_randao=0x20000, base_fee_per_gas=10, - gas_limit=100000, + gas_limit=100000 + top_frame_state_gas, ) - - pre[sender] = Account(balance=0x3B9ACA00) - tx = Transaction( sender=sender, to=Address(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF), data=Bytes(""), - gas_limit=22000, + gas_limit=22000 + top_frame_state_gas, value=100, ) diff --git a/tests/ported_static/stTransactionTest/test_transaction_to_itself.py b/tests/ported_static/stTransactionTest/test_transaction_to_itself.py index 320a0073f09..28e0e14505a 100644 --- a/tests/ported_static/stTransactionTest/test_transaction_to_itself.py +++ b/tests/ported_static/stTransactionTest/test_transaction_to_itself.py @@ -3,6 +3,16 @@ Ported from: state_tests/stTransactionTest/TransactionToItselfFiller.json + +@manually-enhanced: Do not overwrite. The post-state asserts the sender +balance after a self-transfer (to == sender). Instead of the original +hardcoded value, the balance shift is derived from the fork intrinsic +calculator: ``intrinsic(recipient_type=SELF, sends_value=True) - 21_000`` +is the delta versus the pre-EIP-2780 baseline intrinsic 21_000. EIP-2780 +carves out the recipient and value-transfer surcharges for self-sends, +dropping the intrinsic to ``TX_BASE`` (12_000 on Amsterdam), so the delta +is 0 at Cancun and negative afterward. The balance moves by +``gas_price * intrinsic_delta``. Do not hardcode the Amsterdam value. """ import pytest @@ -12,6 +22,8 @@ Alloc, Bytes, Environment, + Fork, + RecipientType, StateTestFiller, Transaction, ) @@ -27,6 +39,7 @@ def test_transaction_to_itself( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_transaction_to_itself.""" coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) @@ -49,6 +62,19 @@ def test_transaction_to_itself( value=1, ) - post = {sender: Account(balance=0x3B9795B0, nonce=1)} + # EIP-2780 carves out self-transfers from the recipient and + # value-transfer surcharges, leaving only ``TX_BASE`` (12_000 on + # Amsterdam vs 21_000 on Cancun). Shift the sender balance by + # ``gas_price * delta``. + intrinsic_delta = ( + fork.transaction_intrinsic_cost_calculator()( + recipient_type=RecipientType.SELF, + sends_value=True, + ) + - 21_000 + ) + post = { + sender: Account(balance=0x3B9795B0 - 10 * intrinsic_delta, nonce=1) + } state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stZeroCallsTest/test_zero_value_call.py b/tests/ported_static/stZeroCallsTest/test_zero_value_call.py index 7c0f01feb36..93a4faac1a7 100644 --- a/tests/ported_static/stZeroCallsTest/test_zero_value_call.py +++ b/tests/ported_static/stZeroCallsTest/test_zero_value_call.py @@ -3,6 +3,14 @@ Ported from: state_tests/stZeroCallsTest/ZeroValue_CALLFiller.json + +@manually-enhanced: Do not overwrite. The post-state asserts storage slot +0 holds the `Op.GAS` value (0x8D5B6), which depends on the gas remaining +at a fixed execution point. EIP-2780 lowers the intrinsic for this non-self +non-value tx, so the gas budget is derived from the fork as +`600_000 + (intrinsic - 21_000)`: the fork intrinsic minus the +pre-EIP-2780 baseline 21_000 keeps the post-intrinsic budget fixed at +Cancun's value across forks. Do not hardcode the gas_limit. """ import pytest @@ -13,6 +21,7 @@ Alloc, Bytes, Environment, + Fork, StateTestFiller, Transaction, ) @@ -30,6 +39,7 @@ def test_zero_value_call( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_zero_value_call.""" coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) @@ -70,11 +80,18 @@ def test_zero_value_call( address=Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B), # noqa: E501 ) + # Preserve Cancun's post-intrinsic execution budget across + # forks; EIP-2780 lowers the intrinsic for non-self non-value + # txs, and the Op.GAS storage assertion depends on the + # remaining gas at a fixed execution point. + intrinsic = fork.transaction_intrinsic_cost_calculator()() + gas_limit = 600_000 + (intrinsic - 21_000) + tx = Transaction( sender=sender, to=contract_0, data=Bytes(""), - gas_limit=600000, + gas_limit=gas_limit, ) post = { diff --git a/tests/ported_static/stZeroCallsTest/test_zero_value_call_to_empty_paris.py b/tests/ported_static/stZeroCallsTest/test_zero_value_call_to_empty_paris.py index 9153b52e963..dea5687fb4b 100644 --- a/tests/ported_static/stZeroCallsTest/test_zero_value_call_to_empty_paris.py +++ b/tests/ported_static/stZeroCallsTest/test_zero_value_call_to_empty_paris.py @@ -3,6 +3,14 @@ Ported from: state_tests/stZeroCallsTest/ZeroValue_CALL_ToEmpty_ParisFiller.json + +@manually-enhanced: Do not overwrite. The post-state asserts the +`Op.GAS` value stored at slot 0 (0x8D5B6), which depends on the gas +remaining at a fixed execution point. To keep that budget constant +across forks, `gas_limit` is derived as 600_000 plus the fork's intrinsic +cost minus the pre-EIP-2780 baseline intrinsic of 21_000 +(`intrinsic - 21_000`), since EIP-2780 lowers the intrinsic for non-self +non-value txs. Do not hardcode the gas_limit. """ import pytest @@ -12,6 +20,7 @@ Alloc, Bytes, Environment, + Fork, StateTestFiller, Transaction, ) @@ -29,6 +38,7 @@ def test_zero_value_call_to_empty_paris( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_zero_value_call_to_empty_paris.""" coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) @@ -65,11 +75,18 @@ def test_zero_value_call_to_empty_paris( nonce=0, ) + # Preserve Cancun's post-intrinsic execution budget across + # forks; EIP-2780 lowers the intrinsic for non-self non-value + # txs, and the Op.GAS storage assertion depends on the + # remaining gas at a fixed execution point. + intrinsic = fork.transaction_intrinsic_cost_calculator()() + gas_limit = 600_000 + (intrinsic - 21_000) + tx = Transaction( sender=sender, to=target, data=Bytes(""), - gas_limit=600000, + gas_limit=gas_limit, ) post = { diff --git a/tests/ported_static/stZeroCallsTest/test_zero_value_call_to_non_zero_balance.py b/tests/ported_static/stZeroCallsTest/test_zero_value_call_to_non_zero_balance.py index 50c861952c0..4e65814717a 100644 --- a/tests/ported_static/stZeroCallsTest/test_zero_value_call_to_non_zero_balance.py +++ b/tests/ported_static/stZeroCallsTest/test_zero_value_call_to_non_zero_balance.py @@ -3,6 +3,14 @@ Ported from: state_tests/stZeroCallsTest/ZeroValue_CALL_ToNonZeroBalanceFiller.json + +@manually-enhanced: Do not overwrite. Slot 0 stores `Op.GAS` and is +asserted at a fixed `0x8D5B6`, so the post-intrinsic execution budget +must stay constant across forks. The `gas_limit` is derived from the +fork intrinsic via `fork.transaction_intrinsic_cost_calculator()()` +minus the pre-EIP-2780 baseline `21_000`, leaving exactly 600_000 for +execution (the adjustment is 0 pre-EIP-2780). Do not hardcode the +gas_limit. """ import pytest @@ -12,6 +20,7 @@ Alloc, Bytes, Environment, + Fork, StateTestFiller, Transaction, ) @@ -29,6 +38,7 @@ def test_zero_value_call_to_non_zero_balance( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_zero_value_call_to_non_zero_balance.""" coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) @@ -65,11 +75,18 @@ def test_zero_value_call_to_non_zero_balance( nonce=0, ) + # Preserve Cancun's post-intrinsic execution budget across + # forks; EIP-2780 lowers the intrinsic for non-self non-value + # txs, and the Op.GAS storage assertion depends on the + # remaining gas at a fixed execution point. + intrinsic = fork.transaction_intrinsic_cost_calculator()() + gas_limit = 600_000 + (intrinsic - 21_000) + tx = Transaction( sender=sender, to=target, data=Bytes(""), - gas_limit=600000, + gas_limit=gas_limit, ) post = { diff --git a/tests/ported_static/stZeroCallsTest/test_zero_value_call_to_one_storage_key_paris.py b/tests/ported_static/stZeroCallsTest/test_zero_value_call_to_one_storage_key_paris.py index a073612b73b..68d21a57d11 100644 --- a/tests/ported_static/stZeroCallsTest/test_zero_value_call_to_one_storage_key_paris.py +++ b/tests/ported_static/stZeroCallsTest/test_zero_value_call_to_one_storage_key_paris.py @@ -3,6 +3,15 @@ Ported from: state_tests/stZeroCallsTest/ZeroValue_CALL_ToOneStorageKey_ParisFiller.json + +@manually-enhanced: Do not overwrite. The contract stores `Op.GAS` into +slot 0, asserting `0x8D5B6` remaining at a fixed execution point, so the +tx gas budget must track the intrinsic across forks. `gas_limit` is +derived as `600_000 + (intrinsic - 21_000)`, where `intrinsic` comes from +`fork.transaction_intrinsic_cost_calculator()`; subtracting the +pre-EIP-2780 baseline intrinsic 21_000 keeps the post-intrinsic execution +budget fixed when EIP-2780 lowers the intrinsic for non-value, non-self +txs. Do not hardcode the literal gas limit. """ import pytest @@ -13,6 +22,7 @@ Alloc, Bytes, Environment, + Fork, StateTestFiller, Transaction, ) @@ -32,6 +42,7 @@ def test_zero_value_call_to_one_storage_key_paris( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_zero_value_call_to_one_storage_key_paris.""" coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) @@ -73,11 +84,18 @@ def test_zero_value_call_to_one_storage_key_paris( address=Address(0xF202BAE278AC09857F5A56991C7A4679632F5841), # noqa: E501 ) + # Preserve Cancun's post-intrinsic execution budget across + # forks; EIP-2780 lowers the intrinsic for non-self non-value + # txs, and the Op.GAS storage assertion depends on the + # remaining gas at a fixed execution point. + intrinsic = fork.transaction_intrinsic_cost_calculator()() + gas_limit = 600_000 + (intrinsic - 21_000) + tx = Transaction( sender=sender, to=target, data=Bytes(""), - gas_limit=600000, + gas_limit=gas_limit, ) post = { diff --git a/tests/ported_static/stZeroCallsTest/test_zero_value_callcode.py b/tests/ported_static/stZeroCallsTest/test_zero_value_callcode.py index d4721fe68c5..a018699bea2 100644 --- a/tests/ported_static/stZeroCallsTest/test_zero_value_callcode.py +++ b/tests/ported_static/stZeroCallsTest/test_zero_value_callcode.py @@ -3,6 +3,15 @@ Ported from: state_tests/stZeroCallsTest/ZeroValue_CALLCODEFiller.json + +@manually-enhanced: Do not overwrite. The contract stores `Op.GAS` at +slot 0 (asserted as 0x8D5B6), so the post-state depends on the gas left +at a fixed execution point. The tx gas budget is derived from the fork +gas model instead of a hardcoded literal: `gas_limit = 600_000 + +(intrinsic - 21_000)` adds back whatever EIP-2780 shaved off the +intrinsic for this non-self non-value tx (subtracting the pre-EIP-2780 +baseline 21_000) so the 600_000 post-intrinsic execution budget, and +thus the stored GAS value, stays invariant across forks. """ import pytest @@ -13,6 +22,7 @@ Alloc, Bytes, Environment, + Fork, StateTestFiller, Transaction, ) @@ -30,6 +40,7 @@ def test_zero_value_callcode( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_zero_value_callcode.""" coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) @@ -70,11 +81,18 @@ def test_zero_value_callcode( address=Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B), # noqa: E501 ) + # Preserve Cancun's post-intrinsic execution budget across + # forks; EIP-2780 lowers the intrinsic for non-self non-value + # txs, and the Op.GAS storage assertion depends on the + # remaining gas at a fixed execution point. + intrinsic = fork.transaction_intrinsic_cost_calculator()() + gas_limit = 600_000 + (intrinsic - 21_000) + tx = Transaction( sender=sender, to=contract_0, data=Bytes(""), - gas_limit=600000, + gas_limit=gas_limit, ) post = { diff --git a/tests/ported_static/stZeroCallsTest/test_zero_value_callcode_to_empty_paris.py b/tests/ported_static/stZeroCallsTest/test_zero_value_callcode_to_empty_paris.py index 8117720261d..935396c723e 100644 --- a/tests/ported_static/stZeroCallsTest/test_zero_value_callcode_to_empty_paris.py +++ b/tests/ported_static/stZeroCallsTest/test_zero_value_callcode_to_empty_paris.py @@ -3,6 +3,15 @@ Ported from: state_tests/stZeroCallsTest/ZeroValue_CALLCODE_ToEmpty_ParisFiller.json + +@manually-enhanced: Do not overwrite. The target stores `Op.GAS` into +slot 0 and the post-state asserts it as a fixed value (0x8D5B6), so the +remaining gas at that point must be fork-invariant. The `gas_limit` is +derived as `600_000 + (intrinsic - 21_000)` from the fork intrinsic +calculator instead of a hardcoded number: subtracting the pre-EIP-2780 +baseline intrinsic of 21_000 keeps a constant 600_000 post-intrinsic +execution budget when EIP-2780 lowers the intrinsic for non-self +non-value txs. Do not hardcode the gas_limit. """ import pytest @@ -12,6 +21,7 @@ Alloc, Bytes, Environment, + Fork, StateTestFiller, Transaction, ) @@ -31,6 +41,7 @@ def test_zero_value_callcode_to_empty_paris( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_zero_value_callcode_to_empty_paris.""" coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) @@ -67,11 +78,18 @@ def test_zero_value_callcode_to_empty_paris( nonce=0, ) + # Preserve Cancun's post-intrinsic execution budget across + # forks; EIP-2780 lowers the intrinsic for non-self non-value + # txs, and the Op.GAS storage assertion depends on the + # remaining gas at a fixed execution point. + intrinsic = fork.transaction_intrinsic_cost_calculator()() + gas_limit = 600_000 + (intrinsic - 21_000) + tx = Transaction( sender=sender, to=target, data=Bytes(""), - gas_limit=600000, + gas_limit=gas_limit, ) post = { diff --git a/tests/ported_static/stZeroCallsTest/test_zero_value_callcode_to_non_zero_balance.py b/tests/ported_static/stZeroCallsTest/test_zero_value_callcode_to_non_zero_balance.py index 9b5ab5f0cae..f34ddf54aab 100644 --- a/tests/ported_static/stZeroCallsTest/test_zero_value_callcode_to_non_zero_balance.py +++ b/tests/ported_static/stZeroCallsTest/test_zero_value_callcode_to_non_zero_balance.py @@ -3,6 +3,17 @@ Ported from: state_tests/stZeroCallsTest/ZeroValue_CALLCODE_ToNonZeroBalanceFiller.json + +@manually-enhanced: Do not overwrite. The post-state asserts the +`Op.GAS` value stored at slot 0 (0x8D5B6), which depends on the gas +remaining at a fixed execution point. To hold that point constant, the +`gas_limit` is derived from the fork gas model rather than hardcoded: +`gas_limit = 600_000 + (intrinsic - 21_000)`, where `intrinsic` comes +from `fork.transaction_intrinsic_cost_calculator()`. Subtracting the +pre-EIP-2780 baseline intrinsic 21_000 keeps the post-intrinsic +execution budget at 600_000 across the EIP-2780 intrinsic +decomposition (which lowers the intrinsic for non-self, non-value txs). +Do not hardcode the gas_limit. """ import pytest @@ -12,6 +23,7 @@ Alloc, Bytes, Environment, + Fork, StateTestFiller, Transaction, ) @@ -31,6 +43,7 @@ def test_zero_value_callcode_to_non_zero_balance( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_zero_value_callcode_to_non_zero_balance.""" coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) @@ -67,11 +80,18 @@ def test_zero_value_callcode_to_non_zero_balance( nonce=0, ) + # Preserve Cancun's post-intrinsic execution budget across + # forks; EIP-2780 lowers the intrinsic for non-self non-value + # txs, and the Op.GAS storage assertion depends on the + # remaining gas at a fixed execution point. + intrinsic = fork.transaction_intrinsic_cost_calculator()() + gas_limit = 600_000 + (intrinsic - 21_000) + tx = Transaction( sender=sender, to=target, data=Bytes(""), - gas_limit=600000, + gas_limit=gas_limit, ) post = { diff --git a/tests/ported_static/stZeroCallsTest/test_zero_value_callcode_to_one_storage_key_paris.py b/tests/ported_static/stZeroCallsTest/test_zero_value_callcode_to_one_storage_key_paris.py index 6872e5b24d9..b11a46374e3 100644 --- a/tests/ported_static/stZeroCallsTest/test_zero_value_callcode_to_one_storage_key_paris.py +++ b/tests/ported_static/stZeroCallsTest/test_zero_value_callcode_to_one_storage_key_paris.py @@ -3,6 +3,16 @@ Ported from: state_tests/stZeroCallsTest/ZeroValue_CALLCODE_ToOneStorageKey_ParisFiller.json + +@manually-enhanced: Do not overwrite. The contract's first SSTORE records +`Op.GAS`, so the slot-0 post value (`0x8D5B6`) pins the remaining gas at a +fixed execution point. To keep that budget constant as the intrinsic +shifts, `gas_limit` is derived from the fork intrinsic calculator rather +than hardcoded: `600_000 + (intrinsic - 21_000)`, where `21_000` is the +pre-EIP-2780 baseline intrinsic. EIP-2780 lowers the intrinsic for this +non-self, zero-value tx, so the `- 21_000` term keeps the post-intrinsic +execution budget (and thus the `Op.GAS` assertion) correct across forks. +Do not replace the calculator-derived value with a literal. """ import pytest @@ -13,6 +23,7 @@ Alloc, Bytes, Environment, + Fork, StateTestFiller, Transaction, ) @@ -32,6 +43,7 @@ def test_zero_value_callcode_to_one_storage_key_paris( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_zero_value_callcode_to_one_storage_key_paris.""" coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) @@ -73,11 +85,18 @@ def test_zero_value_callcode_to_one_storage_key_paris( address=Address(0xA93AE635B4FA4D618045C019AC32ED9ADC8F54EA), # noqa: E501 ) + # Preserve Cancun's post-intrinsic execution budget across + # forks; EIP-2780 lowers the intrinsic for non-self non-value + # txs, and the Op.GAS storage assertion depends on the + # remaining gas at a fixed execution point. + intrinsic = fork.transaction_intrinsic_cost_calculator()() + gas_limit = 600_000 + (intrinsic - 21_000) + tx = Transaction( sender=sender, to=target, data=Bytes(""), - gas_limit=600000, + gas_limit=gas_limit, ) post = { diff --git a/tests/ported_static/stZeroCallsTest/test_zero_value_delegatecall.py b/tests/ported_static/stZeroCallsTest/test_zero_value_delegatecall.py index 3e630665533..d3fad1a766a 100644 --- a/tests/ported_static/stZeroCallsTest/test_zero_value_delegatecall.py +++ b/tests/ported_static/stZeroCallsTest/test_zero_value_delegatecall.py @@ -3,6 +3,16 @@ Ported from: state_tests/stZeroCallsTest/ZeroValue_DELEGATECALLFiller.json + +@manually-enhanced: Do not overwrite. The `gas_limit` is derived from +the fork intrinsic calculator instead of a hardcoded literal, so the +post-intrinsic execution budget stays fixed at 600_000 across forks: +`gas_limit = 600_000 + (intrinsic - 21_000)`, where `21_000` is the +pre-EIP-2780 baseline intrinsic. EIP-2780 lowers the intrinsic for +non-self, non-value txs, and the `SSTORE(0, GAS)` post assertion +(`0x8D5B6`) pins `Op.GAS` at a fixed execution point, so the remaining +gas after the intrinsic deduction must not shift. Do not hardcode the +gas limit. """ import pytest @@ -13,6 +23,7 @@ Alloc, Bytes, Environment, + Fork, StateTestFiller, Transaction, ) @@ -30,6 +41,7 @@ def test_zero_value_delegatecall( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_zero_value_delegatecall.""" coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) @@ -69,11 +81,18 @@ def test_zero_value_delegatecall( address=Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B), # noqa: E501 ) + # Preserve Cancun's post-intrinsic execution budget across + # forks; EIP-2780 lowers the intrinsic for non-self non-value + # txs, and the Op.GAS storage assertion depends on the + # remaining gas at a fixed execution point. + intrinsic = fork.transaction_intrinsic_cost_calculator()() + gas_limit = 600_000 + (intrinsic - 21_000) + tx = Transaction( sender=sender, to=contract_0, data=Bytes(""), - gas_limit=600000, + gas_limit=gas_limit, ) post = { diff --git a/tests/ported_static/stZeroCallsTest/test_zero_value_delegatecall_to_empty_paris.py b/tests/ported_static/stZeroCallsTest/test_zero_value_delegatecall_to_empty_paris.py index a92303da1cc..a858d9edcc5 100644 --- a/tests/ported_static/stZeroCallsTest/test_zero_value_delegatecall_to_empty_paris.py +++ b/tests/ported_static/stZeroCallsTest/test_zero_value_delegatecall_to_empty_paris.py @@ -3,6 +3,16 @@ Ported from: state_tests/stZeroCallsTest/ZeroValue_DELEGATECALL_ToEmpty_ParisFiller.json + +@manually-enhanced: Do not overwrite. The contract records `Op.GAS` into +storage slot 0, and the post-state asserts that value (`0x8D5B6`), so the +gas remaining at that fixed execution point must stay constant across +forks. The `gas_limit` is therefore derived from the fork as +`600_000 + (fork.transaction_intrinsic_cost_calculator()() - 21_000)`: +it re-adds the 600k post-intrinsic execution budget onto the fork +intrinsic and subtracts the pre-EIP-2780 baseline intrinsic `21_000`, +which EIP-2780 lowers for non-self non-value txs. Do not hardcode the +gas limit. """ import pytest @@ -12,6 +22,7 @@ Alloc, Bytes, Environment, + Fork, StateTestFiller, Transaction, ) @@ -31,6 +42,7 @@ def test_zero_value_delegatecall_to_empty_paris( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_zero_value_delegatecall_to_empty_paris.""" coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) @@ -66,11 +78,18 @@ def test_zero_value_delegatecall_to_empty_paris( nonce=0, ) + # Preserve Cancun's post-intrinsic execution budget across + # forks; EIP-2780 lowers the intrinsic for non-self non-value + # txs, and the Op.GAS storage assertion depends on the + # remaining gas at a fixed execution point. + intrinsic = fork.transaction_intrinsic_cost_calculator()() + gas_limit = 600_000 + (intrinsic - 21_000) + tx = Transaction( sender=sender, to=target, data=Bytes(""), - gas_limit=600000, + gas_limit=gas_limit, ) post = { diff --git a/tests/ported_static/stZeroCallsTest/test_zero_value_delegatecall_to_non_zero_balance.py b/tests/ported_static/stZeroCallsTest/test_zero_value_delegatecall_to_non_zero_balance.py index a02b7615016..2a06868f8a7 100644 --- a/tests/ported_static/stZeroCallsTest/test_zero_value_delegatecall_to_non_zero_balance.py +++ b/tests/ported_static/stZeroCallsTest/test_zero_value_delegatecall_to_non_zero_balance.py @@ -3,6 +3,16 @@ Ported from: state_tests/stZeroCallsTest/ZeroValue_DELEGATECALL_ToNonZeroBalanceFiller.json + +@manually-enhanced: Do not overwrite. The contract stores `Op.GAS` into +slot 0 (asserted as 0x8D5B6), so the gas remaining at that fixed +execution point must be fork-invariant. The `gas_limit` is derived from +the fork: `600_000 + (intrinsic - 21_000)`, where `intrinsic` comes from +`fork.transaction_intrinsic_cost_calculator()()` and 21_000 is the +pre-EIP-2780 baseline intrinsic. EIP-2780's intrinsic decomposition +lowers the intrinsic for non-self non-value txs, so subtracting the old +21_000 literal keeps the post-intrinsic execution budget (600_000) +constant. Do not hardcode the gas_limit. """ import pytest @@ -12,6 +22,7 @@ Alloc, Bytes, Environment, + Fork, StateTestFiller, Transaction, ) @@ -31,6 +42,7 @@ def test_zero_value_delegatecall_to_non_zero_balance( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_zero_value_delegatecall_to_non_zero_balance.""" coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) @@ -66,11 +78,18 @@ def test_zero_value_delegatecall_to_non_zero_balance( nonce=0, ) + # Preserve Cancun's post-intrinsic execution budget across + # forks; EIP-2780 lowers the intrinsic for non-self non-value + # txs, and the Op.GAS storage assertion depends on the + # remaining gas at a fixed execution point. + intrinsic = fork.transaction_intrinsic_cost_calculator()() + gas_limit = 600_000 + (intrinsic - 21_000) + tx = Transaction( sender=sender, to=target, data=Bytes(""), - gas_limit=600000, + gas_limit=gas_limit, ) post = { diff --git a/tests/ported_static/stZeroCallsTest/test_zero_value_delegatecall_to_one_storage_key_paris.py b/tests/ported_static/stZeroCallsTest/test_zero_value_delegatecall_to_one_storage_key_paris.py index d4e3d2fad43..10553129c76 100644 --- a/tests/ported_static/stZeroCallsTest/test_zero_value_delegatecall_to_one_storage_key_paris.py +++ b/tests/ported_static/stZeroCallsTest/test_zero_value_delegatecall_to_one_storage_key_paris.py @@ -3,6 +3,16 @@ Ported from: state_tests/stZeroCallsTest/ZeroValue_DELEGATECALL_ToOneStorageKey_ParisFiller.json + +@manually-enhanced: Do not overwrite. Slot 0 asserts the `Op.GAS` +value (0x8D5B6) captured by the first SSTORE, so the gas remaining at +that fixed execution point must be constant across forks. The +`gas_limit` is derived from the fork intrinsic calculator +(`fork.transaction_intrinsic_cost_calculator()()`) as +`600_000 + (intrinsic - 21_000)`: subtracting the pre-EIP-2780 +baseline intrinsic 21_000 keeps the post-intrinsic execution budget +fixed at 600_000 even as EIP-2780 lowers the intrinsic for +non-value, non-self calls. Do not hardcode the gas_limit. """ import pytest @@ -13,6 +23,7 @@ Alloc, Bytes, Environment, + Fork, StateTestFiller, Transaction, ) @@ -32,6 +43,7 @@ def test_zero_value_delegatecall_to_one_storage_key_paris( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """Test_zero_value_delegatecall_to_one_storage_key_paris.""" coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) @@ -72,11 +84,18 @@ def test_zero_value_delegatecall_to_one_storage_key_paris( address=Address(0xC8881A7E48D37B4A4CDD6338CE7076D6A116283D), # noqa: E501 ) + # Preserve Cancun's post-intrinsic execution budget across + # forks; EIP-2780 lowers the intrinsic for non-self non-value + # txs, and the Op.GAS storage assertion depends on the + # remaining gas at a fixed execution point. + intrinsic = fork.transaction_intrinsic_cost_calculator()() + gas_limit = 600_000 + (intrinsic - 21_000) + tx = Transaction( sender=sender, to=target, data=Bytes(""), - gas_limit=600000, + gas_limit=gas_limit, ) post = { diff --git a/tests/ported_static/vmTests/test_suicide.py b/tests/ported_static/vmTests/test_suicide.py index 02c1db148af..c562b182d0d 100644 --- a/tests/ported_static/vmTests/test_suicide.py +++ b/tests/ported_static/vmTests/test_suicide.py @@ -3,6 +3,18 @@ Ported from: state_tests/VMTests/vmTests/suicideFiller.yml + +@manually-enhanced: Do not overwrite. For the `caller` case the post-state +asserts the sender balance, which equals its start minus +`gas_used * gas_price`. The transaction calls a contract that CALLs a +cold, existing account (slot 0x1000) before it self-destructs; EIP-8038 +raises the cold account-access surcharge on that CALL from 2600 to 3000. +The SELFDESTRUCT itself is to a warm, non-empty beneficiary (the caller), +so its charge is unchanged, and there is no refund. Derive the +account-access delta from the fork gas model (0 pre-EIP-8037) and +subtract `gas_price * delta` from the Cancun balance; do not hardcode the +Amsterdam value. The `random` and `myself` cases assert only +non-gas-dependent balances and need no adjustment. """ import pytest @@ -133,12 +145,24 @@ def test_suicide( address=Address(0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC), # noqa: E501 ) + # The CALL into the self-destructing contract touches a cold, already + # existing account; EIP-8038 raises that cold account-access surcharge + # from 2600 to 3000. The SELFDESTRUCT beneficiary is warm and + # non-empty, so its charge is unchanged. EIP-2780 separately reshapes + # the tx intrinsic for this non-self non-value call. The sender pays + # the combined delta at the base fee (no priority fee). + cold_account_access_delta = fork.gas_costs().COLD_ACCOUNT_ACCESS - 2600 + intrinsic_delta = fork.transaction_intrinsic_cost_calculator()() - 21_000 + caller_balance = ( + 0x5AF31075D9DE - 10 * cold_account_access_delta - 10 * intrinsic_delta + ) + expect_entries_: list[dict] = [ { "indexes": {"data": [0], "gas": -1, "value": -1}, "network": [">=Cancun"], "result": { - sender: Account(balance=0x5AF31075D9DE), + sender: Account(balance=caller_balance), contract_3: Account(balance=0xFF100000000000), }, }, diff --git a/tests/prague/eip7702_set_code_tx/test_gas.py b/tests/prague/eip7702_set_code_tx/test_gas.py index 431b4166add..4acb1f4f083 100644 --- a/tests/prague/eip7702_set_code_tx/test_gas.py +++ b/tests/prague/eip7702_set_code_tx/test_gas.py @@ -973,6 +973,7 @@ def test_gas_cost( def test_account_warming( state_test: StateTestFiller, pre: Alloc, + fork: Fork, authorization_list_with_properties: List[AuthorizationWithProperties], authorization_list: List[AuthorizationTuple], access_list: List[AccessList], @@ -988,8 +989,9 @@ def test_account_warming( # check. overhead_cost = 3 * len(Op.CALL.kwargs) - cold_account_cost = 2600 - warm_account_cost = 100 + gas_costs = fork.gas_costs() + cold_account_cost = gas_costs.COLD_ACCOUNT_ACCESS + warm_account_cost = gas_costs.WARM_ACCESS access_list_addresses = { access_list.address for access_list in access_list @@ -1190,6 +1192,7 @@ def test_intrinsic_gas_cost( def test_self_set_code_cost( state_test: StateTestFiller, pre: Alloc, + fork: Fork, pre_authorized: bool, ) -> None: """Test set to code account access cost when it delegates to itself.""" @@ -1200,6 +1203,10 @@ def test_self_set_code_cost( slot_call_cost = 1 + gas_costs = fork.gas_costs() + cold_account_cost = gas_costs.COLD_ACCOUNT_ACCESS + warm_account_cost = gas_costs.WARM_ACCESS + overhead_cost = 3 * len(Op.CALL.kwargs) callee_code = CodeGasMeasure( @@ -1211,7 +1218,11 @@ def test_self_set_code_cost( callee_address = pre.deploy_contract(callee_code) callee_storage = Storage() - callee_storage[slot_call_cost] = 200 if not pre_authorized else 2700 + callee_storage[slot_call_cost] = ( + 2 * warm_account_cost + if not pre_authorized + else cold_account_cost + warm_account_cost + ) tx = Transaction( to=callee_address, diff --git a/tests/prague/eip7702_set_code_tx/test_set_code_txs.py b/tests/prague/eip7702_set_code_tx/test_set_code_txs.py index a083c9035e0..e7167078a04 100644 --- a/tests/prague/eip7702_set_code_tx/test_set_code_txs.py +++ b/tests/prague/eip7702_set_code_tx/test_set_code_txs.py @@ -36,6 +36,7 @@ Hash, Initcode, Op, + RecipientType, Requests, StateTestFiller, Storage, @@ -1470,6 +1471,7 @@ def test_ext_code_on_self_set_code( def test_set_code_address_and_authority_warm_state( state_test: StateTestFiller, pre: Alloc, + fork: Fork, set_code_address_first: bool, ) -> None: """ @@ -1512,13 +1514,18 @@ def test_set_code_address_and_authority_warm_state( callee_code += Op.SSTORE(slot_call_success, 1) + Op.STOP callee_address = pre.deploy_contract(callee_code) + gas_costs = fork.gas_costs() + cold_account_cost = gas_costs.COLD_ACCOUNT_ACCESS + warm_account_cost = gas_costs.WARM_ACCESS callee_storage = Storage() callee_storage[slot_call_success] = 1 callee_storage[slot_set_code_to_warm_state] = ( - 2_600 if set_code_address_first else 100 + cold_account_cost if set_code_address_first else warm_account_cost ) callee_storage[slot_authority_warm_state] = ( - 200 if set_code_address_first else 2_700 + 2 * warm_account_cost + if set_code_address_first + else warm_account_cost + cold_account_cost ) tx = Transaction( @@ -3995,13 +4002,16 @@ def test_many_delegations( max_gas = tx_gas_limit_cap else: max_gas = env.gas_limit - gas_for_delegations = max_gas - 21_000 - 20_000 - (3 * 2) + + success_slot = 1 + entry_code = Op.SSTORE(success_slot, 1) + Op.STOP + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()() + entry_code_gas = entry_code.gas_cost(fork) + gas_for_delegations = max_gas - intrinsic_gas - entry_code_gas gas_costs = fork.gas_costs() delegation_count = gas_for_delegations // gas_costs.AUTH_PER_EMPTY_ACCOUNT - success_slot = 1 - entry_code = Op.SSTORE(success_slot, 1) + Op.STOP entry_address = pre.deploy_contract(entry_code) signers = [pre.fund_eoa(signer_balance) for _ in range(delegation_count)] @@ -4092,6 +4102,7 @@ def test_invalid_transaction_after_authorization( def test_authorization_reusing_nonce( blockchain_test: BlockchainTestFiller, pre: Alloc, + fork: Fork, ) -> None: """ Test an authorization reusing the same nonce as a prior transaction @@ -4100,11 +4111,34 @@ def test_authorization_reusing_nonce( auth_signer = pre.fund_eoa() sender = pre.fund_eoa() recipient = pre.fund_eoa(amount=0) + + intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() + # Tx1: value transfer to an empty recipient -- pays the intrinsic + # value-transfer surcharges plus the top-frame ``NEW_ACCOUNT`` + # state-gas charge. + tx1_intrinsic = intrinsic_gas_calculator( + recipient_type=RecipientType.EMPTY_ACCOUNT, + sends_value=True, + ) + tx1_top_frame_state = fork.transaction_top_frame_state_gas( + recipient_type=RecipientType.EMPTY_ACCOUNT, + sends_value=True, + ) + tx1_gas = tx1_intrinsic + tx1_top_frame_state + + # Tx2: recipient is now alive (received 1 wei in tx1), so the + # recipient is an EOA and no top-frame charge fires. The auth + # list adds one ``AUTH_PER_EMPTY_ACCOUNT`` to intrinsic. + tx2_gas = intrinsic_gas_calculator( + recipient_type=RecipientType.EOA, + authorization_list_or_count=1, + ) + txs = [ Transaction( sender=auth_signer, nonce=0, - gas_limit=21_000, + gas_limit=tx1_gas, to=recipient, value=1, ), @@ -4112,6 +4146,7 @@ def test_authorization_reusing_nonce( sender=sender, to=recipient, value=0, + gas_limit=tx2_gas, authorization_list=[ AuthorizationTuple( address=Address(1), diff --git a/tests/shanghai/eip4895_withdrawals/test_withdrawals.py b/tests/shanghai/eip4895_withdrawals/test_withdrawals.py index 8c4c62adf60..9d9a3074888 100644 --- a/tests/shanghai/eip4895_withdrawals/test_withdrawals.py +++ b/tests/shanghai/eip4895_withdrawals/test_withdrawals.py @@ -16,6 +16,7 @@ Fork, Hash, Op, + RecipientType, Transaction, TransactionException, Withdrawal, @@ -69,11 +70,16 @@ def recipient(self, pre: Alloc) -> EOA: return pre.fund_eoa(0) @pytest.fixture - def tx(self, sender: EOA, recipient: EOA) -> Transaction: # noqa: D102 + def tx( # noqa: D102 + self, sender: EOA, recipient: EOA, fork: Fork + ) -> Transaction: # Transaction sent from the `sender`, which has 1 wei balance at start + gas_limit = fork.transaction_intrinsic_cost_calculator()( + recipient_type=RecipientType.EOA, + ) return Transaction( gas_price=ONE_GWEI, - gas_limit=21_000, + gas_limit=gas_limit, to=recipient, sender=sender, )