From d19822166524b988556274907a73055aea93bc69 Mon Sep 17 00:00:00 2001 From: LouisTsai-Csie Date: Wed, 27 May 2026 07:21:37 +0000 Subject: [PATCH 1/5] feat: repleace debug setHead by FCU update --- .../execute/rpc/chain_builder_eth_rpc.py | 27 +++++++++++++++ .../plugins/fill_stateful/fill_stateful.py | 33 +++++++------------ 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/chain_builder_eth_rpc.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/chain_builder_eth_rpc.py index 66fc5c75e80..53b27a4fca8 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/chain_builder_eth_rpc.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/chain_builder_eth_rpc.py @@ -347,3 +347,30 @@ def fund_via_withdrawals( new_payload, payload_attributes.parent_beacon_block_root, ) + + def set_canonical_head(self, head_block_hash: Hash) -> None: + """ + Reorg the canonical head to head_block_hash via + ``engine_forkchoiceUpdated``. + """ + head_block = self.get_block_by_hash(head_block_hash) + assert head_block is not None, ( + f"cannot reset head to unknown block {head_block_hash}" + ) + head_fork = self.fork.fork_at( + block_number=HexNumber(head_block["number"]), + timestamp=HexNumber(head_block["timestamp"]), + ) + fcu_version = head_fork.engine_forkchoice_updated_version() + assert fcu_version is not None, ( + "Fork does not support engine forkchoice_updated" + ) + response = self.engine_rpc.forkchoice_updated( + ForkchoiceState(head_block_hash=head_block_hash), + None, + version=fcu_version, + ) + assert response.payload_status.status == PayloadStatusEnum.VALID, ( + f"forkchoice_updated reset to {head_block_hash} was not VALID " + f"(got {response.payload_status.status})" + ) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py index 0d50c671e6a..38579872248 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py @@ -41,7 +41,7 @@ TransitionFork, ) from execution_testing.logging import get_logger -from execution_testing.rpc import DebugRPC, EngineRPC, EthRPC +from execution_testing.rpc import EngineRPC, EthRPC from execution_testing.specs.blockchain import ( payload_metadata_to_fixture, ) @@ -444,12 +444,6 @@ def max_gas_limit_per_test( # --------------------------------------------------------------------------- -@pytest.fixture(scope="session") -def debug_rpc(eth_rpc: EthRPC) -> DebugRPC: - """DebugRPC on the same endpoint as eth_rpc (for debug_setHead).""" - return DebugRPC(eth_rpc.url) - - @pytest.fixture(scope="session") def client_backend( eth_rpc: ChainBuilderEthRPC, @@ -693,35 +687,32 @@ def t8n( @pytest.fixture(autouse=True, scope="function") def _reset_chain_between_tests( client_backend: ClientBackend, - debug_rpc: DebugRPC, eth_rpc: "ChainBuilderEthRPC", ) -> Generator[None, None, None]: """ - Rewind to start_block after each test so the chain is identical for - every fill. ``debug_setHead`` only takes a number, so after the - rewind we re-fetch ``latest`` and fail loudly if the hash drifted - (e.g. live reorg of a same-numbered block). + # Rewind to start_block after each test via engine_forkchoiceUpdated. + # Re-fetch latest and fail if hash drifted (detects live reorgs). """ yield if client_backend.start_block is None: return - start_hex = client_backend.start_block["number"] expected_hash = client_backend.start_block["hash"] - # Skip when head already at start (geth rejects same-block setHead). + current_head = eth_rpc.get_block_by_number("latest") if current_head is not None and current_head["hash"] == expected_hash: return try: - debug_rpc.set_head(start_hex) + eth_rpc.set_canonical_head(Hash(expected_hash)) except Exception as e: - pytest.exit(f"debug_setHead failed — subsequent fixtures invalid: {e}") + pytest.exit( + f"forkchoice reset failed, subsequent fixtures invalid: {e}" + ) head = eth_rpc.get_block_by_number("latest") if head is None or head["hash"] != expected_hash: observed = head["hash"] if head is not None else "" pytest.exit( - f"debug_setHead landed on hash {observed} but expected " - f"{expected_hash} (start_block at number {start_hex}). The " - "live chain may have reorged out from under fill-stateful; " - "rerun against a quiescent client or use --snapshot-block " - "with an explicit hash." + f"forkchoice reset landed on hash {observed} but expected " + f"{expected_hash}. The live chain may have reorged out from " + "under fill-stateful; rerun against a quiescent client or use " + "--snapshot-block with an explicit hash." ) From 0252bec93f69e85d35db564bc7493f28b2948268 Mon Sep 17 00:00:00 2001 From: LouisTsai-Csie Date: Wed, 27 May 2026 07:40:34 +0000 Subject: [PATCH 2/5] feat: increase gas limit via empty blocks --- .../execute/rpc/chain_builder_eth_rpc.py | 35 ++++++++++++ .../plugins/fill_stateful/fill_stateful.py | 54 ++++++++++++------- 2 files changed, 69 insertions(+), 20 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/chain_builder_eth_rpc.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/chain_builder_eth_rpc.py index 53b27a4fca8..61d30bdd146 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/chain_builder_eth_rpc.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/chain_builder_eth_rpc.py @@ -348,6 +348,41 @@ def fund_via_withdrawals( payload_attributes.parent_beacon_block_root, ) + def bump_block_gas_limit( + self, + block_count: int, + ) -> List[EnginePayloadMetadata]: + """ + Build empty block to increase the block gas limit to target. + """ + if block_count <= 0: + return [] + assert self.testing_rpc is not None, ( + "bump_block_gas_limit requires testing_rpc" + ) + captured: List[EnginePayloadMetadata] = [] + with self.block_building_lock: + for _ in range(block_count): + head_block = self.get_block_by_number("latest") + assert head_block is not None + next_timestamp = int(HexNumber(head_block["timestamp"]) + 1) + payload_attributes = self._payload_attributes( + next_timestamp=next_timestamp, + ) + new_payload = self.testing_rpc.build_block( + parent_block_hash=Hash(head_block["hash"]), + payload_attributes=payload_attributes, + transactions=[], + extra_data=Bytes(b""), + ) + captured.append( + self._finalize_payload( + new_payload, + payload_attributes.parent_beacon_block_root, + ) + ) + return captured + def set_canonical_head(self, head_block_hash: Hash) -> None: """ Reorg the canonical head to head_block_hash via diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py index 38579872248..55cb4d4820c 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py @@ -117,6 +117,18 @@ def pytest_addoption(parser: pytest.Parser) -> None: "key is generated and funded via CL withdrawal if omitted." ), ) + group.addoption( + "--gas-bump-blocks", + action="store", + dest="gas_bump_blocks", + default=5000, + type=int, + help=( + "Empty blocks at start to increase block gas limit." + "Each block increase ≤ parent_gas_limit/1024." + "Default: 5000 blocks." + ), + ) group.addoption( "--snapshot-block", action="store", @@ -562,32 +574,36 @@ def _session_pre_run( request: pytest.FixtureRequest, ) -> None: """ - Session pre-run: capture snapshot, fund seed, deploy factory, capture - start block, write ``pre_run/.json``. - - The file is named after the start block hash so per-test - ``BlockchainEngineStatefulFixture`` instances reference their setup - file implicitly via their own ``start_block_hash`` field — leaves - room for multiple pre-run files (e.g. different setup variants off - one snapshot) without coordinating filenames. - - Pre-run helpers return their built ``EnginePayloadMetadata`` directly; - we collect them into ``captured`` and serialise via - ``payload_metadata_to_fixture``. + # 1. Snapshot anchor via client-returned hash (survives reorg). + # 2. Ramp gas limit with empty blocks before funding/setup. + # 3. Fund seed key via CL withdrawal. + # 4. Deploy deterministic factory if needed. + # 5. Capture start block (head after setup). + # 6. Write to pre_run/.json for implicit lookup. """ if is_help_or_collectonly_mode(request.config): return - # 1. Snapshot anchor. Either way, the client-returned hash is what we - # record — a later reorg can't silently re-anchor by block number. + # Anchor snapshot (client hash, reorg-safe). client_backend.snapshot_block = snapshot_block logger.info( f"Snapshot block {snapshot_block['number']} " f"hash={snapshot_block['hash'][:20]}..." ) - # 2. Fund seed key via CL withdrawal; helper returns the built payload. captured: List[EnginePayloadMetadata] = [] + + # Ramp gas limit (empty blocks) via empty blocks. + bump_payloads = eth_rpc.bump_block_gas_limit( + request.config.getoption("gas_bump_blocks") + ) + captured.extend(bump_payloads) + if bump_payloads: + logger.info( + f"Ramped block gas limit with {len(bump_payloads)} empty blocks" + ) + + # 3. Fund seed key via CL withdrawal. fund_payload = eth_rpc.fund_via_withdrawals( [(Address(session_worker_key), SEED_FUNDING_WEI)] ) @@ -595,7 +611,7 @@ def _session_pre_run( captured.append(fund_payload) logger.info(f"Funded {Address(session_worker_key)} via withdrawal") - # 3. Deploy deterministic factory if not already present. + # 4. Deploy deterministic factory if needed. lock_file = session_temp_folder / "fill_stateful_setup.lock" with FileLock(lock_file): if ( @@ -614,7 +630,7 @@ def _session_pre_run( ) captured.extend(deploy_payloads) - # 4. Capture start block (head after global setup). + # 5. Capture start block. start_block = eth_rpc.get_block_by_number("latest") assert start_block is not None, "Failed to fetch start block" client_backend.start_block = start_block @@ -623,9 +639,7 @@ def _session_pre_run( f"hash={start_block['hash'][:20]}..." ) - # 5. Persist captured payloads to pre_run/.json. - # Per-test fixtures already carry start_block_hash; naming the - # pre-run file after that hash makes lookup a direct path build. + # Write pre_run/.json. if captured: output_dir = Path(request.config.getoption("output")) pre_run_dir = ( From 05f3c16b2af478e5a87701aa3a732efb9d15f2d4 Mon Sep 17 00:00:00 2001 From: LouisTsai-Csie Date: Wed, 27 May 2026 08:36:19 +0000 Subject: [PATCH 3/5] feat: deterministic sender pool allocation --- .../plugins/fill_stateful/fill_stateful.py | 40 +++++++++++++++---- .../bloatnet/test_transaction_types.py | 5 ++- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py index 55cb4d4820c..bf8e8112478 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py @@ -16,10 +16,11 @@ import os import secrets from pathlib import Path -from typing import Any, Generator, List +from typing import Any, Generator, List, Tuple from urllib.parse import urlparse, urlunparse import pytest +from ethereum.crypto.hash import keccak256 from filelock import FileLock from execution_testing.base_types import ( @@ -61,10 +62,16 @@ from ..shared.helpers import is_help_or_collectonly_mode from ..shared.live_client_flags import FEE_BUMP_MULTIPLIER -# 1 billion ETH. Withdrawals are Gwei (u64-capped); much higher risks -# overflow on some clients, this is plenty for any single fill session. +# 1B ETH for seed account SEED_FUNDING_WEI = 10**9 * 10**18 +# 1 ETH per deterministic sender-pool account. +POOL_FUNDING_WEI = 10**18 + +SENDER_BASE_KEY = int.from_bytes( + keccak256(b"gas-repricings-private-key"), "big" +) + logger = get_logger(__name__) @@ -129,6 +136,17 @@ def pytest_addoption(parser: pytest.Parser) -> None: "Default: 5000 blocks." ), ) + group.addoption( + "--sender-pool-size", + action="store", + dest="sender_pool_size", + default=15000, + type=int, + help=( + "Sender-pool accounts to fund via CL withdrawals for 1 ETH" + "Default: 15000." + ), + ) group.addoption( "--snapshot-block", action="store", @@ -603,13 +621,19 @@ def _session_pre_run( f"Ramped block gas limit with {len(bump_payloads)} empty blocks" ) - # 3. Fund seed key via CL withdrawal. - fund_payload = eth_rpc.fund_via_withdrawals( - [(Address(session_worker_key), SEED_FUNDING_WEI)] - ) + # Fund the seed key and the deterministic sender pool + pool_size = request.config.getoption("sender_pool_size") + funding_targets: List[Tuple[Address, int]] = [ + (Address(session_worker_key), SEED_FUNDING_WEI) + ] + funding_targets += [ + (Address(EOA(key=SENDER_BASE_KEY + i)), POOL_FUNDING_WEI) + for i in range(pool_size) + ] + fund_payload = eth_rpc.fund_via_withdrawals(funding_targets) if fund_payload is not None: captured.append(fund_payload) - logger.info(f"Funded {Address(session_worker_key)} via withdrawal") + logger.info(f"Funded seed key and {pool_size} sender pool accounts") # 4. Deploy deterministic factory if needed. lock_file = session_temp_folder / "fill_stateful_setup.lock" diff --git a/tests/benchmark/stateful/bloatnet/test_transaction_types.py b/tests/benchmark/stateful/bloatnet/test_transaction_types.py index 875c69fecdf..171c12a9e08 100644 --- a/tests/benchmark/stateful/bloatnet/test_transaction_types.py +++ b/tests/benchmark/stateful/bloatnet/test_transaction_types.py @@ -16,13 +16,14 @@ Transaction, compute_create2_address, compute_create_address, + keccak256, ) # Deterministic sender pool of 15K accounts. # Funded via system contract withdrawals (funding.txt) in payload generation. # Placed outside pre-allocation to ensure accounts remain uncached. -SENDER_BASE_KEY = ( - 0x1111111111111111111111111111111111111111111111111111111111111111 +SENDER_BASE_KEY = int.from_bytes( + keccak256(b"gas-repricings-private-key"), "big" ) From 54f43ad277880bf82f0e9c506249df2b6ef5534c Mon Sep 17 00:00:00 2001 From: LouisTsai-Csie Date: Wed, 27 May 2026 14:04:57 +0000 Subject: [PATCH 4/5] refactor: batch receipt request --- .../client_clis/client_backend.py | 8 ++++--- .../testing/src/execution_testing/rpc/rpc.py | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/testing/src/execution_testing/client_clis/client_backend.py b/packages/testing/src/execution_testing/client_clis/client_backend.py index eebd02d278d..3d8260645af 100644 --- a/packages/testing/src/execution_testing/client_clis/client_backend.py +++ b/packages/testing/src/execution_testing/client_clis/client_backend.py @@ -281,10 +281,12 @@ def _finalize( def _fetch_receipts( self, txs: List[Transaction] ) -> List[TransactionReceipt]: - """Fetch receipts for each transaction. TODO: batch via JSON-RPC.""" + """Fetch receipts for all block txs in one JSON-RPC batch.""" + receipts_data = self.eth_rpc.get_transaction_receipts( + [tx.hash for tx in txs] + ) receipts: List[TransactionReceipt] = [] - for tx in txs: - receipt_data = self.eth_rpc.get_transaction_receipt(tx.hash) + for tx, receipt_data in zip(txs, receipts_data, strict=True): if receipt_data is None: raise RuntimeError( f"No receipt found for transaction {tx.hash}" diff --git a/packages/testing/src/execution_testing/rpc/rpc.py b/packages/testing/src/execution_testing/rpc/rpc.py index 2c8cdcb50b5..a647ed7a3bf 100644 --- a/packages/testing/src/execution_testing/rpc/rpc.py +++ b/packages/testing/src/execution_testing/rpc/rpc.py @@ -764,6 +764,30 @@ def get_transaction_receipt( ) ).result_or_raise() + def get_transaction_receipts( + self, transaction_hashes: Sequence[Hash] + ) -> List[dict[str, Any] | None]: + """ + Get transaction receipts for a list of transaction hashes. + Use batch requests to avoid RPC overload. + """ + if not transaction_hashes: + return [] + receipts: List[dict[str, Any] | None] = [] + batch_size = self.max_transactions_per_batch + for i in range(0, len(transaction_hashes), batch_size): + chunk = transaction_hashes[i : i + batch_size] + calls = [ + RPCCall( + method="getTransactionReceipt", + params=[f"{tx_hash}"], + ) + for tx_hash in chunk + ] + responses = self.post_batch_request(calls=calls) + receipts.extend(r.result_or_raise() for r in responses) + return receipts + def get_storage_at( self, address: Address, From 8477bfe48b8eaeebd7727bf0efc5d3626ce40d2e Mon Sep 17 00:00:00 2001 From: LouisTsai-Csie Date: Wed, 27 May 2026 14:40:04 +0000 Subject: [PATCH 5/5] refactor: split large fill-stateful file --- .../plugins/fill_stateful/fill_stateful.py | 176 +---------------- .../plugins/fill_stateful/hive_session.py | 180 ++++++++++++++++++ 2 files changed, 184 insertions(+), 172 deletions(-) create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/hive_session.py diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py index bf8e8112478..697018a5a2d 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py @@ -37,30 +37,20 @@ ) from execution_testing.forks import ( Fork, - ForkSetAdapter, - InvalidForkError, TransitionFork, ) from execution_testing.logging import get_logger -from execution_testing.rpc import EngineRPC, EthRPC +from execution_testing.rpc import EthRPC from execution_testing.specs.blockchain import ( payload_metadata_to_fixture, ) from execution_testing.test_types import EOA -from execution_testing.test_types.chain_config_types import ( - ChainConfigDefaults, -) from ..execute import contracts from ..execute.rpc.chain_builder_eth_rpc import ChainBuilderEthRPC -from ..execute.rpc.hive import ( - build_client_files, - build_client_genesis_dict, - build_genesis_header, - build_hive_environment, -) from ..shared.helpers import is_help_or_collectonly_mode from ..shared.live_client_flags import FEE_BUMP_MULTIPLIER +from .hive_session import configure_hive, teardown_hive # 1B ETH for seed account SEED_FUNDING_WEI = 10**9 * 10**18 @@ -163,122 +153,6 @@ def pytest_addoption(parser: pytest.Parser) -> None: ) -def _resolve_session_fork( - config: pytest.Config, -) -> Fork | TransitionFork: - """ - Resolve the single fork from ``--fork`` in hive mode. - - Runs in ``pytest_configure`` before forks.py populates - ``selected_fork_set``, so we can't use ``session_fork`` here — the - hive genesis/ruleset must be built before remote.py's pytest_configure - pings the (not-yet-started) client. - """ - fork_arg = config.getoption("single_fork", default="") - if not fork_arg: - pytest.exit( - "--fork is required in --hive-mode (single-fork only).", - returncode=pytest.ExitCode.USAGE_ERROR, - ) - try: - fork_set = ForkSetAdapter.validate_python(fork_arg) - except InvalidForkError: - pytest.exit( - f"Unsupported fork provided to --fork: {fork_arg!r}", - returncode=pytest.ExitCode.USAGE_ERROR, - ) - if len(fork_set) != 1: - pytest.exit( - f"Expected exactly one fork in --hive-mode, got {len(fork_set)} " - f"({sorted(f.name() for f in fork_set)}).", - returncode=pytest.ExitCode.USAGE_ERROR, - ) - return next(iter(fork_set)) - - -def _hive_chain_id(config: pytest.Config) -> int: - """Resolve chain id from CLI/env, defaulting to ChainConfigDefaults.""" - cli_value = config.getoption("chain_id", default=None) - if cli_value is not None: - return int(cli_value) - env_value = os.environ.get("CHAIN_ID") - if env_value is not None: - return int(env_value) - return ChainConfigDefaults.chain_id - - -def _configure_hive(config: pytest.Config) -> None: - """ - Start a hive simulator session and a single execution client, then - rewrite ``config.option`` so the rest of the plugin stack (remote.py - in particular) connects to the hive-managed client. - """ - hive_simulator_url = config.getoption("hive_simulator") - if hive_simulator_url is None: - pytest.exit( - "The HIVE_SIMULATOR environment variable is not set.\n\n" - "If running locally, start hive in --dev mode, for example:\n" - "./hive --dev --client go-ethereum\n\n" - "and set the HIVE_SIMULATOR to the reported URL. For example, " - "in bash:\n" - "export HIVE_SIMULATOR=http://127.0.0.1:3000\n" - "or in fish:\n" - "set -x HIVE_SIMULATOR http://127.0.0.1:3000" - ) - from hive.simulation import Simulation - - session_fork = _resolve_session_fork(config) - chain_id = _hive_chain_id(config) - - simulator = Simulation(url=hive_simulator_url) - suite = simulator.start_suite( - name="fill-stateful test suite", - description="Test suite used to drive a fill-stateful session", - ) - config.hive_test_suite = suite # type: ignore[attr-defined] - base_test = suite.start_test( - name="fill-stateful base test", - description=( - "Base test in the fill-stateful suite hosting the long-lived " - "execution client." - ), - ) - config.hive_base_test = base_test # type: ignore[attr-defined] - - # base_pre=None: seed key funded via CL withdrawal and deterministic - # factory deployed on-chain by ``_session_pre_run``, so genesis only - # needs the fork's required system contracts. - pre_alloc, genesis_header = build_genesis_header(session_fork, None) - genesis_dict = build_client_genesis_dict(pre_alloc, genesis_header) - - client_type = simulator.client_types()[0] - client = base_test.start_client( - client_type=client_type, - environment=build_hive_environment(session_fork, chain_id), - files=build_client_files(genesis_dict), - ) - if client is None: - pytest.exit( - f"Unable to start hive client {client_type.name}. Check the " - "hive server logs for more information." - ) - config.hive_client = client # type: ignore[attr-defined] - logger.info( - f"Started hive client {client_type.name} at {client.ip} " - f"(fork={session_fork.name()}, chain_id={chain_id})" - ) - - config.option.rpc_endpoint = f"http://{client.ip}:8545" - config.option.engine_endpoint = f"http://{client.ip}:8551" - if ( - config.getoption("engine_jwt_secret", default=None) is None - and config.getoption("engine_jwt_secret_file", default=None) is None - ): - config.option.engine_jwt_secret = EngineRPC.DEFAULT_JWT_SECRET - if config.getoption("chain_id", default=None) is None: - config.option.chain_id = chain_id - - @pytest.hookimpl(tryfirst=True) def pytest_configure(config: pytest.Config) -> None: """ @@ -310,7 +184,7 @@ def pytest_configure(config: pytest.Config) -> None: config.hive_base_test = None # type: ignore[attr-defined] config.hive_client = None # type: ignore[attr-defined] if config.getoption("hive_mode", default=False): - _configure_hive(config) + configure_hive(config) else: if not config.getoption("rpc_endpoint", default=None): config.option.rpc_endpoint = "http://localhost:8545" @@ -328,43 +202,7 @@ def pytest_configure(config: pytest.Config) -> None: def pytest_unconfigure(config: pytest.Config) -> None: """Tear down hive resources started in ``pytest_configure``.""" - client = getattr(config, "hive_client", None) - if client is not None: - try: - client.stop() - except Exception as e: - logger.warning(f"Failed to stop hive client: {e}") - config.hive_client = None # type: ignore[attr-defined] - - base_test = getattr(config, "hive_base_test", None) - if base_test is not None: - from hive.testing import HiveTestResult - - test_pass = ( - getattr(config, "_fill_stateful_session_failed", False) is False - ) - try: - base_test.end( - result=HiveTestResult( - test_pass=test_pass, - details=( - "fill-stateful session complete" - if test_pass - else "fill-stateful session had failures" - ), - ) - ) - except Exception as e: - logger.warning(f"Failed to end hive base test: {e}") - config.hive_base_test = None # type: ignore[attr-defined] - - suite = getattr(config, "hive_test_suite", None) - if suite is not None: - try: - suite.end() - except Exception as e: - logger.warning(f"Failed to end hive test suite: {e}") - config.hive_test_suite = None # type: ignore[attr-defined] + teardown_hive(config) def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: @@ -463,12 +301,6 @@ def max_gas_limit_per_test( return fork_at_genesis.transaction_gas_limit_cap() -# Other live-client fixtures (``max_transactions_per_batch``, -# ``default_*``, fee fields, ``dry_run``, ...) come from -# ``shared.live_client_flags``. ``skip_cleanup`` from ``execute.pre_alloc``; -# we force it on in ``pytest_configure``. - - # --------------------------------------------------------------------------- # Backend + session setup # --------------------------------------------------------------------------- diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/hive_session.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/hive_session.py new file mode 100644 index 00000000000..287a2810055 --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/hive_session.py @@ -0,0 +1,180 @@ +""" +Hive session bootstrap for the fill-stateful plugin. +""" + +import os + +import pytest + +from execution_testing.forks import ( + Fork, + ForkSetAdapter, + InvalidForkError, + TransitionFork, +) +from execution_testing.logging import get_logger +from execution_testing.rpc import EngineRPC +from execution_testing.test_types.chain_config_types import ( + ChainConfigDefaults, +) + +from ..execute.rpc.hive import ( + build_client_files, + build_client_genesis_dict, + build_genesis_header, + build_hive_environment, +) + +logger = get_logger(__name__) + + +def _resolve_session_fork( + config: pytest.Config, +) -> Fork | TransitionFork: + """ + Resolve the single fork from ``--fork`` in hive mode. + """ + fork_arg = config.getoption("single_fork", default="") + if not fork_arg: + pytest.exit( + "--fork is required in --hive-mode (single-fork only).", + returncode=pytest.ExitCode.USAGE_ERROR, + ) + try: + fork_set = ForkSetAdapter.validate_python(fork_arg) + except InvalidForkError: + pytest.exit( + f"Unsupported fork provided to --fork: {fork_arg!r}", + returncode=pytest.ExitCode.USAGE_ERROR, + ) + if len(fork_set) != 1: + pytest.exit( + f"Expected exactly one fork in --hive-mode, got {len(fork_set)} " + f"({sorted(f.name() for f in fork_set)}).", + returncode=pytest.ExitCode.USAGE_ERROR, + ) + return next(iter(fork_set)) + + +def _hive_chain_id(config: pytest.Config) -> int: + """Resolve chain id from CLI/env, defaulting to ChainConfigDefaults.""" + cli_value = config.getoption("chain_id", default=None) + if cli_value is not None: + return int(cli_value) + env_value = os.environ.get("CHAIN_ID") + if env_value is not None: + return int(env_value) + return ChainConfigDefaults.chain_id + + +def configure_hive(config: pytest.Config) -> None: + """ + Start a hive simulator session and a single execution client, then + rewrite ``config.option`` so the rest of the plugin stack (remote.py + in particular) connects to the hive-managed client. + """ + hive_simulator_url = config.getoption("hive_simulator") + if hive_simulator_url is None: + pytest.exit( + "The HIVE_SIMULATOR environment variable is not set.\n\n" + "If running locally, start hive in --dev mode, for example:\n" + "./hive --dev --client go-ethereum\n\n" + "and set the HIVE_SIMULATOR to the reported URL. For example, " + "in bash:\n" + "export HIVE_SIMULATOR=http://127.0.0.1:3000\n" + "or in fish:\n" + "set -x HIVE_SIMULATOR http://127.0.0.1:3000" + ) + from hive.simulation import Simulation + + session_fork = _resolve_session_fork(config) + chain_id = _hive_chain_id(config) + + simulator = Simulation(url=hive_simulator_url) + suite = simulator.start_suite( + name="fill-stateful test suite", + description="Test suite used to drive a fill-stateful session", + ) + config.hive_test_suite = suite # type: ignore[attr-defined] + base_test = suite.start_test( + name="fill-stateful base test", + description=( + "Base test in the fill-stateful suite hosting the long-lived " + "execution client." + ), + ) + config.hive_base_test = base_test # type: ignore[attr-defined] + + # base_pre=None: seed key funded via CL withdrawal and deterministic + # factory deployed on-chain by ``_session_pre_run``, so genesis only + # needs the fork's required system contracts. + pre_alloc, genesis_header = build_genesis_header(session_fork, None) + genesis_dict = build_client_genesis_dict(pre_alloc, genesis_header) + + client_type = simulator.client_types()[0] + client = base_test.start_client( + client_type=client_type, + environment=build_hive_environment(session_fork, chain_id), + files=build_client_files(genesis_dict), + ) + if client is None: + pytest.exit( + f"Unable to start hive client {client_type.name}. Check the " + "hive server logs for more information." + ) + config.hive_client = client # type: ignore[attr-defined] + logger.info( + f"Started hive client {client_type.name} at {client.ip} " + f"(fork={session_fork.name()}, chain_id={chain_id})" + ) + + config.option.rpc_endpoint = f"http://{client.ip}:8545" + config.option.engine_endpoint = f"http://{client.ip}:8551" + if ( + config.getoption("engine_jwt_secret", default=None) is None + and config.getoption("engine_jwt_secret_file", default=None) is None + ): + config.option.engine_jwt_secret = EngineRPC.DEFAULT_JWT_SECRET + if config.getoption("chain_id", default=None) is None: + config.option.chain_id = chain_id + + +def teardown_hive(config: pytest.Config) -> None: + """Tear down hive resources started in ``configure_hive``.""" + client = getattr(config, "hive_client", None) + if client is not None: + try: + client.stop() + except Exception as e: + logger.warning(f"Failed to stop hive client: {e}") + config.hive_client = None # type: ignore[attr-defined] + + base_test = getattr(config, "hive_base_test", None) + if base_test is not None: + from hive.testing import HiveTestResult + + test_pass = ( + getattr(config, "_fill_stateful_session_failed", False) is False + ) + try: + base_test.end( + result=HiveTestResult( + test_pass=test_pass, + details=( + "fill-stateful session complete" + if test_pass + else "fill-stateful session had failures" + ), + ) + ) + except Exception as e: + logger.warning(f"Failed to end hive base test: {e}") + config.hive_base_test = None # type: ignore[attr-defined] + + suite = getattr(config, "hive_test_suite", None) + if suite is not None: + try: + suite.end() + except Exception as e: + logger.warning(f"Failed to end hive test suite: {e}") + config.hive_test_suite = None # type: ignore[attr-defined]