Skip to content

Commit 4f42272

Browse files
committed
added eth_config simulator prototype
1 parent 3642b20 commit 4f42272

File tree

1 file changed

+152
-15
lines changed

1 file changed

+152
-15
lines changed

src/pytest_plugins/execute/eth_config/eth_config.py

Lines changed: 152 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
"""Pytest plugin to test the `eth_config` RPC endpoint in a node."""
22

3+
import json
4+
import re
5+
from hashlib import sha256
36
from os.path import realpath
47
from pathlib import Path
8+
from typing import List, Tuple
59

610
import pytest
11+
import requests
712

813
from ethereum_test_rpc import EthRPC
914

@@ -15,6 +20,9 @@
1520
DEFAULT_NETWORK_CONFIGS_FILE = CURRENT_FOLDER / "networks.yml"
1621
DEFAULT_NETWORKS = NetworkConfigFile.from_yaml(DEFAULT_NETWORK_CONFIGS_FILE)
1722

23+
EXECUTION_CLIENTS = ["besu", "erigon", "geth", "nethermind", "reth"]
24+
CONSENSUS_CLIENTS = ["grandine", "lighthouse", "lodestar", "nimbus", "prysm", "teku"]
25+
1826

1927
def pytest_addoption(parser):
2028
"""Add command-line options to pytest."""
@@ -38,7 +46,21 @@ def pytest_addoption(parser):
3846
required=False,
3947
type=Path,
4048
default=None,
41-
help="Path to the yml file that contains custom network configuration.",
49+
help="Path to the yml file that contains custom network configuration "
50+
"(e.g. ./src/pytest_plugins/execute/eth_config/networks.yml).\nIf no config is provided "
51+
"then majority mode will be used for devnet testing (clients that have a different "
52+
"response than the majority of clients will fail the test)",
53+
)
54+
eth_config_group.addoption(
55+
"--clients",
56+
required=False,
57+
action="store",
58+
dest="clients",
59+
type=str,
60+
default="besu,erigon,geth,nethermind,reth",
61+
help="Comma-separated list of clients to be tested in majority mode. This flag will be "
62+
"ignored when you pass a value for the network-config-file flag. Default: "
63+
"besu,erigon,geth,nethermind,reth",
4264
)
4365
eth_config_group.addoption(
4466
"--rpc-endpoint",
@@ -54,21 +76,38 @@ def pytest_configure(config: pytest.Config) -> None:
5476
Load the network configuration file and load the specific network to be used for
5577
the test.
5678
"""
57-
network_configs_path = config.getoption("network_config_file", default=None)
58-
if network_configs_path is None:
59-
network_configs_path = DEFAULT_NETWORK_CONFIGS_FILE
60-
if not network_configs_path.exists():
61-
pytest.exit(f'Specified networks file "{network_configs_path}" does not exist.')
62-
try:
63-
network_configs = NetworkConfigFile.from_yaml(network_configs_path)
64-
except Exception as e:
65-
pytest.exit(f"Could not load file {network_configs_path}: {e}")
79+
# get flag values
6680
network_name = config.getoption("network")
67-
if network_name not in network_configs.root:
68-
pytest.exit(
69-
f'Network "{network_name}" could not be found in file "{network_configs_path}".'
70-
)
71-
config.network = network_configs.root[network_name] # type: ignore
81+
network_configs_path = config.getoption("network_config_file")
82+
clients = config.getoption("clients")
83+
rpc_endpoint = config.getoption("rpc_endpoint")
84+
85+
# either load network file or activate majority test mode
86+
if network_configs_path is not None: # case 1: load provided networks file
87+
if not network_configs_path.exists():
88+
pytest.exit(f'Specified networks file "{network_configs_path}" does not exist.')
89+
try:
90+
network_configs = NetworkConfigFile.from_yaml(network_configs_path)
91+
except Exception as e:
92+
pytest.exit(f"Could not load file {network_configs_path}: {e}")
93+
94+
if network_name not in network_configs.root:
95+
pytest.exit(
96+
f'Network "{network_name}" could not be found in file "{network_configs_path}".'
97+
)
98+
config.network = network_configs.root[network_name] # type: ignore
99+
else: # case 2: activate majority test mode
100+
# parse clients list
101+
clients.replace(" ", "")
102+
clients = clients.split(",")
103+
for c in clients:
104+
if c not in EXECUTION_CLIENTS:
105+
pytest.exit(f"Unsupported client was passed: {c}")
106+
print(f"Activating majority mode\nProvided client list: {clients}")
107+
108+
# request and store and compare all eth_config responses, then terminate
109+
majority_eth_config_test(exec_clients=clients, rpc_endpoint=rpc_endpoint)
110+
return # TODO: how to not run the other tests, exit(1)?
72111

73112
if config.getoption("collectonly", default=False):
74113
return
@@ -97,3 +136,101 @@ def rpc_endpoint(request) -> str:
97136
def eth_rpc(rpc_endpoint: str) -> EthRPC:
98137
"""Initialize ethereum RPC client for the execution client under test."""
99138
return EthRPC(rpc_endpoint)
139+
140+
141+
def majority_eth_config_test(exec_clients: List[str], rpc_endpoint: str): # noqa: D103
142+
# sanity checks
143+
assert ".ethpandaops.io" in rpc_endpoint
144+
assert len(exec_clients) > 0
145+
if "geth" in exec_clients and "devnet-3" in rpc_endpoint:
146+
print("Devnet-3 geth does not support eth_config")
147+
return
148+
149+
# generate client-specific URLs from provided rpc_endpoint (it does not matter which client the original rpc_endpoint specifies) # noqa: E501
150+
# we want all combinations of consensus and execution clients (sometimes an exec client is only reachable via a subset of consensus client combinations) # noqa: E501
151+
pattern = r"(.*?@rpc\.)([^-]+)-([^-]+)(-.*)"
152+
url_dict = {
153+
exec_client: [
154+
re.sub(
155+
pattern,
156+
f"\\g<1>{consensus}-{exec_client}\\g<4>",
157+
rpc_endpoint,
158+
)
159+
for consensus in CONSENSUS_CLIENTS
160+
]
161+
for exec_client in exec_clients
162+
}
163+
# url_dict looks like this:
164+
# {
165+
# 'besu': ["url for grandine+besu", "url for lighthouse+besu"], ...
166+
# 'erigon': ["url for grandine+erigon", "url for lighthouse+erigon"], ...
167+
# ...
168+
# }
169+
170+
# print("Majority test might contact some of these URLs:")
171+
# pprint(url_dict)
172+
173+
# responses dict maps exec-client name to its response
174+
responses = dict() # noqa: C408
175+
for exec_client in url_dict.keys():
176+
# try only as many consensus+exec client combinations until you receive a response
177+
# if all combinations fail we panic
178+
for url in url_dict[exec_client]:
179+
success, response = get_eth_config(url)
180+
if not success:
181+
# safely split url to not leak rpc_endpoint in logs
182+
print(
183+
f"When trying to get eth_config from {url.split('@', 1)[-1] if '@' in url else ''} the following problem occurred: {response}" # noqa: E501
184+
)
185+
continue
186+
187+
responses[exec_client] = response
188+
print(f"Response of {exec_client}: {response}\n\n")
189+
break # no need to gather more responses for this client
190+
191+
assert len(responses.keys()) == len(exec_clients), "Failed to get an eth_config response "
192+
f" from each specified execution client. Full list of execution clients is {exec_clients} "
193+
f"but we were only able to gather eth_config responses from: {responses.keys()}\nWill try "
194+
"again with a different consensus-execution client combination for this execution client"
195+
196+
# determine hashes of client responses
197+
client_to_hash_dict = dict() # noqa: C408
198+
for client in responses.keys():
199+
response_bytes = json.dumps(responses[client], sort_keys=True).encode("utf-8")
200+
response_hash = sha256(response_bytes).digest().hex()
201+
print(f"Response hash of client {client}: {response_hash}")
202+
client_to_hash_dict[client] = response_hash
203+
204+
# if not all responses have the same hash there is a critical consensus issue
205+
expected_hash = ""
206+
for h in client_to_hash_dict.keys():
207+
if expected_hash == "":
208+
expected_hash = client_to_hash_dict[h]
209+
continue
210+
211+
if client_to_hash_dict[h] != expected_hash:
212+
pytest.exit("Critical consensus issue: Not all eth_config responses are the same!")
213+
214+
print("All clients returned the same eth_config response. Test has been passed!")
215+
216+
217+
def get_eth_config(url: str) -> Tuple[bool, str]: # success, response
218+
"""Request data from devnet node via JSON_RPC."""
219+
payload = {
220+
"jsonrpc": "2.0",
221+
"method": "eth_config",
222+
"params": [],
223+
"id": 1,
224+
}
225+
226+
headers = {"Content-Type": "application/json"}
227+
228+
try:
229+
# Make the request
230+
response = requests.post(url, json=payload, headers=headers, timeout=20)
231+
232+
# Return JSON response
233+
return True, response.json()
234+
235+
except Exception as e:
236+
return False, f"error: {e}"

0 commit comments

Comments
 (0)