diff --git a/src/ethereum_test_execution/transaction_post.py b/src/ethereum_test_execution/transaction_post.py index 5c97eb045bc..fe356b4cb4c 100644 --- a/src/ethereum_test_execution/transaction_post.py +++ b/src/ethereum_test_execution/transaction_post.py @@ -8,7 +8,7 @@ from ethereum_test_base_types import Address, Alloc, Hash from ethereum_test_forks import Fork from ethereum_test_rpc import EngineRPC, EthRPC, SendTransactionExceptionError -from ethereum_test_types import Transaction, TransactionTestMetadata +from ethereum_test_types import TestPhase, Transaction, TransactionTestMetadata from .base import BaseExecute @@ -40,9 +40,15 @@ def execute( tx = tx.with_signature_and_sender() to_address = tx.to label = to_address.label if isinstance(to_address, Address) else None + phase = ( + "testing" + if getattr(tx, "test_phase", None) == TestPhase.EXECUTION + or getattr(block, "test_phase", None) == TestPhase.EXECUTION + else "setup" + ) tx.metadata = TransactionTestMetadata( test_id=request.node.nodeid, - phase="testing", + phase=phase, target=label, tx_index=tx_index, ) diff --git a/src/ethereum_test_specs/blockchain.py b/src/ethereum_test_specs/blockchain.py index 6669ab2f274..e0363dc4b46 100644 --- a/src/ethereum_test_specs/blockchain.py +++ b/src/ethereum_test_specs/blockchain.py @@ -51,7 +51,16 @@ ) from ethereum_test_fixtures.common import FixtureBlobSchedule from ethereum_test_forks import Fork -from ethereum_test_types import Alloc, Environment, Removable, Requests, Transaction, Withdrawal +from ethereum_test_types import ( + Alloc, + Environment, + Removable, + Requests, + TestPhase, + TestPhaseManager, + Transaction, + Withdrawal, +) from ethereum_test_types.block_access_list import BlockAccessList, BlockAccessListExpectation from .base import BaseTest, OpMode, verify_result @@ -233,6 +242,8 @@ class Block(Header): """Post state for verification after block execution in BlockchainTest""" block_access_list: Bytes | None = Field(None) """EIP-7928: Block-level access lists (serialized).""" + test_phase: TestPhase | None = None + """Test phase for this block.""" def set_environment(self, env: Environment) -> Environment: """ @@ -404,10 +415,11 @@ class BlockchainTest(BaseTest): pre: Alloc post: Alloc - blocks: List[Block] + blocks: List[Block] = Field(default_factory=list) genesis_environment: Environment = Field(default_factory=Environment) chain_id: int = 1 exclude_full_post_state_in_output: bool = False + test_phase_manager: TestPhaseManager | None = Field(default=None, exclude=True) """ Exclude the post state from the fixture output. In this case, the state verification is only performed based on the state root. @@ -698,6 +710,33 @@ def verify_post_state(self, t8n, t8n_state: Alloc, expected_state: Alloc | None print_traces(t8n.get_traces()) raise e + def _get_test_phase_blocks(self) -> List[Block]: + """ + Get additional blocks from phase manager for setup + and execution phases. + """ + assert self.test_phase_manager is not None, "Test phase manager is not set" + + blocks = [] + if self.test_phase_manager.setup_blocks: + for block in self.test_phase_manager.setup_blocks: + block.test_phase = TestPhase.SETUP + blocks.extend(self.test_phase_manager.setup_blocks) + elif self.test_phase_manager.setup_transactions: + for tx in self.test_phase_manager.setup_transactions: + tx.test_phase = TestPhase.SETUP + blocks.append(Block(txs=self.test_phase_manager.setup_transactions)) + + if self.test_phase_manager.execution_blocks: + for block in self.test_phase_manager.execution_blocks: + block.test_phase = TestPhase.EXECUTION + blocks.extend(self.test_phase_manager.execution_blocks) + elif self.test_phase_manager.execution_transactions: + for tx in self.test_phase_manager.execution_transactions: + tx.test_phase = TestPhase.EXECUTION + blocks.append(Block(txs=self.test_phase_manager.execution_transactions)) + return blocks + def make_fixture( self, t8n: TransitionTool, @@ -712,6 +751,7 @@ def make_fixture( env = environment_from_parent_header(genesis.header) head = genesis.header.block_hash invalid_blocks = 0 + for i, block in enumerate(self.blocks): # This is the most common case, the RLP needs to be constructed # based on the transactions to be included in the block. @@ -776,6 +816,8 @@ def make_hive_fixture( env = environment_from_parent_header(genesis.header) head_hash = genesis.header.block_hash invalid_blocks = 0 + + built_block = None for i, block in enumerate(self.blocks): built_block = self.generate_block_data( t8n=t8n, @@ -798,6 +840,10 @@ def make_hive_fixture( t8n, t8n_state=alloc, expected_state=block.expected_post_state ) self.check_exception_test(exception=invalid_blocks > 0) + + if built_block is None: + raise Exception("No blocks to process in the test") + fcu_version = fork.engine_forkchoice_updated_version( built_block.header.number, built_block.header.timestamp ) @@ -877,6 +923,10 @@ def generate( ) -> BaseFixture: """Generate the BlockchainTest fixture.""" t8n.reset_traces() + + if self.test_phase_manager is not None: + self.blocks.extend(self._get_test_phase_blocks()) + if fixture_format in [ BlockchainEngineFixture, BlockchainEngineXFixture, @@ -897,6 +947,8 @@ def execute( """Generate the list of test fixtures.""" if execute_format == TransactionPost: blocks: List[List[Transaction]] = [] + if self.test_phase_manager is not None: + self.blocks.extend(self._get_test_phase_blocks()) for block in self.blocks: blocks += [block.txs] return TransactionPost( diff --git a/src/ethereum_test_types/__init__.py b/src/ethereum_test_types/__init__.py index 3fac2dd37d9..49bc4ee9ace 100644 --- a/src/ethereum_test_types/__init__.py +++ b/src/ethereum_test_types/__init__.py @@ -26,6 +26,7 @@ compute_create_address, compute_eofcreate_address, ) +from .phase_manager import TestPhase, TestPhaseManager from .receipt_types import TransactionReceipt from .request_types import ( ConsolidationRequest, @@ -66,6 +67,8 @@ "Removable", "Requests", "TestParameterGroup", + "TestPhase", + "TestPhaseManager", "Transaction", "TransactionDefaults", "TransactionReceipt", diff --git a/src/ethereum_test_types/phase_manager.py b/src/ethereum_test_types/phase_manager.py new file mode 100644 index 00000000000..8c35626ec8a --- /dev/null +++ b/src/ethereum_test_types/phase_manager.py @@ -0,0 +1,95 @@ +"""Test phase management for Ethereum tests.""" + +from contextlib import contextmanager +from enum import Enum +from typing import Any, Iterator, List, Optional + +from pydantic import GetCoreSchemaHandler +from pydantic_core.core_schema import ( + PlainValidatorFunctionSchema, + no_info_plain_validator_function, +) + + +class TestPhase(Enum): + """Test phase for state and blockchain tests.""" + + SETUP = "setup" + EXECUTION = "execution" + + +class TestPhaseManager: + """ + Manages test phases and collects transactions and blocks by phase. + This class provides a mechanism for "setup" and "execution" phases, + Only supports "setup" and "execution" phases now. + """ + + def __init__(self, *args, **kwargs): + """Initialize TestPhaseManager with empty transactions and blocks.""" + self.setup_transactions: List = [] + self.setup_blocks: List = [] + self.execution_transactions: List = [] + self.execution_blocks: List = [] + self._current_phase: Optional[TestPhase] = TestPhase.EXECUTION + + @contextmanager + def setup(self) -> Iterator["TestPhaseManager"]: + """Context manager for the setup phase of a benchmark test.""" + old_phase = self._current_phase + self._current_phase = TestPhase.SETUP + try: + yield self + finally: + self._current_phase = old_phase + + @contextmanager + def execution(self) -> Iterator["TestPhaseManager"]: + """Context manager for the execution phase of a test.""" + old_phase = self._current_phase + self._current_phase = TestPhase.EXECUTION + try: + yield self + finally: + self._current_phase = old_phase + + def add_transaction(self, tx) -> None: + """Add a transaction to the current phase.""" + current_phase = self._current_phase + tx.test_phase = current_phase + + if current_phase == TestPhase.EXECUTION: + self.execution_transactions.append(tx) + else: + self.setup_transactions.append(tx) + + def add_block(self, block) -> None: + """Add a block to the current phase.""" + current_phase = self._current_phase + for tx in block.txs: + tx.test_phase = current_phase + + if current_phase == TestPhase.EXECUTION: + self.execution_blocks.append(block) + else: + self.setup_blocks.append(block) + + def get_current_phase(self) -> Optional[TestPhase]: + """Get the current test phase.""" + return self._current_phase + + @staticmethod + def __get_pydantic_core_schema__( + source_type: Any, handler: GetCoreSchemaHandler + ) -> PlainValidatorFunctionSchema: + """Pydantic schema for TestPhaseManager.""" + + def validate_test_phase_manager(value): + """Return the TestPhaseManager instance as-is.""" + if isinstance(value, source_type): + return value + return source_type() + + return no_info_plain_validator_function( + validate_test_phase_manager, + ) diff --git a/src/ethereum_test_types/tests/test_phase_manager.py b/src/ethereum_test_types/tests/test_phase_manager.py new file mode 100644 index 00000000000..da4aed3cb04 --- /dev/null +++ b/src/ethereum_test_types/tests/test_phase_manager.py @@ -0,0 +1,278 @@ +"""Test suite for TestPhaseManager functionality.""" + +import pytest + +from ethereum_test_base_types import Address +from ethereum_test_tools import Block, Transaction + +from ..phase_manager import TestPhase, TestPhaseManager + + +def test_test_phase_enum_values(): + """Test that TestPhase enum has correct values.""" + assert TestPhase.SETUP.value == "setup" + assert TestPhase.EXECUTION.value == "execution" + + +def test_phase_manager_initialization(): + """Test TestPhaseManager initialization.""" + manager = TestPhaseManager() + assert len(manager.setup_transactions) == 0 + assert len(manager.setup_blocks) == 0 + assert len(manager.execution_transactions) == 0 + assert len(manager.execution_blocks) == 0 + assert manager.get_current_phase() == TestPhase.EXECUTION + + +def test_phase_manager_accepts_args_kwargs(): + """Test that __init__ accepts arbitrary args and kwargs.""" + # These should not cause errors + manager1 = TestPhaseManager() + manager2 = TestPhaseManager("some_arg") + manager3 = TestPhaseManager(some_kwarg="value") + manager4 = TestPhaseManager("arg1", "arg2", kwarg1="val1", kwarg2="val2") + + # All should have the same initialization + for manager in [manager1, manager2, manager3, manager4]: + assert len(manager.setup_transactions) == 0 + assert len(manager.setup_blocks) == 0 + assert len(manager.execution_transactions) == 0 + assert len(manager.execution_blocks) == 0 + assert manager.get_current_phase() == TestPhase.EXECUTION + + +def test_add_transaction_execution_phase(): + """Test adding transaction in execution phase.""" + manager = TestPhaseManager() + tx = Transaction(to=Address(0x123), value=100, gas_limit=21000) + + manager.add_transaction(tx) + + assert len(manager.execution_transactions) == 1 + assert manager.execution_transactions[0] == tx + assert tx.test_phase == TestPhase.EXECUTION.value + assert len(manager.setup_transactions) == 0 + + +def test_add_transaction_setup_phase(): + """Test adding transaction in setup phase.""" + manager = TestPhaseManager() + tx = Transaction(to=Address(0x456), value=50, gas_limit=21000) + + with manager.setup(): + manager.add_transaction(tx) + + assert len(manager.setup_transactions) == 1 + assert manager.setup_transactions[0] == tx + assert tx.test_phase == TestPhase.SETUP.value + assert len(manager.execution_transactions) == 0 + + +def test_add_block_execution_phase(): + """Test adding block in execution phase.""" + manager = TestPhaseManager() + tx1 = Transaction(to=Address(0x111), value=100, gas_limit=21000) + tx2 = Transaction(to=Address(0x222), value=200, gas_limit=21000) + block = Block(txs=[tx1, tx2]) + + manager.add_block(block) + + assert len(manager.execution_blocks) == 1 + assert manager.execution_blocks[0] == block + assert tx1.test_phase == TestPhase.EXECUTION.value + assert tx2.test_phase == TestPhase.EXECUTION.value + assert len(manager.setup_blocks) == 0 + + +def test_add_block_setup_phase(): + """Test adding block in setup phase.""" + manager = TestPhaseManager() + tx1 = Transaction(to=Address(0x333), value=100, gas_limit=21000) + tx2 = Transaction(to=Address(0x444), value=200, gas_limit=21000) + block = Block(txs=[tx1, tx2]) + + with manager.setup(): + manager.add_block(block) + + assert len(manager.setup_blocks) == 1 + assert manager.setup_blocks[0] == block + assert tx1.test_phase == TestPhase.SETUP.value + assert tx2.test_phase == TestPhase.SETUP.value + assert len(manager.execution_blocks) == 0 + + +@pytest.mark.parametrize( + ["num_setup_txs", "num_setup_blocks", "num_exec_txs", "num_exec_blocks"], + [ + pytest.param(0, 0, 1, 0, id="exec_tx_only"), + pytest.param(1, 0, 0, 0, id="setup_tx_only"), + pytest.param(0, 1, 0, 0, id="setup_block_only"), + pytest.param(0, 0, 0, 1, id="exec_block_only"), + pytest.param(2, 1, 3, 2, id="mixed_operations"), + pytest.param(5, 0, 0, 5, id="many_items"), + ], +) +def test_mixed_operations(num_setup_txs, num_setup_blocks, num_exec_txs, num_exec_blocks): + """Test mixed operations across phases.""" + manager = TestPhaseManager() + + # Add setup items + with manager.setup(): + for i in range(num_setup_txs): + tx = Transaction(to=Address(0x1000 + i), value=i * 10, gas_limit=21000) + manager.add_transaction(tx) + + for i in range(num_setup_blocks): + tx = Transaction(to=Address(0x2000 + i), value=i * 100, gas_limit=21000) + block = Block(txs=[tx]) + manager.add_block(block) + + # Add execution items + for i in range(num_exec_txs): + tx = Transaction(to=Address(0x3000 + i), value=i * 20, gas_limit=21000) + manager.add_transaction(tx) + + for i in range(num_exec_blocks): + tx = Transaction(to=Address(0x4000 + i), value=i * 200, gas_limit=21000) + block = Block(txs=[tx]) + manager.add_block(block) + + # Verify counts + assert len(manager.setup_transactions) == num_setup_txs + assert len(manager.setup_blocks) == num_setup_blocks + assert len(manager.execution_transactions) == num_exec_txs + assert len(manager.execution_blocks) == num_exec_blocks + + # Verify phase markers + for tx in manager.setup_transactions: + assert tx.test_phase == TestPhase.SETUP.value + + for block in manager.setup_blocks: + for tx in block.txs: + assert tx.test_phase == TestPhase.SETUP.value + + for tx in manager.execution_transactions: + assert tx.test_phase == TestPhase.EXECUTION.value + + for block in manager.execution_blocks: + for tx in block.txs: + assert tx.test_phase == TestPhase.EXECUTION.value + + +def test_empty_block_handling(): + """Test handling of empty blocks.""" + manager = TestPhaseManager() + empty_block = Block(txs=[]) + + with manager.setup(): + manager.add_block(empty_block) + + assert len(manager.setup_blocks) == 1 + assert len(manager.setup_blocks[0].txs) == 0 + + +def test_block_with_many_transactions(): + """Test block with many transactions gets all transactions marked.""" + manager = TestPhaseManager() + + # Create block with multiple transactions + transactions = [ + Transaction(to=Address(0x100 + i), value=i * 10, gas_limit=21000) for i in range(5) + ] + block = Block(txs=transactions) + + with manager.setup(): + manager.add_block(block) + + # Verify all transactions in the block have the setup phase + assert len(manager.setup_blocks) == 1 + setup_block = manager.setup_blocks[0] + assert len(setup_block.txs) == 5 + + for tx in setup_block.txs: + assert tx.test_phase == TestPhase.SETUP.value + + +def test_phase_switching_preserves_existing_data(): + """Test that phase switching doesn't affect existing data.""" + manager = TestPhaseManager() + + # Add data in execution phase + exec_tx1 = Transaction(to=Address(0x100), value=100, gas_limit=21000) + manager.add_transaction(exec_tx1) + + # Switch to setup and add data + with manager.setup(): + setup_tx = Transaction(to=Address(0x200), value=50, gas_limit=21000) + manager.add_transaction(setup_tx) + + # Switch back to execution within setup phase + with manager.execution(): + exec_tx2 = Transaction(to=Address(0x300), value=200, gas_limit=21000) + manager.add_transaction(exec_tx2) + + # Add more data in execution phase after phase changes + exec_tx3 = Transaction(to=Address(0x400), value=300, gas_limit=21000) + manager.add_transaction(exec_tx3) + + # Verify data integrity + assert len(manager.setup_transactions) == 1 + assert len(manager.execution_transactions) == 3 + + assert manager.setup_transactions[0].value == 50 + exec_values = [tx.value for tx in manager.execution_transactions] + assert exec_values == [100, 200, 300] + + +def test_multiple_managers_phase_isolation(): + """Test that phase dependence for managers is isolated.""" + manager1 = TestPhaseManager() + manager2 = TestPhaseManager() + + # Both managers should start in execution phase + assert manager1.get_current_phase() == TestPhase.EXECUTION + assert manager2.get_current_phase() == TestPhase.EXECUTION + + # Change phase of manager1 to setup + with manager1.setup(): + # manager1 should be in setup phase + assert manager1.get_current_phase() == TestPhase.SETUP + # manager2 should still be in execution phase + assert manager2.get_current_phase() == TestPhase.EXECUTION + + # Add transactions to verify phase isolation + tx1 = Transaction(to=Address(0x100), value=100, gas_limit=21000) + tx2 = Transaction(to=Address(0x200), value=200, gas_limit=21000) + + manager1.add_transaction(tx1) + manager2.add_transaction(tx2) + + # Verify transactions are in correct phases + assert tx1.test_phase == TestPhase.SETUP.value + assert tx2.test_phase == TestPhase.EXECUTION.value + + # After exiting setup context, manager1 should return to execution phase + assert manager1.get_current_phase() == TestPhase.EXECUTION + assert manager2.get_current_phase() == TestPhase.EXECUTION + + # Verify final state + assert len(manager1.setup_transactions) == 1 + assert len(manager1.execution_transactions) == 0 + assert len(manager2.setup_transactions) == 0 + assert len(manager2.execution_transactions) == 1 + + +@pytest.mark.parametrize( + ["manager_instance", "expected_phase"], + [ + pytest.param(TestPhaseManager(), TestPhase.EXECUTION, id="new_instance"), + ], +) +def test_manager_properties(manager_instance, expected_phase): + """Test TestPhaseManager instance properties.""" + assert isinstance(manager_instance, TestPhaseManager) + assert manager_instance.get_current_phase() == expected_phase + assert hasattr(manager_instance, "setup_transactions") + assert hasattr(manager_instance, "setup_blocks") + assert hasattr(manager_instance, "execution_transactions") + assert hasattr(manager_instance, "execution_blocks") diff --git a/src/ethereum_test_types/transaction_types.py b/src/ethereum_test_types/transaction_types.py index ce1117635e0..ee6e205875f 100644 --- a/src/ethereum_test_types/transaction_types.py +++ b/src/ethereum_test_types/transaction_types.py @@ -292,6 +292,7 @@ class Transaction( zero: ClassVar[Literal[0]] = 0 metadata: TransactionTestMetadata | None = Field(None, exclude=True) + test_phase: str | None = None model_config = ConfigDict(validate_assignment=True) diff --git a/src/pytest_plugins/shared/execute_fill.py b/src/pytest_plugins/shared/execute_fill.py index 0df88c2ef38..9441f810557 100644 --- a/src/pytest_plugins/shared/execute_fill.py +++ b/src/pytest_plugins/shared/execute_fill.py @@ -15,6 +15,7 @@ from ..spec_version_checker.spec_version_checker import EIPSpecTestItem ALL_FIXTURE_PARAMETERS = { + "test_phase_manager", "gas_benchmark_value", "genesis_environment", "env", diff --git a/src/pytest_plugins/shared/transaction_fixtures.py b/src/pytest_plugins/shared/transaction_fixtures.py index cfe0cd6ef52..ee419b84798 100644 --- a/src/pytest_plugins/shared/transaction_fixtures.py +++ b/src/pytest_plugins/shared/transaction_fixtures.py @@ -9,6 +9,7 @@ from ethereum_test_base_types import AccessList from ethereum_test_tools import Opcodes as Op from ethereum_test_types import AuthorizationTuple, Transaction, add_kzg_version +from ethereum_test_types.phase_manager import TestPhaseManager @pytest.fixture @@ -161,3 +162,9 @@ def typed_transaction(request, fork): f"Please add the missing fixture to " f"src/pytest_plugins/shared/transaction_fixtures.py" ) from e + + +@pytest.fixture(scope="function") +def test_phase_manager(): + """Fixture providing access to a test phase manager.""" + return TestPhaseManager() diff --git a/tests/benchmark/test_worst_stateful_opcodes.py b/tests/benchmark/test_worst_stateful_opcodes.py index f5cd6c0289f..21f73da3cc3 100644 --- a/tests/benchmark/test_worst_stateful_opcodes.py +++ b/tests/benchmark/test_worst_stateful_opcodes.py @@ -22,6 +22,7 @@ compute_create2_address, compute_create_address, ) +from ethereum_test_types import TestPhaseManager from ethereum_test_vm import Opcodes as Op from .helpers import code_loop_precompile_call @@ -462,14 +463,16 @@ def test_worst_storage_access_warm( def test_worst_blockhash( blockchain_test: BlockchainTestFiller, pre: Alloc, + test_phase_manager: TestPhaseManager, gas_benchmark_value: int, ): """ Test running a block with as many blockhash accessing oldest allowed block as possible. """ - # Create 256 dummy blocks to fill the blockhash window. - blocks = [Block()] * 256 + with test_phase_manager.setup(): + for _ in range(256): + test_phase_manager.add_block(Block()) # Always ask for the oldest allowed BLOCKHASH block. execution_code = Op.PUSH1(1) + While( @@ -481,12 +484,14 @@ def test_worst_blockhash( gas_limit=gas_benchmark_value, sender=pre.fund_eoa(), ) - blocks.append(Block(txs=[op_tx])) + + with test_phase_manager.execution(): + test_phase_manager.add_transaction(op_tx) blockchain_test( pre=pre, post={}, - blocks=blocks, + test_phase_manager=test_phase_manager, )