From 5ed0304c83820853633b210d43bf5e741825f3c8 Mon Sep 17 00:00:00 2001 From: Felix H Date: Wed, 20 Aug 2025 13:03:24 +0000 Subject: [PATCH 1/6] added eth_config simulator prototype --- src/ethereum_test_rpc/rpc.py | 135 +++++++++++++----- .../execute/eth_config/eth_config.py | 132 +++++++++++++++-- .../execute/eth_config/execute_eth_config.py | 82 +++++++++-- 3 files changed, 292 insertions(+), 57 deletions(-) diff --git a/src/ethereum_test_rpc/rpc.py b/src/ethereum_test_rpc/rpc.py index 91568abda33..73621c7629d 100644 --- a/src/ethereum_test_rpc/rpc.py +++ b/src/ethereum_test_rpc/rpc.py @@ -79,10 +79,12 @@ def __init_subclass__(cls, namespace: str | None = None) -> None: def post_request( self, + *, method: str, - *params: Any, + params: Any | None = None, extra_headers: Dict | None = None, request_id: int | str | None = None, + timeout: int | None = None, ) -> Any: """Send JSON-RPC POST request to the client RPC server at port defined in the url.""" if extra_headers is None: @@ -92,7 +94,8 @@ def post_request( next_request_id_counter = next(self.request_id_counter) if request_id is None: request_id = next_request_id_counter - payload = { + + json = { "jsonrpc": "2.0", "method": f"{self.namespace}_{method}", "params": params, @@ -103,7 +106,9 @@ def post_request( } headers = base_header | extra_headers - response = requests.post(self.url, json=payload, headers=headers) + # print(f"Sending RPC request to {self.url}, timeout is set to {timeout}...") + print(f"Sending RPC request, timeout is set to {timeout}...") # don't leak url in logs + response = requests.post(self.url, json=json, headers=headers, timeout=timeout) response.raise_for_status() response_json = response.json() @@ -135,11 +140,12 @@ def __init__( super().__init__(*args, **kwargs) self.transaction_wait_timeout = transaction_wait_timeout - def config(self): + def config(self, timeout: int | None = None): """`eth_config`: Returns information about a fork configuration of the client.""" try: - response = self.post_request("config") + response = self.post_request(method="config", timeout=timeout) if response is None: + print("eth_config request: failed to get response") return None return EthConfigResponse.model_validate( response, context=self.response_validation_context @@ -147,41 +153,66 @@ def config(self): except ValidationError as e: pprint(e.errors()) raise e + except Exception as e: + print(f"exception occurred when sending JSON-RPC request: {e}") + raise e def chain_id(self) -> int: """`eth_chainId`: Returns the current chain id.""" - return int(self.post_request("chainId"), 16) + response = self.post_request(method="chainId", timeout=10) + + return int(response, 16) def get_block_by_number(self, block_number: BlockNumberType = "latest", full_txs: bool = True): """`eth_getBlockByNumber`: Returns information about a block by block number.""" block = hex(block_number) if isinstance(block_number, int) else block_number - return self.post_request("getBlockByNumber", block, full_txs) + params = [block, full_txs] + response = self.post_request(method="getBlockByNumber", params=params) + + return response def get_block_by_hash(self, block_hash: Hash, full_txs: bool = True): """`eth_getBlockByHash`: Returns information about a block by hash.""" - return self.post_request("getBlockByHash", f"{block_hash}", full_txs) + params = [f"{block_hash}", full_txs] + response = self.post_request(method="getBlockByHash", params=params) + + return response def get_balance(self, address: Address, block_number: BlockNumberType = "latest") -> int: """`eth_getBalance`: Returns the balance of the account of given address.""" block = hex(block_number) if isinstance(block_number, int) else block_number - return int(self.post_request("getBalance", f"{address}", block), 16) + params = [f"{address}", block] + + response = self.post_request(method="getBalance", params=params) + + return int(response, 16) def get_code(self, address: Address, block_number: BlockNumberType = "latest") -> Bytes: """`eth_getCode`: Returns code at a given address.""" block = hex(block_number) if isinstance(block_number, int) else block_number - return Bytes(self.post_request("getCode", f"{address}", block)) + params = [f"{address}", block] + + response = self.post_request(method="getCode", params=params) + + return Bytes(response) def get_transaction_count( self, address: Address, block_number: BlockNumberType = "latest" ) -> int: """`eth_getTransactionCount`: Returns the number of transactions sent from an address.""" block = hex(block_number) if isinstance(block_number, int) else block_number - return int(self.post_request("getTransactionCount", f"{address}", block), 16) + params = [f"{address}", block] + + response = self.post_request(method="getTransactionCount", params=params) + + return int(response, 16) def get_transaction_by_hash(self, transaction_hash: Hash) -> TransactionByHashResponse | None: """`eth_getTransactionByHash`: Returns transaction details.""" try: - response = self.post_request("getTransactionByHash", f"{transaction_hash}") + response = self.post_request( + method="getTransactionByHash", params=f"{transaction_hash}" + ) if response is None: return None return TransactionByHashResponse.model_validate( @@ -196,22 +227,29 @@ def get_storage_at( ) -> Hash: """`eth_getStorageAt`: Returns the value from a storage position at a given address.""" block = hex(block_number) if isinstance(block_number, int) else block_number - return Hash(self.post_request("getStorageAt", f"{address}", f"{position}", block)) + params = [f"{address}", f"{position}", block] + + response = self.post_request(method="getStorageAt", params=params) + return Hash(response) def gas_price(self) -> int: """`eth_gasPrice`: Returns the number of transactions sent from an address.""" - return int(self.post_request("gasPrice"), 16) + response = self.post_request(method="gasPrice") + + return int(response, 16) def send_raw_transaction( self, transaction_rlp: Bytes, request_id: int | str | None = None ) -> Hash: """`eth_sendRawTransaction`: Send a transaction to the client.""" try: - result_hash = Hash( - self.post_request( - "sendRawTransaction", f"{transaction_rlp.hex()}", request_id=request_id - ), + response = self.post_request( + method="sendRawTransaction", + params=f"{transaction_rlp.hex()}", + request_id=request_id, # noqa: E501 ) + + result_hash = Hash(response) assert result_hash is not None return result_hash except Exception as e: @@ -219,14 +257,15 @@ def send_raw_transaction( def send_transaction(self, transaction: Transaction) -> Hash: """`eth_sendRawTransaction`: Send a transaction to the client.""" + # TODO: is this a copypaste error from above? try: - result_hash = Hash( - self.post_request( - "sendRawTransaction", - f"{transaction.rlp().hex()}", - request_id=transaction.metadata_string(), - ) + response = self.post_request( + method="sendRawTransaction", + params=f"{transaction.rlp().hex()}", + request_id=transaction.metadata_string(), # noqa: E501 ) + + result_hash = Hash(response) assert result_hash == transaction.hash assert result_hash is not None return transaction.hash @@ -318,7 +357,8 @@ class DebugRPC(EthRPC): def trace_call(self, tr: dict[str, str], block_number: str): """`debug_traceCall`: Returns pre state required for transaction.""" - return self.post_request("traceCall", tr, block_number, {"tracer": "prestateTracer"}) + params = [tr, block_number, {"tracer": "prestateTracer"}] + return self.post_request(method="traceCall", params=params) class EngineRPC(BaseRPC): @@ -341,10 +381,12 @@ def __init__( def post_request( self, + *, method: str, - *params: Any, + params: Any | None = None, extra_headers: Dict | None = None, request_id: int | str | None = None, + timeout: int | None = None, ) -> Any: """Send JSON-RPC POST request to the client RPC server at port defined in the url.""" if extra_headers is None: @@ -357,14 +399,22 @@ def post_request( extra_headers = { "Authorization": f"Bearer {jwt_token}", } | extra_headers + return super().post_request( - method, *params, extra_headers=extra_headers, request_id=request_id + method=method, + params=params, + extra_headers=extra_headers, + timeout=timeout, + request_id=request_id, ) def new_payload(self, *params: Any, version: int) -> PayloadStatus: """`engine_newPayloadVX`: Attempts to execute the given payload on an execution client.""" + method = f"newPayloadV{version}" + params_list = [to_json(param) for param in params] + return PayloadStatus.model_validate( - self.post_request(f"newPayloadV{version}", *[to_json(param) for param in params]), + self.post_request(method=method, params=params_list), context=self.response_validation_context, ) @@ -376,11 +426,17 @@ def forkchoice_updated( version: int, ) -> ForkchoiceUpdateResponse: """`engine_forkchoiceUpdatedVX`: Updates the forkchoice state of the execution client.""" + method = f"forkchoiceUpdatedV{version}" + + if payload_attributes is None: + params = [to_json(forkchoice_state)] + else: + params = [to_json(forkchoice_state), to_json(payload_attributes)] + return ForkchoiceUpdateResponse.model_validate( self.post_request( - f"forkchoiceUpdatedV{version}", - to_json(forkchoice_state), - to_json(payload_attributes) if payload_attributes is not None else None, + method=method, + params=params, ), context=self.response_validation_context, ) @@ -395,10 +451,12 @@ def get_payload( `engine_getPayloadVX`: Retrieves a payload that was requested through `engine_forkchoiceUpdatedVX`. """ + method = f"getPayloadV{version}" + return GetPayloadResponse.model_validate( self.post_request( - f"getPayloadV{version}", - f"{payload_id}", + method=method, + params=f"{payload_id}", ), context=self.response_validation_context, ) @@ -410,9 +468,12 @@ def get_blobs( version: int, ) -> GetBlobsResponse | None: """`engine_getBlobsVX`: Retrieves blobs from an execution layers tx pool.""" + method = f"getBlobsV{version}" + params = [f"{h}" for h in versioned_hashes] + response = self.post_request( - f"getBlobsV{version}", - [f"{h}" for h in versioned_hashes], + method=method, + params=[params], ) if response is None: # for tests that request non-existing blobs logger.debug("get_blobs response received but it has value: None") @@ -429,7 +490,7 @@ class NetRPC(BaseRPC): def peer_count(self) -> int: """`net_peerCount`: Get the number of peers connected to the client.""" - response = self.post_request("peerCount") + response = self.post_request(method="peerCount") return int(response, 16) # hex -> int @@ -438,4 +499,4 @@ class AdminRPC(BaseRPC): def add_peer(self, enode: str) -> bool: """`admin_addPeer`: Add a peer by enode URL.""" - return self.post_request("addPeer", enode) + return self.post_request(method="addPeer", params=enode) diff --git a/src/pytest_plugins/execute/eth_config/eth_config.py b/src/pytest_plugins/execute/eth_config/eth_config.py index 0a1febe6417..bb0bc39fd5b 100644 --- a/src/pytest_plugins/execute/eth_config/eth_config.py +++ b/src/pytest_plugins/execute/eth_config/eth_config.py @@ -1,12 +1,15 @@ """Pytest plugin to test the `eth_config` RPC endpoint in a node.""" +import re from os.path import realpath from pathlib import Path +from typing import Dict, List import pytest import requests from ethereum_test_rpc import EthRPC +from pytest_plugins.logging import get_logger from .types import Genesis, NetworkConfigFile @@ -16,6 +19,11 @@ DEFAULT_NETWORK_CONFIGS_FILE = CURRENT_FOLDER / "networks.yml" DEFAULT_NETWORKS = NetworkConfigFile.from_yaml(DEFAULT_NETWORK_CONFIGS_FILE) +EXECUTION_CLIENTS = ["besu", "erigon", "geth", "nethermind", "nimbusel", "reth"] +CONSENSUS_CLIENTS = ["grandine", "lighthouse", "lodestar", "nimbus", "prysm", "teku"] + +logger = get_logger(__name__) + def pytest_addoption(parser): """Add command-line options to pytest.""" @@ -39,7 +47,21 @@ def pytest_addoption(parser): required=False, type=Path, default=None, - help="Path to the yml file that contains custom network configuration.", + help="Path to the yml file that contains custom network configuration " + "(e.g. ./src/pytest_plugins/execute/eth_config/networks.yml).\nIf no config is provided " + "then majority mode will be used for devnet testing (clients that have a different " + "response than the majority of clients will fail the test)", + ) + eth_config_group.addoption( + "--clients", + required=False, + action="store", + dest="clients", + type=str, + default="besu,erigon,geth,nethermind,reth", + help="Comma-separated list of clients to be tested in majority mode. This flag will be " + "ignored when you pass a value for the network-config-file flag. Default: " + "besu,erigon,geth,nethermind,reth", ) eth_config_group.addoption( "--genesis-config-file", @@ -77,8 +99,12 @@ def pytest_configure(config: pytest.Config) -> None: """ genesis_config_file = config.getoption("genesis_config_file") genesis_config_url = config.getoption("genesis_config_url") - network_configs_path = config.getoption("network_config_file", default=None) + network_configs_path = config.getoption("network_config_file") network_name = config.getoption("network") + rpc_endpoint = config.getoption("rpc_endpoint") + # majority mode + clients = config.getoption("clients") + config.option.majority_clients = [] # List[str] if genesis_config_file and genesis_config_url: pytest.exit( @@ -119,21 +145,39 @@ def pytest_configure(config: pytest.Config) -> None: ) config.network = network_configs.root[network_name] # type: ignore + # determine whether to activate majority mode or not + if clients: + clients.replace(" ", "") + clients = clients.split(",") + for c in clients: + if c not in EXECUTION_CLIENTS: + pytest.exit(f"Unsupported client was passed: {c}") + logger.info(f"Provided client list: {clients}") + # activate majority mode if also URL condition is met + if ".ethpandaops.io" in rpc_endpoint: + logger.info("Ethpandaops RPC detected") + logger.info("Toggling majority test on") + config.option.majority_clients = clients # List[str] + else: + logger.info("Majority test mode is disabled because no --clients value was passed.") + if config.getoption("collectonly", default=False): return # Test out the RPC endpoint to be able to fail fast if it's not working - eth_rpc = EthRPC(config.getoption("rpc_endpoint")) + eth_rpc = EthRPC(rpc_endpoint) try: - eth_rpc.chain_id() + print("Will now perform a connection check (request chain_id)..") + chain_id = eth_rpc.chain_id() + print(f"Connection check ok (successfully got chain id {chain_id})") except Exception as e: - pytest.exit(f"Could not connect to RPC endpoint {config.getoption('rpc_endpoint')}: {e}") + pytest.exit(f"Could not connect to RPC endpoint {rpc_endpoint}: {e}") try: + print("Will now briefly check whether eth_config is supported by target rpc..") eth_rpc.config() + print("Connection check ok (successfully got eth_config response)") except Exception as e: - pytest.exit( - f"RPC endpoint {config.getoption('rpc_endpoint')} does not support `eth_config`: {e}" - ) + pytest.exit(f"RPC endpoint {rpc_endpoint} does not support `eth_config`: {e}") @pytest.fixture(autouse=True, scope="session") @@ -142,7 +186,71 @@ def rpc_endpoint(request) -> str: return request.config.getoption("rpc_endpoint") -@pytest.fixture(autouse=True, scope="session") -def eth_rpc(rpc_endpoint: str) -> EthRPC: - """Initialize ethereum RPC client for the execution client under test.""" - return EthRPC(rpc_endpoint) +# @pytest.fixture(autouse=True, scope="session") +# def eth_rpc(rpc_endpoint: str) -> EthRPC: +# """Initialize ethereum RPC client for the execution client under test.""" +# return EthRPC(rpc_endpoint) + + +def all_rpc_endpoints(config) -> Dict[str, List[EthRPC]]: + """Derive a mapping of exec clients to the RPC URLs they are reachable at.""" + rpc_endpoint = config.getoption("rpc_endpoint") + el_clients: List[str] = config.getoption("majority_clients") # besu, erigon, .. + if len(el_clients) == 0: + return {} + + pattern = r"(.*?@rpc\.)([^-]+)-([^-]+)(-.*)" + url_dict: Dict[str, List[EthRPC]] = { + exec_client: [ + EthRPC( + re.sub( + pattern, + f"\\g<1>{consensus}-{exec_client}\\g<4>", + rpc_endpoint, + ) + ) + for consensus in CONSENSUS_CLIENTS + ] + for exec_client in el_clients + } + # url_dict looks like this: + # { + # 'besu': [, , ..], # noqa: E501 + # 'erigon': ... + # ... + # } + return url_dict + + +def pytest_generate_tests(metafunc: pytest.Metafunc): + """Generate tests for all clients under test.""" + # all_rpc_endpoints is a dictionary with the name of the exec client as key + # and the possible URLs to contact it (different cl combinations) as value list + all_rpc_endpoints_dict = all_rpc_endpoints(metafunc.config) + + if metafunc.definition.name == "test_eth_config_majority": + if len(all_rpc_endpoints_dict) < 2: + # The test function is not run because we only have a single client, so no majority comparison # noqa: E501 + print("Skipping eth_config majority because less than 2 exec clients were passed") + metafunc.parametrize( + ["all_rpc_endpoints"], + [], + ) + else: + metafunc.parametrize( + ["all_rpc_endpoints"], + [[all_rpc_endpoints_dict]], # interpret it as a single argument dict + scope="function", + ) + else: + metafunc.parametrize( + ["eth_rpc"], + [ + pytest.param( + rpc_endpoint, + id=endpoint_name, + ) + for endpoint_name, rpc_endpoint in all_rpc_endpoints_dict.items() + ], + scope="function", + ) diff --git a/src/pytest_plugins/execute/eth_config/execute_eth_config.py b/src/pytest_plugins/execute/eth_config/execute_eth_config.py index adbf5247dc8..ae23fc82f71 100644 --- a/src/pytest_plugins/execute/eth_config/execute_eth_config.py +++ b/src/pytest_plugins/execute/eth_config/execute_eth_config.py @@ -1,6 +1,9 @@ """Pytest test to verify a client's configuration using `eth_config` RPC endpoint.""" +import json import time +from hashlib import sha256 +from typing import Dict, List import pytest @@ -9,27 +12,29 @@ from .types import NetworkConfig -@pytest.fixture(scope="session") -def eth_config_response(eth_rpc: EthRPC) -> EthConfigResponse | None: +@pytest.fixture(scope="function") +def eth_config_response(eth_rpc: List[EthRPC]) -> EthConfigResponse | None: """Get the `eth_config` response from the client to be verified by all tests.""" - return eth_rpc.config() + assert len(eth_rpc) > 0 + return eth_rpc[0].config() # just pick the first of possible URLs for this exec client -@pytest.fixture(scope="session") -def network(request: pytest.FixtureRequest) -> NetworkConfig: +@pytest.fixture(scope="function") +def network(request) -> NetworkConfig: """Get the network that will be used to verify all tests.""" - return request.config.network # type: ignore + return request.config.network -@pytest.fixture(scope="session") +@pytest.fixture(scope="function") def current_time() -> int: """Get the `eth_config` response from the client to be verified by all tests.""" return int(time.time()) -@pytest.fixture(scope="session") +@pytest.fixture(scope="function") def expected_eth_config(network: NetworkConfig, current_time: int) -> EthConfigResponse: """Calculate the current fork value to verify against the client's response.""" + print(f"Network provided: {network}, Type: {type(network)}") return network.get_eth_config(current_time) @@ -171,3 +176,64 @@ def test_eth_config_last_fork_id( f"{received_fork_id} != " f"{expected_last_fork_id}" ) + + +def test_eth_config_majority( + all_rpc_endpoints: Dict[str, List[EthRPC]], +) -> None: + """Queries devnet exec clients for their eth_config and fails if not all have the same response.""" # noqa: E501 + responses = dict() # Dict[exec_client_name : response] # noqa: C408 + client_to_url_used_dict = dict() # noqa: C408 + for exec_client in all_rpc_endpoints.keys(): + # try only as many consensus+exec client combinations until you receive a response + # if all combinations for a given exec client fail we panic + for eth_rpc_target in all_rpc_endpoints[exec_client]: + response = eth_rpc_target.config(timeout=10) + if response is None: + # safely split url to not leak rpc_endpoint in logs + print( + f"When trying to get eth_config from {eth_rpc_target} a problem occurred" # problem itself is logged by .config() call # noqa: E501 + ) + continue + + response_str = json.dumps(response.model_dump(mode="json")) + responses[exec_client] = response_str + client_to_url_used_dict[exec_client] = ( + eth_rpc_target.url + ) # remember which cl+el combination was used # noqa: E501 + print(f"Response of {exec_client}: {response_str}\n\n") + + break # no need to gather more responses for this client + + assert len(responses.keys()) == len(all_rpc_endpoints.keys()), ( + "Failed to get an eth_config response " + f" from each specified execution client. Full list of execution clients is " + f"{all_rpc_endpoints.keys()} but we were only able to gather eth_config responses " + f"from: {responses.keys()}\n" + "Will try again with a different consensus-execution client combination for " + "this execution client" + ) + # determine hashes of client responses + client_to_hash_dict = dict() # Dict[exec_client : response hash] # noqa: C408 + for client in responses.keys(): + response_bytes = json.dumps(responses[client], sort_keys=True).encode("utf-8") + response_hash = sha256(response_bytes).digest().hex() + print(f"Response hash of client {client}: {response_hash}") + client_to_hash_dict[client] = response_hash + + # if not all responses have the same hash there is a critical consensus issue + expected_hash = "" + for h in client_to_hash_dict.keys(): + if expected_hash == "": + expected_hash = client_to_hash_dict[h] + continue + + assert client_to_hash_dict[h] == expected_hash, ( + "Critical consensus issue: Not all eth_config responses are the same! " + f"Here is an overview of client response hashes:\n{'\n\t'.join(f'{k}: {v}' for k, v in client_to_hash_dict.items())}\n\n" # noqa: E501 + f"Here is an overview of which URLs were contacted:\n\t{'\n\t'.join(f'{k}: @{v.split("@")[1]}' for k, v in client_to_url_used_dict.items())}\n\n" # log which cl+el combinations were used without leaking full url # noqa: E501 + f"Here is a dump of all client responses:\n{'\n\n'.join(f'{k}: {v}' for k, v in responses.items())}" # noqa: E501 + ) + assert expected_hash != "" + + print("All clients returned the same eth_config response. Test has been passed!") From 534c09b253b65fee2764154593a39ed69dfef44c Mon Sep 17 00:00:00 2001 From: Felix H Date: Fri, 29 Aug 2025 09:28:56 +0000 Subject: [PATCH 2/6] implemented mario feedback --- src/ethereum_test_rpc/rpc.py | 25 +++++++++--------- .../execute/eth_config/eth_config.py | 26 ++++++++----------- .../execute/eth_config/execute_eth_config.py | 12 +++++---- 3 files changed, 31 insertions(+), 32 deletions(-) diff --git a/src/ethereum_test_rpc/rpc.py b/src/ethereum_test_rpc/rpc.py index 73621c7629d..73f61c2cdaa 100644 --- a/src/ethereum_test_rpc/rpc.py +++ b/src/ethereum_test_rpc/rpc.py @@ -26,7 +26,6 @@ ) logger = get_logger(__name__) - BlockNumberType = int | Literal["latest", "earliest", "pending"] @@ -81,7 +80,7 @@ def post_request( self, *, method: str, - params: Any | None = None, + params: List[Any] | None = None, extra_headers: Dict | None = None, request_id: int | str | None = None, timeout: int | None = None, @@ -89,6 +88,9 @@ def post_request( """Send JSON-RPC POST request to the client RPC server at port defined in the url.""" if extra_headers is None: extra_headers = {} + if params is None: + params = [] + assert self.namespace, "RPC namespace not set" next_request_id_counter = next(self.request_id_counter) @@ -106,8 +108,7 @@ def post_request( } headers = base_header | extra_headers - # print(f"Sending RPC request to {self.url}, timeout is set to {timeout}...") - print(f"Sending RPC request, timeout is set to {timeout}...") # don't leak url in logs + logger.debug(f"Sending RPC request, timeout is set to {timeout}...") response = requests.post(self.url, json=json, headers=headers, timeout=timeout) response.raise_for_status() response_json = response.json() @@ -145,7 +146,7 @@ def config(self, timeout: int | None = None): try: response = self.post_request(method="config", timeout=timeout) if response is None: - print("eth_config request: failed to get response") + logger.warning("eth_config request: failed to get response") return None return EthConfigResponse.model_validate( response, context=self.response_validation_context @@ -154,7 +155,7 @@ def config(self, timeout: int | None = None): pprint(e.errors()) raise e except Exception as e: - print(f"exception occurred when sending JSON-RPC request: {e}") + logger.error(f"exception occurred when sending JSON-RPC request: {e}") raise e def chain_id(self) -> int: @@ -211,7 +212,7 @@ def get_transaction_by_hash(self, transaction_hash: Hash) -> TransactionByHashRe """`eth_getTransactionByHash`: Returns transaction details.""" try: response = self.post_request( - method="getTransactionByHash", params=f"{transaction_hash}" + method="getTransactionByHash", params=[f"{transaction_hash}"] ) if response is None: return None @@ -245,7 +246,7 @@ def send_raw_transaction( try: response = self.post_request( method="sendRawTransaction", - params=f"{transaction_rlp.hex()}", + params=[transaction_rlp.hex()], request_id=request_id, # noqa: E501 ) @@ -261,7 +262,7 @@ def send_transaction(self, transaction: Transaction) -> Hash: try: response = self.post_request( method="sendRawTransaction", - params=f"{transaction.rlp().hex()}", + params=[transaction.rlp().hex()], request_id=transaction.metadata_string(), # noqa: E501 ) @@ -431,7 +432,7 @@ def forkchoice_updated( if payload_attributes is None: params = [to_json(forkchoice_state)] else: - params = [to_json(forkchoice_state), to_json(payload_attributes)] + params = [to_json(forkchoice_state), None] return ForkchoiceUpdateResponse.model_validate( self.post_request( @@ -456,7 +457,7 @@ def get_payload( return GetPayloadResponse.model_validate( self.post_request( method=method, - params=f"{payload_id}", + params=[f"{payload_id}"], ), context=self.response_validation_context, ) @@ -499,4 +500,4 @@ class AdminRPC(BaseRPC): def add_peer(self, enode: str) -> bool: """`admin_addPeer`: Add a peer by enode URL.""" - return self.post_request(method="addPeer", params=enode) + return self.post_request(method="addPeer", params=[enode]) diff --git a/src/pytest_plugins/execute/eth_config/eth_config.py b/src/pytest_plugins/execute/eth_config/eth_config.py index bb0bc39fd5b..46313bf8193 100644 --- a/src/pytest_plugins/execute/eth_config/eth_config.py +++ b/src/pytest_plugins/execute/eth_config/eth_config.py @@ -58,10 +58,10 @@ def pytest_addoption(parser): action="store", dest="clients", type=str, - default="besu,erigon,geth,nethermind,reth", - help="Comma-separated list of clients to be tested in majority mode. This flag will be " - "ignored when you pass a value for the network-config-file flag. Default: " - "besu,erigon,geth,nethermind,reth", + default=None, + help="Comma-separated list of clients to be tested in majority mode. Example: " + '"besu,erigon,geth,nethermind,nimbusel,reth"\nIf you do not pass a value, majority mode ' + "testing will be disabled.", ) eth_config_group.addoption( "--genesis-config-file", @@ -167,15 +167,15 @@ def pytest_configure(config: pytest.Config) -> None: # Test out the RPC endpoint to be able to fail fast if it's not working eth_rpc = EthRPC(rpc_endpoint) try: - print("Will now perform a connection check (request chain_id)..") + logger.debug("Will now perform a connection check (request chain_id)..") chain_id = eth_rpc.chain_id() - print(f"Connection check ok (successfully got chain id {chain_id})") + logger.debug(f"Connection check ok (successfully got chain id {chain_id})") except Exception as e: pytest.exit(f"Could not connect to RPC endpoint {rpc_endpoint}: {e}") try: - print("Will now briefly check whether eth_config is supported by target rpc..") + logger.debug("Will now briefly check whether eth_config is supported by target rpc..") eth_rpc.config() - print("Connection check ok (successfully got eth_config response)") + logger.debug("Connection check ok (successfully got eth_config response)") except Exception as e: pytest.exit(f"RPC endpoint {rpc_endpoint} does not support `eth_config`: {e}") @@ -186,12 +186,6 @@ def rpc_endpoint(request) -> str: return request.config.getoption("rpc_endpoint") -# @pytest.fixture(autouse=True, scope="session") -# def eth_rpc(rpc_endpoint: str) -> EthRPC: -# """Initialize ethereum RPC client for the execution client under test.""" -# return EthRPC(rpc_endpoint) - - def all_rpc_endpoints(config) -> Dict[str, List[EthRPC]]: """Derive a mapping of exec clients to the RPC URLs they are reachable at.""" rpc_endpoint = config.getoption("rpc_endpoint") @@ -231,7 +225,9 @@ def pytest_generate_tests(metafunc: pytest.Metafunc): if metafunc.definition.name == "test_eth_config_majority": if len(all_rpc_endpoints_dict) < 2: # The test function is not run because we only have a single client, so no majority comparison # noqa: E501 - print("Skipping eth_config majority because less than 2 exec clients were passed") + logger.info( + "Skipping eth_config majority because less than 2 exec clients were passed" + ) metafunc.parametrize( ["all_rpc_endpoints"], [], diff --git a/src/pytest_plugins/execute/eth_config/execute_eth_config.py b/src/pytest_plugins/execute/eth_config/execute_eth_config.py index ae23fc82f71..de0bcecdf8c 100644 --- a/src/pytest_plugins/execute/eth_config/execute_eth_config.py +++ b/src/pytest_plugins/execute/eth_config/execute_eth_config.py @@ -8,9 +8,12 @@ import pytest from ethereum_test_rpc import EthConfigResponse, EthRPC +from pytest_plugins.logging import get_logger from .types import NetworkConfig +logger = get_logger(__name__) + @pytest.fixture(scope="function") def eth_config_response(eth_rpc: List[EthRPC]) -> EthConfigResponse | None: @@ -34,7 +37,6 @@ def current_time() -> int: @pytest.fixture(scope="function") def expected_eth_config(network: NetworkConfig, current_time: int) -> EthConfigResponse: """Calculate the current fork value to verify against the client's response.""" - print(f"Network provided: {network}, Type: {type(network)}") return network.get_eth_config(current_time) @@ -191,7 +193,7 @@ def test_eth_config_majority( response = eth_rpc_target.config(timeout=10) if response is None: # safely split url to not leak rpc_endpoint in logs - print( + logger.warning( f"When trying to get eth_config from {eth_rpc_target} a problem occurred" # problem itself is logged by .config() call # noqa: E501 ) continue @@ -201,7 +203,7 @@ def test_eth_config_majority( client_to_url_used_dict[exec_client] = ( eth_rpc_target.url ) # remember which cl+el combination was used # noqa: E501 - print(f"Response of {exec_client}: {response_str}\n\n") + logger.info(f"Response of {exec_client}: {response_str}\n\n") break # no need to gather more responses for this client @@ -218,7 +220,7 @@ def test_eth_config_majority( for client in responses.keys(): response_bytes = json.dumps(responses[client], sort_keys=True).encode("utf-8") response_hash = sha256(response_bytes).digest().hex() - print(f"Response hash of client {client}: {response_hash}") + logger.info(f"Response hash of client {client}: {response_hash}") client_to_hash_dict[client] = response_hash # if not all responses have the same hash there is a critical consensus issue @@ -236,4 +238,4 @@ def test_eth_config_majority( ) assert expected_hash != "" - print("All clients returned the same eth_config response. Test has been passed!") + logger.info("All clients returned the same eth_config response. Test has been passed!") From d3366ad5cf52eab7c0ff89299abedecc31c77118 Mon Sep 17 00:00:00 2001 From: Felix H Date: Fri, 29 Aug 2025 12:31:35 +0000 Subject: [PATCH 3/6] gentler handling of failed/missing rpc responses --- src/ethereum_test_rpc/rpc.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/ethereum_test_rpc/rpc.py b/src/ethereum_test_rpc/rpc.py index 73f61c2cdaa..37140d109e3 100644 --- a/src/ethereum_test_rpc/rpc.py +++ b/src/ethereum_test_rpc/rpc.py @@ -19,7 +19,6 @@ ForkchoiceUpdateResponse, GetBlobsResponse, GetPayloadResponse, - JSONRPCError, PayloadAttributes, PayloadStatus, TransactionByHashResponse, @@ -110,11 +109,18 @@ def post_request( logger.debug(f"Sending RPC request, timeout is set to {timeout}...") response = requests.post(self.url, json=json, headers=headers, timeout=timeout) - response.raise_for_status() - response_json = response.json() + try: + response_json = response.json() + except Exception as e: + logger.debug(f"Failed to deserialize response: {e}") + return None + + # response.raise_for_status() if "error" in response_json: - raise JSONRPCError(**response_json["error"]) + # raise JSONRPCError(**response_json["error"]) + logger.debug(f"Got response with error: {response_json}") + return None assert "result" in response_json, "RPC response didn't contain a result field" result = response_json["result"] @@ -155,7 +161,7 @@ def config(self, timeout: int | None = None): pprint(e.errors()) raise e except Exception as e: - logger.error(f"exception occurred when sending JSON-RPC request: {e}") + logger.debug(f"exception occurred when sending JSON-RPC request: {e}") raise e def chain_id(self) -> int: From baacdc756b1e4c7c63ce919e1e0c0b46498d345f Mon Sep 17 00:00:00 2001 From: Felix H Date: Mon, 1 Sep 2025 09:01:31 +0000 Subject: [PATCH 4/6] implemented feedback --- .../execute/eth_config/eth_config.py | 26 ++++++++++++++--- .../execute/eth_config/execute_eth_config.py | 28 +++++++++++++------ 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/pytest_plugins/execute/eth_config/eth_config.py b/src/pytest_plugins/execute/eth_config/eth_config.py index 46313bf8193..1e7da09cb34 100644 --- a/src/pytest_plugins/execute/eth_config/eth_config.py +++ b/src/pytest_plugins/execute/eth_config/eth_config.py @@ -4,6 +4,7 @@ from os.path import realpath from pathlib import Path from typing import Dict, List +from urllib.parse import urlparse import pytest import requests @@ -191,7 +192,13 @@ def all_rpc_endpoints(config) -> Dict[str, List[EthRPC]]: rpc_endpoint = config.getoption("rpc_endpoint") el_clients: List[str] = config.getoption("majority_clients") # besu, erigon, .. if len(el_clients) == 0: - return {} + endpoint_name = rpc_endpoint + try: + parsed = urlparse(rpc_endpoint) + endpoint_name = parsed.hostname + except Exception: + pass + return {endpoint_name: [EthRPC(rpc_endpoint)]} pattern = r"(.*?@rpc\.)([^-]+)-([^-]+)(-.*)" url_dict: Dict[str, List[EthRPC]] = { @@ -230,12 +237,23 @@ def pytest_generate_tests(metafunc: pytest.Metafunc): ) metafunc.parametrize( ["all_rpc_endpoints"], - [], + [ + pytest.param( + all_rpc_endpoints_dict, + id=metafunc.definition.name, + marks=pytest.mark.skip("Only one client"), + ) + ], ) else: metafunc.parametrize( ["all_rpc_endpoints"], - [[all_rpc_endpoints_dict]], # interpret it as a single argument dict + [ + pytest.param( + all_rpc_endpoints_dict, + id=metafunc.definition.name, + ) + ], scope="function", ) else: @@ -244,7 +262,7 @@ def pytest_generate_tests(metafunc: pytest.Metafunc): [ pytest.param( rpc_endpoint, - id=endpoint_name, + id=f"{metafunc.definition.name}[{endpoint_name}]", ) for endpoint_name, rpc_endpoint in all_rpc_endpoints_dict.items() ], diff --git a/src/pytest_plugins/execute/eth_config/execute_eth_config.py b/src/pytest_plugins/execute/eth_config/execute_eth_config.py index de0bcecdf8c..15eb651d5f9 100644 --- a/src/pytest_plugins/execute/eth_config/execute_eth_config.py +++ b/src/pytest_plugins/execute/eth_config/execute_eth_config.py @@ -18,8 +18,15 @@ @pytest.fixture(scope="function") def eth_config_response(eth_rpc: List[EthRPC]) -> EthConfigResponse | None: """Get the `eth_config` response from the client to be verified by all tests.""" - assert len(eth_rpc) > 0 - return eth_rpc[0].config() # just pick the first of possible URLs for this exec client + for rpc in eth_rpc: + try: + response = rpc.config() + if response is not None: + return response + except Exception: + pass + else: + raise Exception("Could not connect to any RPC client.") @pytest.fixture(scope="function") @@ -198,7 +205,7 @@ def test_eth_config_majority( ) continue - response_str = json.dumps(response.model_dump(mode="json")) + response_str = json.dumps(response.model_dump(mode="json"), sort_keys=True) responses[exec_client] = response_str client_to_url_used_dict[exec_client] = ( eth_rpc_target.url @@ -218,7 +225,7 @@ def test_eth_config_majority( # determine hashes of client responses client_to_hash_dict = dict() # Dict[exec_client : response hash] # noqa: C408 for client in responses.keys(): - response_bytes = json.dumps(responses[client], sort_keys=True).encode("utf-8") + response_bytes = responses[client].encode("utf-8") response_hash = sha256(response_bytes).digest().hex() logger.info(f"Response hash of client {client}: {response_hash}") client_to_hash_dict[client] = response_hash @@ -231,10 +238,15 @@ def test_eth_config_majority( continue assert client_to_hash_dict[h] == expected_hash, ( - "Critical consensus issue: Not all eth_config responses are the same! " - f"Here is an overview of client response hashes:\n{'\n\t'.join(f'{k}: {v}' for k, v in client_to_hash_dict.items())}\n\n" # noqa: E501 - f"Here is an overview of which URLs were contacted:\n\t{'\n\t'.join(f'{k}: @{v.split("@")[1]}' for k, v in client_to_url_used_dict.items())}\n\n" # log which cl+el combinations were used without leaking full url # noqa: E501 - f"Here is a dump of all client responses:\n{'\n\n'.join(f'{k}: {v}' for k, v in responses.items())}" # noqa: E501 + "Critical consensus issue: Not all eth_config responses are the same!\n" + "Here is an overview of client response hashes:\n" + + "\n\t".join(f"{k}: {v}" for k, v in client_to_hash_dict.items()) + + "\n\n" # noqa: E501 + "Here is an overview of which URLs were contacted:\n\t" + + "\n\t".join(f"{k}: @{v.split('@')[1]}" for k, v in client_to_url_used_dict.items()) + + "\n\n" # log which cl+el combinations were used without leaking full url # noqa: E501 + "Here is a dump of all client responses:\n" + + "\n\n".join(f"{k}: {v}" for k, v in responses.items()) # noqa: E501 ) assert expected_hash != "" From 1c23bb09de6d7ae1abc274e64a264fe9cf1b75b3 Mon Sep 17 00:00:00 2001 From: Felix H Date: Tue, 2 Sep 2025 09:28:23 +0000 Subject: [PATCH 5/6] revert rpc changes, adjust test to 'try .. except' --- src/ethereum_test_rpc/rpc.py | 18 ++++++------------ .../execute/eth_config/execute_eth_config.py | 11 +++++++---- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/ethereum_test_rpc/rpc.py b/src/ethereum_test_rpc/rpc.py index 37140d109e3..4b694062bbf 100644 --- a/src/ethereum_test_rpc/rpc.py +++ b/src/ethereum_test_rpc/rpc.py @@ -19,6 +19,7 @@ ForkchoiceUpdateResponse, GetBlobsResponse, GetPayloadResponse, + JSONRPCError, PayloadAttributes, PayloadStatus, TransactionByHashResponse, @@ -96,7 +97,7 @@ def post_request( if request_id is None: request_id = next_request_id_counter - json = { + payload = { "jsonrpc": "2.0", "method": f"{self.namespace}_{method}", "params": params, @@ -108,19 +109,12 @@ def post_request( headers = base_header | extra_headers logger.debug(f"Sending RPC request, timeout is set to {timeout}...") - response = requests.post(self.url, json=json, headers=headers, timeout=timeout) + response = requests.post(self.url, json=payload, headers=headers, timeout=timeout) + response.raise_for_status() + response_json = response.json() - try: - response_json = response.json() - except Exception as e: - logger.debug(f"Failed to deserialize response: {e}") - return None - - # response.raise_for_status() if "error" in response_json: - # raise JSONRPCError(**response_json["error"]) - logger.debug(f"Got response with error: {response_json}") - return None + raise JSONRPCError(**response_json["error"]) assert "result" in response_json, "RPC response didn't contain a result field" result = response_json["result"] diff --git a/src/pytest_plugins/execute/eth_config/execute_eth_config.py b/src/pytest_plugins/execute/eth_config/execute_eth_config.py index 15eb651d5f9..194bce8f752 100644 --- a/src/pytest_plugins/execute/eth_config/execute_eth_config.py +++ b/src/pytest_plugins/execute/eth_config/execute_eth_config.py @@ -197,11 +197,14 @@ def test_eth_config_majority( # try only as many consensus+exec client combinations until you receive a response # if all combinations for a given exec client fail we panic for eth_rpc_target in all_rpc_endpoints[exec_client]: - response = eth_rpc_target.config(timeout=10) - if response is None: - # safely split url to not leak rpc_endpoint in logs + try: + response = eth_rpc_target.config(timeout=5) + if response is None: + logger.warning(f"Got 'None' as eth_config response from {eth_rpc_target}") + continue + except Exception as e: logger.warning( - f"When trying to get eth_config from {eth_rpc_target} a problem occurred" # problem itself is logged by .config() call # noqa: E501 + f"When trying to get eth_config from {eth_rpc_target} a problem occurred: {e}" ) continue From fb8ac131e4c5caa0823c38c8a56ead7c34a890f2 Mon Sep 17 00:00:00 2001 From: Mario Vega Date: Tue, 2 Sep 2025 20:54:13 +0200 Subject: [PATCH 6/6] Apply suggestions from code review --- src/ethereum_test_rpc/rpc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ethereum_test_rpc/rpc.py b/src/ethereum_test_rpc/rpc.py index 4b694062bbf..61aa05c650e 100644 --- a/src/ethereum_test_rpc/rpc.py +++ b/src/ethereum_test_rpc/rpc.py @@ -430,9 +430,9 @@ def forkchoice_updated( method = f"forkchoiceUpdatedV{version}" if payload_attributes is None: - params = [to_json(forkchoice_state)] - else: params = [to_json(forkchoice_state), None] + else: + params = [to_json(forkchoice_state), to_json(payload_attributes)] return ForkchoiceUpdateResponse.model_validate( self.post_request(