diff --git a/docs/running_tests/consume/simulators.md b/docs/running_tests/consume/simulators.md index f8b88306785..f38fbe476c8 100644 --- a/docs/running_tests/consume/simulators.md +++ b/docs/running_tests/consume/simulators.md @@ -14,3 +14,7 @@ uv run consume [OPTIONS] - Help [setting up](../hive/index.md) and [starting Hive in dev mode](../hive/dev_mode.md). - For an explanation of how the `consume` simulators work, see the [Engine](../running.md#engine) and [RLP](../running.md#rlp) sections in [Running Tests](../running.md). - Help for relevant options can be found in [Consume Cache and Fixture Inputs](./cache.md) and [Useful Pytest Options](../useful_pytest_options.md). + +## Related: Block Building + +A separate hive simulator [`build-block`](../running.md#block-building) is also fixture-driven but tests the client's **producer-side** path via the `testing_buildBlockV1` engine-API testing-namespace endpoint, rather than the consumer-side import path that the simulators above exercise. diff --git a/docs/running_tests/running.md b/docs/running_tests/running.md index d09a1b3003c..a85b826ad67 100644 --- a/docs/running_tests/running.md +++ b/docs/running_tests/running.md @@ -17,6 +17,7 @@ Both `consume` and `execute` provide sub-commands which correspond to different | [`consume enginex`](#enginex) | Client imports blocks via Engine API in Hive, optimized by client reuse | EVM, block processing, Engine API | Staging, Hive | System test | | [`consume sync`](#sync) | Client syncs from another client using Engine API in Hive | EVM, block processing, Engine API, P2P sync | Staging, Hive | System test | | [`consume rlp`](#rlp) | Client imports RLP-encoded blocks upon start-up in Hive | EVM, block processing, RLP import (sync\*) | Staging, Hive | System test | +| [`build-block`](#block-building) | Client builds blocks via Engine API testing namespace in Hive, validated against fixture | EVM, block production, Engine API testing namespace | Staging, Hive | System test | | [`execute hive`](./execute/hive.md) | Tests executed against a client via JSON RPC `eth_sendRawTransaction` in Hive | EVM, JSON RPC, mempool | Staging, Hive | System test | | [`execute remote`](./execute/remote.md) | Tests executed against a client via JSON RPC `eth_sendRawTransaction` on a live network | EVM, JSON RPC, mempool, EL-EL/EL-CL interaction (indirectly) | Production | System Test | @@ -144,6 +145,28 @@ The `consume sync` command: 5. **Monitors sync progress** and validates that the sync client reaches the same state. 6. **Verifies final state** matches between both clients. +## Block Building + +| Nomenclature | | +| -------------- | ------------------------ | +| Command | `build-block` | +| Simulator | `eels/build-block` | +| Fixture format | `blockchain_test_engine` | + +The block-building method tests the **producer-side** of an execution client: rather than asking the client to validate and import a pre-built block, it asks the client to build a block from inputs (parent, payload attributes, transactions) and then validates the resulting block field-by-field against the fixture's expected block. This exercises tx ordering, gas accounting, payload assembly, and (for fork ≥ Prague) `executionRequests` derivation. + +The endpoint used is `testing_buildBlockV1`, an engine-API testing-namespace method exposed by `ethpandaops/:master` builds (and similar performance forks). It is not part of the standard Engine API — the testing namespace is opt-in and intended for fixture-driven block-building verification. + +The `build-block` command, for each valid payload in the fixture: + +1. **Builds the block** via `testing_buildBlockV1(parent_hash, payload_attributes, transactions, extra_data)`. The client returns its own constructed `ExecutionPayload`. +2. **Validates execution-dependent fields** of the built payload against the fixture's expected payload (everything except `gas_limit` and `block_hash`, which depend on client-side EIP-1559 elasticity and are validated via a range check separately). +3. **Validates `executionRequests`** for fork ≥ Prague (`engine_newPayloadV4+`). +4. **Imports the fixture block** (not the client-built one) via `engine_newPayloadVX` so the chain advances with the fixture's expected gas limit and block hash. +5. **Advances the chain** via `engine_forkchoiceUpdatedVX`. + +This complements `consume engine`: where `consume engine` tests the client's payload-validation path, `build-block` tests its payload-production path against the same fixtures. + ## Engine vs RLP Simulator The RLP Simulator (`eels/consume-rlp`) and the Engine Simulator (`eels/consume-engine`) should be seen as complimentary to one another. Although they execute the same underlying EVM test cases, the block validation logic is executed via different client code paths (using different [fixture formats](./test_formats/index.md)). Therefore, ideally, **both simulators should be executed for full coverage**. diff --git a/packages/testing/pyproject.toml b/packages/testing/pyproject.toml index 46f5f717649..9965270e13c 100644 --- a/packages/testing/pyproject.toml +++ b/packages/testing/pyproject.toml @@ -84,6 +84,7 @@ checkfixtures = "execution_testing.cli.check_fixtures:check_fixtures" check_eip_versions = "execution_testing.cli.pytest_commands.check_eip_versions:check_eip_versions" consume = "execution_testing.cli.pytest_commands.consume:consume" protec = "execution_testing.cli.pytest_commands.consume:consume" +build-block = "execution_testing.cli.pytest_commands.build_block:build_block" checklist = "execution_testing.cli.pytest_commands.checklist:checklist" generate_checklist_stubs = "execution_testing.cli.generate_checklist_stubs:generate_checklist_stubs" genindex = "execution_testing.cli.gen_index:generate_fixtures_index_cli" diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/build_block.py b/packages/testing/src/execution_testing/cli/pytest_commands/build_block.py new file mode 100644 index 00000000000..102436ffacf --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/build_block.py @@ -0,0 +1,43 @@ +"""CLI entry point for the `build-block` pytest-based command.""" + +from pathlib import Path +from typing import Any, List + +import click + +from .base import PytestCommand, common_pytest_options +from .processors import ( + ConsumeCommandProcessor, + HelpFlagsProcessor, + HiveEnvironmentProcessor, +) + + +def create_build_block_command() -> PytestCommand: + """Initialize the build-block command with paths and processors.""" + base_path = Path("cli/pytest_commands/plugins/consume") + command_logic_test_paths = [ + base_path / "simulators" / "simulator_logic" / "test_via_build.py" + ] + return PytestCommand( + config_file="pytest-consume.ini", + argument_processors=[ + HelpFlagsProcessor("consume"), + HiveEnvironmentProcessor(command_name="build_block"), + ConsumeCommandProcessor(is_hive=True), + ], + command_logic_test_paths=command_logic_test_paths, + ) + + +@click.command( + name="build-block", + context_settings={"ignore_unknown_options": True}, +) +@common_pytest_options +def build_block(pytest_args: List[str], **kwargs: Any) -> None: + """Test block building via testing_buildBlockV1.""" + del kwargs + + cmd = create_build_block_command() + cmd.execute(list(pytest_args)) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/base.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/base.py index 5780353cf68..969a7a06e67 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/base.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/base.py @@ -34,6 +34,7 @@ def check_live_port(test_suite_name: str) -> Literal[8545, 8551]: "eels/consume-engine", "eels/consume-enginex", "eels/consume-sync", + "eels/build-block", }: return 8551 raise ValueError( diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/build_block/__init__.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/build_block/__init__.py new file mode 100644 index 00000000000..91f13d8b15f --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/build_block/__init__.py @@ -0,0 +1 @@ +"""Pytest configuration for the build-block simulator.""" diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/build_block/conftest.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/build_block/conftest.py new file mode 100644 index 00000000000..e23b5d03563 --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/build_block/conftest.py @@ -0,0 +1,66 @@ +""" +Pytest fixtures for the `build-block` simulator. + +Configures the hive back-end & EL clients for block building correctness +testing via the ``testing_buildBlockV1`` endpoint. +""" + +import io +from typing import Mapping + +import pytest +from hive.client import Client + +from execution_testing.fixtures import BlockchainEngineFixture +from execution_testing.fixtures.blockchain import FixtureHeader +from execution_testing.rpc import TestingRPC + +pytest_plugins = ( + "execution_testing.cli.pytest_commands.plugins.pytest_hive.pytest_hive", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.base", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.single_test_client", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.test_case_description", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.timing_data", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.exceptions", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.engine_api", +) + + +def pytest_configure(config: pytest.Config) -> None: + """Set the supported fixture formats for the build-block simulator.""" + config.supported_fixture_formats = [BlockchainEngineFixture] # type: ignore[attr-defined] + + +@pytest.fixture(scope="module") +def test_suite_name() -> str: + """The name of the hive test suite used in this simulator.""" + return "eels/build-block" + + +@pytest.fixture(scope="module") +def test_suite_description() -> str: + """The description of the hive test suite used in this simulator.""" + return ( + "Test block building correctness via the " + "testing_buildBlockV1 endpoint." + ) + + +@pytest.fixture(scope="function") +def client_files( + buffered_genesis: io.BufferedReader, +) -> Mapping[str, io.BufferedReader]: + """Define the files that hive will start the client with.""" + return {"/genesis.json": buffered_genesis} + + +@pytest.fixture(scope="function") +def genesis_header(fixture: BlockchainEngineFixture) -> FixtureHeader: + """Provide the genesis header from the fixture.""" + return fixture.genesis + + +@pytest.fixture(scope="function") +def testing_rpc(client: Client) -> TestingRPC: + """Initialize Testing RPC client for the execution client under test.""" + return TestingRPC(f"http://{client.ip}:8545") diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/simulator_logic/test_via_build.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/simulator_logic/test_via_build.py new file mode 100644 index 00000000000..00350737c1a --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/simulator_logic/test_via_build.py @@ -0,0 +1,333 @@ +""" +A hive based simulator that tests block building correctness via the +``testing_buildBlockV1`` endpoint. + +For each valid payload in a fixture, transactions and block attributes +are sent to the block building endpoint. The resulting block is validated +field-by-field against the fixture's expected block, then imported back +into the client via ``engine_newPayloadVX`` and +``engine_forkchoiceUpdatedVX`` to advance the chain. +""" + +import json +from difflib import unified_diff +from typing import List + +from execution_testing.base_types import Bytes +from execution_testing.fixtures import BlockchainEngineFixture +from execution_testing.fixtures.blockchain import ( + FixtureEngineNewPayload, + FixtureExecutionPayload, + FixtureHeader, +) +from execution_testing.logging import get_logger +from execution_testing.rpc import ( + EngineRPC, + EthRPC, + ForkchoiceUpdateTimeoutError, + TestingRPC, +) +from execution_testing.rpc.rpc_types import ( + ForkchoiceState, + PayloadStatusEnum, +) +from execution_testing.test_types.block_access_list import BlockAccessList + +from ..helpers.exceptions import ( + GenesisBlockMismatchExceptionError, + LoggedError, +) +from ..helpers.timing import TimingData + +logger = get_logger(__name__) + + +def _format_block_access_list_diff( + expected_rlp: bytes | None, + built_rlp: bytes | None, +) -> str: + """Return a readable diff for a BAL mismatch.""" + if expected_rlp is None or built_rlp is None: + return f"expected {expected_rlp!r}, got {built_rlp!r}" + + try: + expected_bal = BlockAccessList.from_rlp(Bytes(expected_rlp)) + built_bal = BlockAccessList.from_rlp(Bytes(built_rlp)) + except Exception as exc: + return ( + f"expected {expected_rlp!r}, got {built_rlp!r} " + f"(BAL decode failed: {exc})" + ) + + expected_json = json.dumps( + expected_bal.model_dump(mode="json"), + indent=2, + sort_keys=True, + ) + built_json = json.dumps( + built_bal.model_dump(mode="json"), + indent=2, + sort_keys=True, + ) + diff = "\n".join( + unified_diff( + expected_json.splitlines(), + built_json.splitlines(), + fromfile="expected_bal", + tofile="client_built_bal", + lineterm="", + ) + ) + return ( + f"expected {expected_rlp!r}, got {built_rlp!r}\n" + "decoded BAL diff relative to expected BAL:\n" + " '-' lines are present in the expected BAL but missing from the " + "client-built BAL.\n" + " '+' lines are extra or changed in the client-built BAL compared " + "to the expected BAL.\n" + f"{diff}" + ) + + +def _validate_gas_limit( + built: FixtureExecutionPayload, + parent_gas_limit: int, +) -> None: + """Validate the built block's gas limit is within EIP-1559 range.""" + built_gas_limit = int(built.gas_limit) + max_delta = parent_gas_limit // 1024 + + if abs(built_gas_limit - parent_gas_limit) >= max_delta: + raise LoggedError( + f"Gas limit for block {built.number} outside " + f"EIP-1559 range: parent={parent_gas_limit} " + f"(±{max_delta}), got {built.gas_limit}" + ) + + +def _validate_built_block( + built: FixtureExecutionPayload, + expected: FixtureExecutionPayload, + parent_gas_limit: int, +) -> None: + """ + Validate the built block against the fixture's expected block. + + Check all execution-dependent fields for exact match and verify + the gas limit is within the valid EIP-1559 range. + """ + _validate_gas_limit(built, parent_gas_limit) + + mismatches: List[str] = [] + + # All FixtureExecutionPayload fields are validated except: + # - gas_limit: testing_buildBlockV1 doesn't accept it; the client + # picks its own via EIP-1559 (validated separately by range check). + # - block_hash: depends on gas_limit, so it will differ too. + validated_fields = tuple( + name + for name in FixtureExecutionPayload.model_fields + if name not in {"gas_limit", "block_hash"} + ) + for field in validated_fields: + built_val = getattr(built, field) + expected_val = getattr(expected, field) + if built_val != expected_val: + if field == "block_access_list": + mismatches.append( + " block_access_list:\n" + + _format_block_access_list_diff(expected_val, built_val) + ) + continue + mismatches.append( + f" {field}: expected {expected_val}, got {built_val}" + ) + + if mismatches: + detail = "\n".join(mismatches) + raise LoggedError( + f"Block validation failed for block {expected.number}:\n{detail}" + ) + + logger.info(f"Block validated for block {expected.number}.") + + +def _bootstrap_engine_at_genesis( + engine_rpc: EngineRPC, + eth_rpc: EthRPC, + fixture: BlockchainEngineFixture, + genesis_header: FixtureHeader, + timing_data: TimingData, +) -> None: + """Send initial FCU to genesis and verify the client's genesis hash.""" + with timing_data.time("Initial forkchoice update"): + logger.info("Sending initial forkchoice update to genesis block...") + try: + response = engine_rpc.forkchoice_updated_with_retry( + forkchoice_state=ForkchoiceState( + head_block_hash=fixture.genesis.block_hash, + ), + forkchoice_version=fixture.payloads[ + 0 + ].forkchoice_updated_version, + ) + if response.payload_status.status != PayloadStatusEnum.VALID: + raise LoggedError( + "Unexpected status on forkchoice updated to " + f"genesis: {response.payload_status.status}" + ) + except ForkchoiceUpdateTimeoutError as e: + raise LoggedError( + f"Timed out waiting for forkchoice update to genesis: {e}" + ) from None + + with timing_data.time("Get genesis block"): + logger.info("Calling getBlockByNumber to get genesis block...") + genesis_block = eth_rpc.get_block_by_number(0) + assert genesis_block is not None, "genesis_block is None" + if genesis_block["hash"] != str(genesis_header.block_hash): + raise GenesisBlockMismatchExceptionError( + expected_header=genesis_header, + got_genesis_block=genesis_block, + ) + + +def _build_validate_and_advance( + testing_rpc: TestingRPC, + engine_rpc: EngineRPC, + payload: FixtureEngineNewPayload, + parent_gas_limit: int, + block_index: int, + total_blocks: int, + total_build_timing: TimingData, +) -> int: + """ + Build, validate, import, and advance the chain for one payload. + + Returns the new ``parent_gas_limit`` to use for the next block (taken + from the fixture's expected payload, not the client-built one, since + we import the fixture block to advance the chain). + """ + expected_payload = payload.params[0] + logger.info( + f"Building block {block_index + 1}/{total_blocks} " + f"(number {expected_payload.number})..." + ) + with total_build_timing.time(f"Block {block_index + 1}") as block_timing: + # 1. Build the block + with block_timing.time("testing_buildBlockV1"): + build_response = testing_rpc.build_block( + parent_block_hash=expected_payload.parent_hash, + payload_attributes=payload.get_payload_attributes(), + transactions=expected_payload.transactions, + extra_data=expected_payload.extra_data, + ) + + # 2. Validate fields + gas limit range + _validate_built_block( + built=build_response.execution_payload, + expected=expected_payload, + parent_gas_limit=parent_gas_limit, + ) + + # 3. Validate execution_requests (V4+) + if payload.new_payload_version >= 4: + expected_requests = ( + payload.params[3] if len(payload.params) >= 4 else None + ) + if build_response.execution_requests != expected_requests: + raise LoggedError( + f"execution_requests mismatch for block " + f"{expected_payload.number}: expected " + f"{expected_requests}, got " + f"{build_response.execution_requests}" + ) + + # 4. Import the fixture block (not the built block) so the chain + # advances with the expected gas limit and block hash. + with block_timing.time( + f"engine_newPayloadV{payload.new_payload_version}" + ): + logger.info( + "Importing block via " + f"engine_newPayloadV{payload.new_payload_version}..." + ) + import_response = engine_rpc.new_payload( + *payload.params, + version=payload.new_payload_version, + ) + if import_response.status != PayloadStatusEnum.VALID: + raise LoggedError( + "Unexpected status importing " + f"block: {import_response.status}" + ) + + # 5. Advance the chain via forkchoice update + v = payload.forkchoice_updated_version + with block_timing.time(f"engine_forkchoiceUpdatedV{v}"): + logger.info(f"Sending engine_forkchoiceUpdatedV{v}...") + fcu_response = engine_rpc.forkchoice_updated( + forkchoice_state=ForkchoiceState( + head_block_hash=expected_payload.block_hash, + ), + payload_attributes=None, + version=v, + ) + fcu_status = fcu_response.payload_status.status + if fcu_status != PayloadStatusEnum.VALID: + raise LoggedError( + "Unexpected status on forkchoice update: " + f"want {PayloadStatusEnum.VALID}, got {fcu_status}" + ) + + # Use the fixture's gas limit since we import the fixture block, + # not the built one. + return int(expected_payload.gas_limit) + + +def test_blockchain_via_build( + timing_data: TimingData, + eth_rpc: EthRPC, + engine_rpc: EngineRPC, + testing_rpc: TestingRPC, + fixture: BlockchainEngineFixture, + genesis_header: FixtureHeader, +) -> None: + """ + Test block building correctness against a client. + + For each valid payload in the fixture: + 1. Build a block via ``testing_buildBlockV1`` + 2. Validate execution-dependent fields + gas limit range + 3. Validate execution_requests for fork >= Prague (V4+) + 4. Import the fixture block via ``engine_newPayloadVX`` + 5. Advance the chain via ``engine_forkchoiceUpdatedVX`` + """ + _bootstrap_engine_at_genesis( + engine_rpc=engine_rpc, + eth_rpc=eth_rpc, + fixture=fixture, + genesis_header=genesis_header, + timing_data=timing_data, + ) + + with timing_data.time("Block building") as total_build_timing: + valid_payloads = [p for p in fixture.payloads if p.valid()] + logger.info( + f"Starting block building for {len(valid_payloads)} " + f"valid payload(s) " + f"(of {len(fixture.payloads)} total)..." + ) + parent_gas_limit = int(genesis_header.gas_limit) + for i, payload in enumerate(valid_payloads): + parent_gas_limit = _build_validate_and_advance( + testing_rpc=testing_rpc, + engine_rpc=engine_rpc, + payload=payload, + parent_gas_limit=parent_gas_limit, + block_index=i, + total_blocks=len(valid_payloads), + total_build_timing=total_build_timing, + ) + + logger.info("All blocks built and verified successfully.") diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/processors.py b/packages/testing/src/execution_testing/cli/pytest_commands/processors.py index 7862140380e..d23174fc34e 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/processors.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/processors.py @@ -122,32 +122,18 @@ def process_args(self, args: List[str]) -> List[str]: if os.getenv("HIVE_LOGLEVEL") is not None: warnings.warn("HIVE_LOG_LEVEL is not yet supported.", stacklevel=2) - if self.command_name == "engine": + simulator_commands = { + "engine", + "enginex", + "sync", + "rlp", + "build_block", + } + if self.command_name in simulator_commands: modified_args.extend( [ "-p", - "execution_testing.cli.pytest_commands.plugins.consume.simulators.engine.conftest", - ] - ) - elif self.command_name == "enginex": - modified_args.extend( - [ - "-p", - "execution_testing.cli.pytest_commands.plugins.consume.simulators.enginex.conftest", - ] - ) - elif self.command_name == "sync": - modified_args.extend( - [ - "-p", - "execution_testing.cli.pytest_commands.plugins.consume.simulators.sync.conftest", - ] - ) - elif self.command_name == "rlp": - modified_args.extend( - [ - "-p", - "execution_testing.cli.pytest_commands.plugins.consume.simulators.rlp.conftest", + f"execution_testing.cli.pytest_commands.plugins.consume.simulators.{self.command_name}.conftest", ] ) else: diff --git a/packages/testing/src/execution_testing/fixtures/blockchain.py b/packages/testing/src/execution_testing/fixtures/blockchain.py index 26209fc1b07..92ab859f612 100644 --- a/packages/testing/src/execution_testing/fixtures/blockchain.py +++ b/packages/testing/src/execution_testing/fixtures/blockchain.py @@ -2,6 +2,7 @@ from functools import cached_property from typing import ( + TYPE_CHECKING, Annotated, Any, ClassVar, @@ -69,6 +70,9 @@ FixtureTransactionReceipt, ) +if TYPE_CHECKING: + from execution_testing.rpc.rpc_types import PayloadAttributes + def post_state_validator( alternate_field: str | None = None, mode: str = "after" @@ -472,6 +476,26 @@ def valid(self) -> bool: """Return whether the payload is valid.""" return self.validation_error is None + def get_payload_attributes(self) -> "PayloadAttributes": + """Return the ``PayloadAttributes`` corresponding to this payload.""" + from execution_testing.rpc.rpc_types import PayloadAttributes + + execution_payload = self.params[0] + # parent_beacon_block_root exists from V3 onwards. The length check + # is for mypy narrowing; the version check captures the actual rule. + parent_beacon_block_root = ( + self.params[2] + if self.forkchoice_updated_version >= 3 and len(self.params) >= 3 + else None + ) + return PayloadAttributes( + timestamp=execution_payload.timestamp, + prev_randao=execution_payload.prev_randao, + suggested_fee_recipient=execution_payload.fee_recipient, + withdrawals=execution_payload.withdrawals, + parent_beacon_block_root=parent_beacon_block_root, + ) + @classmethod def from_fixture_header( cls, diff --git a/packages/testing/src/execution_testing/rpc/rpc.py b/packages/testing/src/execution_testing/rpc/rpc.py index e7f75aabe7f..22490da2547 100644 --- a/packages/testing/src/execution_testing/rpc/rpc.py +++ b/packages/testing/src/execution_testing/rpc/rpc.py @@ -1418,7 +1418,7 @@ def build_block( self, parent_block_hash: Hash, payload_attributes: PayloadAttributes, - transactions: Sequence[TransactionProtocol] | None, + transactions: Sequence[TransactionProtocol | Bytes] | None, extra_data: Bytes | None = None, *, version: int = 1, @@ -1428,6 +1428,9 @@ def build_block( provided *payload_attributes* and *transactions*. Calls ``testing_buildBlockVX``. + + Transactions can be either ``TransactionProtocol`` objects (with + an ``rlp()`` method) or raw ``Bytes`` (already RLP-encoded). """ method = f"buildBlockV{version}" params: List[Any] = [ @@ -1435,7 +1438,12 @@ def build_block( to_json(payload_attributes), ] if transactions is not None: - params.append([tx.rlp().hex() for tx in transactions]) + params.append( + [ + tx.hex() if isinstance(tx, bytes) else tx.rlp().hex() + for tx in transactions + ] + ) else: params.append(None) if extra_data is not None: