diff --git a/packages/testing/src/execution_testing/execution/base.py b/packages/testing/src/execution_testing/execution/base.py index 4ba11b06824..159d66e9fe1 100644 --- a/packages/testing/src/execution_testing/execution/base.py +++ b/packages/testing/src/execution_testing/execution/base.py @@ -1,7 +1,7 @@ """Ethereum test execution base types.""" from abc import abstractmethod -from typing import Annotated, Any, ClassVar, Dict, List, Type +from typing import Annotated, Any, ClassVar, Dict, Type from pydantic import PlainSerializer, PlainValidator from pytest import FixtureRequest @@ -9,7 +9,7 @@ from execution_testing.base_types import Address, CamelModel from execution_testing.forks import Fork from execution_testing.rpc import EngineRPC, EthRPC -from execution_testing.test_types import Environment, Transaction +from execution_testing.test_types import Environment class ExecuteResult(CamelModel): @@ -43,34 +43,6 @@ def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: # Register the new execute format BaseExecute.formats[cls.format_name] = cls - @staticmethod - def calculate_max_transaction_gas_limit( - txs: List[Transaction], env: Environment, fork: Fork - ) -> int: - """ - Calculate the maximum gas limit that can be set in a transaction - given a list of transactions with and without gas-limits set - and a maximum available environment gas. - """ - available_gas = int(env.gas_limit) - unset_gas_limit_tx_count = 0 - for tx in txs: - if tx.gas_limit is None: - unset_gas_limit_tx_count += 1 - else: - available_gas -= int(tx.gas_limit) - - if unset_gas_limit_tx_count == 0 or available_gas <= 0: - return 0 - - max_gas_limit = available_gas // unset_gas_limit_tx_count - tx_gas_limit_cap = fork.transaction_gas_limit_cap() - if fork.state_gas_reservoir_enabled(): - tx_gas_limit_cap = None - if tx_gas_limit_cap: - max_gas_limit = min(max_gas_limit, tx_gas_limit_cap) - return max_gas_limit - def prepare_transactions( self, *, diff --git a/packages/testing/src/execution_testing/execution/blob_transaction.py b/packages/testing/src/execution_testing/execution/blob_transaction.py index 4ce76b995bf..fb7daa47375 100644 --- a/packages/testing/src/execution_testing/execution/blob_transaction.py +++ b/packages/testing/src/execution_testing/execution/blob_transaction.py @@ -22,6 +22,7 @@ Environment, NetworkWrappedTransaction, Transaction, + calculate_max_transaction_gas_limit, ) from execution_testing.test_types.transaction_types import ( TransactionTestMetadata, @@ -167,8 +168,8 @@ def prepare_transactions( txs.append(tx.tx) else: txs.append(tx) - max_tx_gas_limit = self.calculate_max_transaction_gas_limit( - txs, env, fork + max_tx_gas_limit = calculate_max_transaction_gas_limit( + txs, env_gas_limit=int(env.gas_limit), fork=fork ) for tx in txs: tx.set_gas_limit( diff --git a/packages/testing/src/execution_testing/execution/transaction_post.py b/packages/testing/src/execution_testing/execution/transaction_post.py index a391227916a..d92f33c76da 100644 --- a/packages/testing/src/execution_testing/execution/transaction_post.py +++ b/packages/testing/src/execution_testing/execution/transaction_post.py @@ -19,6 +19,7 @@ TestPhase, Transaction, TransactionTestMetadata, + calculate_max_transaction_gas_limit, ) from .base import BaseExecute, ExecuteResult @@ -52,8 +53,8 @@ def prepare_transactions( ) -> None: """Prepare transactions by setting their final gas properties.""" for block in self.blocks: - max_tx_gas_limit = self.calculate_max_transaction_gas_limit( - block, env, fork + max_tx_gas_limit = calculate_max_transaction_gas_limit( + block, env_gas_limit=int(env.gas_limit), fork=fork ) for tx in block: tx.set_gas_limit( diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 7a1d6e9df7d..6235996cce4 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -92,6 +92,7 @@ TestPhase, Transaction, Withdrawal, + calculate_max_transaction_gas_limit, ) from execution_testing.test_types.block_access_list import ( BlockAccessList, @@ -803,34 +804,6 @@ def make_genesis( ).with_rlp(txs=[]), ) - @staticmethod - def calculate_max_transaction_gas_limit( - txs: List[Transaction], env: Environment, fork: Fork - ) -> int: - """ - Calculate the maximum gas limit that can be set in a transaction - given a list of transactions with and without gas-limits set - and a maximum available environment gas. - """ - available_gas = int(env.gas_limit) - unset_gas_limit_tx_count = 0 - for tx in txs: - if tx.gas_limit is None: - unset_gas_limit_tx_count += 1 - else: - available_gas -= int(tx.gas_limit) - - if unset_gas_limit_tx_count == 0 or available_gas <= 0: - return 0 - - max_tx_gas_limit = available_gas // unset_gas_limit_tx_count - tx_gas_limit_cap = fork.transaction_gas_limit_cap() - if fork.state_gas_reservoir_enabled(): - tx_gas_limit_cap = None - if tx_gas_limit_cap: - max_tx_gas_limit = min(max_tx_gas_limit, tx_gas_limit_cap) - return max_tx_gas_limit - def generate_block_data( self, t8n: FillerBackend, @@ -853,14 +826,9 @@ def generate_block_data( env = env.set_fork_requirements(fork) txs = block.txs[:] if any(tx.gas_limit is None for tx in block.txs): - max_tx_gas_limit = self.calculate_max_transaction_gas_limit( - txs, env, fork + max_tx_gas_limit = calculate_max_transaction_gas_limit( + txs, env_gas_limit=int(env.gas_limit), fork=fork ) - if max_tx_gas_limit == 0: - raise Exception( - "test correctness: unable to automatically calculate gas " - "limit for transactions (No remaining gas)." - ) for tx in txs: tx.set_gas_limit( max_gas_limit=max_tx_gas_limit, diff --git a/packages/testing/src/execution_testing/test_types/__init__.py b/packages/testing/src/execution_testing/test_types/__init__.py index b9029ceb3ad..0c6af2be80a 100644 --- a/packages/testing/src/execution_testing/test_types/__init__.py +++ b/packages/testing/src/execution_testing/test_types/__init__.py @@ -47,6 +47,7 @@ TransactionDefaults, TransactionTestMetadata, TransactionType, + calculate_max_transaction_gas_limit, ) from .utils import Removable, keccak256 @@ -88,6 +89,7 @@ "Withdrawal", "WithdrawalRequest", "add_kzg_version", + "calculate_max_transaction_gas_limit", "ceiling_division", "compute_create_address", "compute_create2_address", diff --git a/packages/testing/src/execution_testing/test_types/tests/test_implicit_gas_limit.py b/packages/testing/src/execution_testing/test_types/tests/test_implicit_gas_limit.py new file mode 100644 index 00000000000..071d07439f5 --- /dev/null +++ b/packages/testing/src/execution_testing/test_types/tests/test_implicit_gas_limit.py @@ -0,0 +1,277 @@ +""" +Test suite for implicit transaction gas-limit resolution. + +Covers `Transaction.set_gas_limit` and +`calculate_max_transaction_gas_limit`: the even split of remaining +environment gas, gas limit cap clamping, the state gas reservoir +(EIP-8037) semantics, and the test correctness errors raised on +contradictory test definitions. +""" + +import pytest + +from execution_testing.forks import Amsterdam, Osaka, Prague + +from ..transaction_types import ( + Transaction, + calculate_max_transaction_gas_limit, +) + +_osaka_cap = Osaka.transaction_gas_limit_cap() +assert _osaka_cap is not None +OSAKA_CAP: int = _osaka_cap +_amsterdam_cap = Amsterdam.transaction_gas_limit_cap() +assert _amsterdam_cap is not None +AMSTERDAM_CAP: int = _amsterdam_cap + +assert Prague.transaction_gas_limit_cap() is None +assert not Prague.state_gas_reservoir_enabled() +assert not Osaka.state_gas_reservoir_enabled() +assert Amsterdam.state_gas_reservoir_enabled() + + +class TestSetGasLimit: + """Test `Transaction.set_gas_limit` resolution of unset limits.""" + + def test_unset_no_cap(self) -> None: + """An unset gas limit resolves to the maximum, uncapped.""" + tx = Transaction() + tx.set_gas_limit(max_gas_limit=100, transaction_gas_limit_cap=None) + assert tx.gas_limit == 100 + + def test_unset_clamped_to_cap(self) -> None: + """An unset gas limit is clamped to the gas limit cap.""" + tx = Transaction() + tx.set_gas_limit(max_gas_limit=100, transaction_gas_limit_cap=60) + assert tx.gas_limit == 60 + + def test_unset_cap_above_max(self) -> None: + """A cap above the maximum does not raise the gas limit.""" + tx = Transaction() + tx.set_gas_limit(max_gas_limit=100, transaction_gas_limit_cap=200) + assert tx.gas_limit == 100 + + def test_explicit_gas_limit_untouched(self) -> None: + """An explicit gas limit is never modified.""" + tx = Transaction(gas_limit=21_000) + tx.set_gas_limit(max_gas_limit=100, transaction_gas_limit_cap=60) + assert tx.gas_limit == 21_000 + + def test_explicit_none_treated_as_unset(self) -> None: + """An explicit `gas_limit=None` is treated as unset.""" + tx = Transaction(gas_limit=None) + tx.set_gas_limit(max_gas_limit=100, transaction_gas_limit_cap=None) + assert tx.gas_limit == 100 + + def test_resolution_is_sticky(self) -> None: + """A second call does not overwrite the resolved gas limit.""" + tx = Transaction() + tx.set_gas_limit(max_gas_limit=100, transaction_gas_limit_cap=None) + tx.set_gas_limit(max_gas_limit=50, transaction_gas_limit_cap=None) + assert tx.gas_limit == 100 + + def test_signing_requires_gas_limit(self) -> None: + """Signing a transaction with an unset gas limit raises.""" + with pytest.raises(ValueError, match="gas_limit must be set"): + Transaction().with_signature_and_sender() + + +class TestSetGasLimitStateGasReservoir: + """Test the state gas reservoir (EIP-8037) gas-limit semantics.""" + + def test_reservoir_unset_keeps_full_maximum(self) -> None: + """With the reservoir unset, the cap does not clamp the limit.""" + tx = Transaction() + tx.set_gas_limit( + max_gas_limit=100, + transaction_gas_limit_cap=60, + state_gas_reservoir_enabled=True, + ) + assert tx.gas_limit == 100 + + def test_reservoir_zero_pins_to_cap(self) -> None: + """An explicit zero reservoir pins the limit to exactly the cap.""" + tx = Transaction(state_gas_reservoir=0) + tx.set_gas_limit( + max_gas_limit=100, + transaction_gas_limit_cap=60, + state_gas_reservoir_enabled=True, + ) + assert tx.gas_limit == 60 + + def test_reservoir_pins_to_cap_plus_reservoir(self) -> None: + """A positive reservoir pins the limit to cap plus reservoir.""" + tx = Transaction(state_gas_reservoir=40) + tx.set_gas_limit( + max_gas_limit=200, + transaction_gas_limit_cap=60, + state_gas_reservoir_enabled=True, + ) + assert tx.gas_limit == 100 + + def test_reservoir_ignored_with_explicit_gas_limit(self) -> None: + """A reservoir is ignored when the gas limit is explicit.""" + tx = Transaction(gas_limit=21_000, state_gas_reservoir=40) + tx.set_gas_limit( + max_gas_limit=200, + transaction_gas_limit_cap=60, + state_gas_reservoir_enabled=True, + ) + assert tx.gas_limit == 21_000 + + def test_reservoir_exceeding_available_gas_raises(self) -> None: + """A reservoir that does not fit the available gas raises.""" + tx = Transaction(state_gas_reservoir=50) + with pytest.raises( + Exception, match="test correctness: the requested state" + ): + tx.set_gas_limit( + max_gas_limit=100, + transaction_gas_limit_cap=60, + state_gas_reservoir_enabled=True, + ) + + @pytest.mark.parametrize( + "gas_limit", + [ + pytest.param(None, id="implicit_gas_limit"), + pytest.param(21_000, id="explicit_gas_limit"), + ], + ) + def test_reservoir_on_unsupported_fork_raises( + self, gas_limit: int | None + ) -> None: + """A positive reservoir raises if the fork has no reservoir.""" + tx = Transaction(gas_limit=gas_limit, state_gas_reservoir=1) + with pytest.raises( + Exception, match="test correctness: transaction requests" + ): + tx.set_gas_limit( + max_gas_limit=100, + transaction_gas_limit_cap=60, + state_gas_reservoir_enabled=False, + ) + + def test_reservoir_zero_on_unsupported_fork_clamps_to_cap(self) -> None: + """An explicit zero reservoir is valid on forks without one.""" + tx = Transaction(state_gas_reservoir=0) + tx.set_gas_limit( + max_gas_limit=100, + transaction_gas_limit_cap=60, + state_gas_reservoir_enabled=False, + ) + assert tx.gas_limit == 60 + + def test_reservoir_without_cap_is_internal_invariant(self) -> None: + """A reservoir request without a cap violates an invariant.""" + tx = Transaction(state_gas_reservoir=1) + with pytest.raises(AssertionError, match="must also define a cap"): + tx.set_gas_limit( + max_gas_limit=100, + transaction_gas_limit_cap=None, + state_gas_reservoir_enabled=True, + ) + + +class TestCalculateMaxTransactionGasLimit: + """Test the even split of environment gas across transactions.""" + + def test_no_implicit_transactions(self) -> None: + """Return 0 when all transactions have explicit gas limits.""" + txs = [Transaction(gas_limit=200_000)] + assert ( + calculate_max_transaction_gas_limit( + txs, env_gas_limit=100_000, fork=Prague + ) + == 0 + ) + + def test_empty_transaction_list(self) -> None: + """Return 0 for an empty transaction list.""" + assert ( + calculate_max_transaction_gas_limit( + [], env_gas_limit=100_000, fork=Prague + ) + == 0 + ) + + def test_single_implicit_transaction(self) -> None: + """A single implicit transaction gets the full environment gas.""" + txs = [Transaction()] + assert ( + calculate_max_transaction_gas_limit( + txs, env_gas_limit=100_000, fork=Prague + ) + == 100_000 + ) + + def test_explicit_limits_reduce_available_gas(self) -> None: + """Explicit gas limits are deducted from the environment gas.""" + txs = [Transaction(gas_limit=40_000), Transaction()] + assert ( + calculate_max_transaction_gas_limit( + txs, env_gas_limit=100_000, fork=Prague + ) + == 60_000 + ) + + def test_even_split_across_implicit_transactions(self) -> None: + """Remaining gas is split evenly across implicit transactions.""" + txs = [Transaction(gas_limit=10_000), Transaction(), Transaction()] + assert ( + calculate_max_transaction_gas_limit( + txs, env_gas_limit=100_000, fork=Prague + ) + == 45_000 + ) + + def test_split_clamped_to_cap(self) -> None: + """The per-transaction share is clamped to the fork's cap.""" + env_gas_limit = 100_000_000 + assert env_gas_limit > OSAKA_CAP + txs = [Transaction()] + assert ( + calculate_max_transaction_gas_limit( + txs, env_gas_limit=env_gas_limit, fork=Osaka + ) + == OSAKA_CAP + ) + + def test_state_gas_reservoir_fork_removes_cap(self) -> None: + """A fork with the state gas reservoir does not clamp the share.""" + env_gas_limit = 100_000_000 + assert env_gas_limit > AMSTERDAM_CAP + txs = [Transaction()] + assert ( + calculate_max_transaction_gas_limit( + txs, env_gas_limit=env_gas_limit, fork=Amsterdam + ) + == env_gas_limit + ) + + @pytest.mark.parametrize( + "explicit_gas_limit", + [ + pytest.param(100_000, id="exactly_consumed"), + pytest.param(150_000, id="over_consumed"), + ], + ) + def test_no_remaining_gas_raises(self, explicit_gas_limit: int) -> None: + """Raise when explicit limits leave implicit transactions no gas.""" + txs = [Transaction(gas_limit=explicit_gas_limit), Transaction()] + with pytest.raises( + Exception, match="test correctness: unable to automatically" + ): + calculate_max_transaction_gas_limit( + txs, env_gas_limit=100_000, fork=Prague + ) + + def test_no_remaining_gas_all_explicit_does_not_raise(self) -> None: + """Over-consumption without implicit transactions returns 0.""" + txs = [Transaction(gas_limit=150_000)] + assert ( + calculate_max_transaction_gas_limit( + txs, env_gas_limit=100_000, fork=Prague + ) + == 0 + ) diff --git a/packages/testing/src/execution_testing/test_types/transaction_types.py b/packages/testing/src/execution_testing/test_types/transaction_types.py index eb381d03eb0..d9637a640f6 100644 --- a/packages/testing/src/execution_testing/test_types/transaction_types.py +++ b/packages/testing/src/execution_testing/test_types/transaction_types.py @@ -331,7 +331,20 @@ def strip_hash_from_t8n_output(cls, data: Any) -> Any: expected_receipt: TransactionReceipt | None = Field(None, exclude=True) - state_gas_reservoir: int = Field(0, exclude=True) + state_gas_reservoir: int = Field( + 0, + exclude=True, + description=( + "Extra gas on top of the transaction gas limit cap, reserved " + "for state gas (EIP-8037). Only takes effect when `gas_limit` " + "is unset and the fork enables the state gas reservoir: " + "leaving it unset keeps the full implicit gas limit, an " + "explicit 0 pins the gas limit to exactly the cap (no " + "reservoir), and a positive value pins it to the cap plus the " + "requested reservoir. Requesting a positive reservoir on a " + "fork without the state gas reservoir raises an error." + ), + ) zero: ClassVar[Literal[0]] = 0 @@ -849,21 +862,53 @@ def set_gas_limit( transaction_gas_limit_cap: int | None, state_gas_reservoir_enabled: bool = False, ) -> None: - """Set the transaction gas limit if unset.""" + """ + Set the transaction gas limit if unset. + + The implicit gas limit defaults to `max_gas_limit`, clamped to + the fork's transaction gas limit cap if there is one. On forks + with the state gas reservoir enabled (EIP-8037), + `state_gas_reservoir` refines this: unset keeps the full + `max_gas_limit` (any excess above the cap acts as an implicit + reservoir), an explicit 0 pins the gas limit to exactly the + cap, and a positive value pins it to the cap plus the requested + reservoir. + """ + if self.state_gas_reservoir > 0 and not state_gas_reservoir_enabled: + raise Exception( + "test correctness: transaction requests a state gas " + f"reservoir of {self.state_gas_reservoir} but the fork " + "does not enable the state gas reservoir; the request " + "would be silently ignored." + ) if "gas_limit" not in self.model_fields_set or self.gas_limit is None: tx_gas_limit = max_gas_limit if state_gas_reservoir_enabled: if "state_gas_reservoir" in self.model_fields_set: assert transaction_gas_limit_cap is not None, ( - "Impossible to set calculate the tx gas limit for the " - "required state gas reservoir without a gas limit cap" + "state_gas_reservoir_enabled is True but " + "transaction_gas_limit_cap is None; the state " + "gas reservoir is defined as gas above the cap " + "(EIP-8037 builds on EIP-7825), so a fork that " + "enables it must also define a cap" ) if self.state_gas_reservoir > 0: minimum_gas_with_reservoir = ( transaction_gas_limit_cap + self.state_gas_reservoir ) - assert tx_gas_limit >= minimum_gas_with_reservoir + if tx_gas_limit < minimum_gas_with_reservoir: + raise Exception( + "test correctness: the requested state " + "gas reservoir of " + f"{self.state_gas_reservoir} requires a " + f"gas limit of {minimum_gas_with_reservoir} " + "(transaction gas limit cap of " + f"{transaction_gas_limit_cap} plus " + "reservoir), but only " + f"{tx_gas_limit} gas is available for " + "this transaction." + ) tx_gas_limit = minimum_gas_with_reservoir else: if tx_gas_limit > transaction_gas_limit_cap: @@ -943,6 +988,49 @@ def __str__(self) -> str: return self.__repr__() +def calculate_max_transaction_gas_limit( + txs: Sequence[Transaction], + *, + env_gas_limit: int, + fork: Fork, +) -> int: + """ + Calculate the maximum gas limit that can be set in a transaction + given a list of transactions with and without gas limits set and + the maximum available environment gas. + + Return 0 if no transaction requires an implicit gas limit. Raise a + test correctness error if transactions with implicit gas limits + are left without remaining gas. + """ + available_gas = env_gas_limit + unset_gas_limit_tx_count = 0 + for tx in txs: + if tx.gas_limit is None: + unset_gas_limit_tx_count += 1 + else: + available_gas -= int(tx.gas_limit) + + if unset_gas_limit_tx_count == 0: + return 0 + + if available_gas <= 0: + raise Exception( + "test correctness: unable to automatically calculate gas " + "limit for transactions (no remaining gas: explicit " + "transaction gas limits already consume the full " + f"environment gas limit of {env_gas_limit})." + ) + + max_gas_limit = available_gas // unset_gas_limit_tx_count + tx_gas_limit_cap = fork.transaction_gas_limit_cap() + if fork.state_gas_reservoir_enabled(): + tx_gas_limit_cap = None + if tx_gas_limit_cap: + max_gas_limit = min(max_gas_limit, tx_gas_limit_cap) + return max_gas_limit + + class NetworkWrappedTransaction(CamelModel, RLPSerializable): """ Network wrapped transaction as defined in