diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f5a6cb077b..141644dd32 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -23,6 +23,7 @@ Test fixtures for use by clients are available for each release on the [Github r - ✨ Added `--traces` support when running with Hyperledger Besu ([#511](https://github.com/ethereum/execution-spec-tests/pull/511)). - ✨ Use pytest's "short" traceback style (`--tb=short`) for failure summaries in the test report for more compact terminal output ([#542](https://github.com/ethereum/execution-spec-tests/pull/542)). - ✨ The `fill` command now generates HTML test reports with links to the JSON fixtures and debug information ([#537](https://github.com/ethereum/execution-spec-tests/pull/537)). +- ✨ Add an Ethereum RPC client class for use with consume commands ([#556](https://github.com/ethereum/execution-spec-tests/pull/556)). ### 🔧 EVM Tools diff --git a/src/ethereum_test_tools/rpc/__init__.py b/src/ethereum_test_tools/rpc/__init__.py new file mode 100644 index 0000000000..2dec4c2fcf --- /dev/null +++ b/src/ethereum_test_tools/rpc/__init__.py @@ -0,0 +1,7 @@ +""" +JSON-RPC methods and helper functions for EEST consume based hive simulators. +""" + +from .rpc import BlockNumberType, EthRPC + +__all__ = ["EthRPC", "BlockNumberType"] diff --git a/src/ethereum_test_tools/rpc/rpc.py b/src/ethereum_test_tools/rpc/rpc.py new file mode 100644 index 0000000000..8b6393482f --- /dev/null +++ b/src/ethereum_test_tools/rpc/rpc.py @@ -0,0 +1,115 @@ +""" +JSON-RPC methods and helper functions for EEST consume based hive simulators. +""" + +from abc import ABC +from typing import Any, Dict, List, Literal, Optional, Union + +import requests +from tenacity import retry, stop_after_attempt, wait_exponential + +from ethereum_test_tools import Address + +BlockNumberType = Union[int, Literal["latest", "earliest", "pending"]] + + +class BaseRPC(ABC): + """ + Represents a base RPC class for every RPC call used within EEST based hive simulators. + """ + + def __init__(self, client_ip: str, port: int): + self.ip = client_ip + self.url = f"http://{client_ip}:{port}" + + @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, max=10)) + def post_request( + self, method: str, params: List[Any], extra_headers: Optional[Dict] = None + ) -> Dict: + """ + Sends a JSON-RPC POST request to the client RPC server at port defined in the url. + """ + payload = { + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": 1, + } + base_header = { + "Content-Type": "application/json", + } + headers = base_header if extra_headers is None else {**base_header, **extra_headers} + + response = requests.post(self.url, json=payload, headers=headers) + response.raise_for_status() + result = response.json().get("result") + + if result is None or "error" in result: + error_info = "result is None; and therefore contains no error info" + error_code = None + if result is not None: + error_info = result["error"] + error_code = result["error"]["code"] + raise Exception( + f"Error calling JSON RPC {method}, code: {error_code}, " f"message: {error_info}" + ) + + return result + + +class EthRPC(BaseRPC): + """ + Represents an `eth_X` RPC class for every default ethereum RPC method used within EEST based + hive simulators. + """ + + def __init__(self, client_ip): + """ + Initializes the EthRPC class with the http port 8545, which requires no authentication. + """ + super().__init__(client_ip, port=8545) + + BlockNumberType = Union[int, Literal["latest", "earliest", "pending"]] + + 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("eth_getBlockByNumber", [block, full_txs]) + + def get_balance(self, address: str, block_number: BlockNumberType = "latest"): + """ + `eth_getBalance`: Returns the balance of the account of given address. + """ + block = hex(block_number) if isinstance(block_number, int) else block_number + return self.post_request("eth_getBalance", [address, block]) + + def get_transaction_count(self, address: Address, block_number: BlockNumberType = "latest"): + """ + `eth_getTransactionCount`: Returns the number of transactions sent from an address. + """ + block = hex(block_number) if isinstance(block_number, int) else block_number + return self.post_request("eth_getTransactionCount", [address, block]) + + def get_storage_at( + self, address: str, position: str, block_number: BlockNumberType = "latest" + ): + """ + `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 self.post_request("eth_getStorageAt", [address, position, block]) + + def storage_at_keys( + self, account: str, keys: List[str], block_number: BlockNumberType = "latest" + ) -> Dict: + """ + Helper to retrieve the storage values for the specified keys at a given address and block + number. + """ + results: Dict = {} + for key in keys: + storage_value = self.get_storage_at(account, key, block_number) + results[key] = storage_value + return results diff --git a/tests_consume/test_via_rlp.py b/tests_consume/test_via_rlp.py index 27d576c7f2..1578c23590 100644 --- a/tests_consume/test_via_rlp.py +++ b/tests_consume/test_via_rlp.py @@ -9,22 +9,22 @@ 1. The client's genesis block hash matches that defined in the fixture. 2. The client's last block hash matches that defined in the fixture. """ + import io import json import pprint import time -from typing import Any, Generator, List, Literal, Mapping, Optional, Union, cast +from typing import Generator, List, Mapping, Optional, cast import pytest -import requests import rich from hive.client import Client, ClientType from hive.testing import HiveTest from pydantic import BaseModel -from tenacity import retry, stop_after_attempt, wait_exponential from ethereum_test_tools.common.base_types import Bytes from ethereum_test_tools.common.json import to_json +from ethereum_test_tools.rpc import EthRPC from ethereum_test_tools.spec.blockchain.types import Fixture, FixtureHeader from pytest_plugins.consume.hive_ruleset import ruleset @@ -181,45 +181,12 @@ def client( timing_data.stop_client = time.perf_counter() - t_start -BlockNumberType = Union[int, Literal["latest", "earliest", "pending"]] - - -@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, max=10)) -def get_block(client: Client, block_number: BlockNumberType) -> dict: +@pytest.fixture(scope="function") +def eth_rpc(client: Client) -> EthRPC: """ - Retrieve the i-th block from the client using the JSON-RPC API. - Retries up to two times (three attempts total) in case of an error or a timeout, - with exponential backoff. + Initialize ethereum RPC client for the execution client under test. """ - if isinstance(block_number, int): - block_number_string = hex(block_number) - else: - block_number_string = block_number - url = f"http://{client.ip}:8545" - payload = { - "jsonrpc": "2.0", - "method": "eth_getBlockByNumber", - "params": [block_number_string, False], - "id": 1, - } - headers = {"Content-Type": "application/json"} - - response = requests.post(url, json=payload, headers=headers) - response.raise_for_status() - result = response.json().get("result") - - if result is None or "error" in result: - error_info: Any = "result is None; and therefore contains no error info" - error_code = None - if result is not None: - error_info = result["error"] - error_code = error_info["code"] - raise Exception( - f"Error calling JSON RPC eth_getBlockByNumber, code: {error_code}, " - f"message: {error_info}" - ) - - return result + return EthRPC(client_ip=client.ip) def compare_models(expected: FixtureHeader, got: FixtureHeader) -> dict: @@ -227,7 +194,7 @@ def compare_models(expected: FixtureHeader, got: FixtureHeader) -> dict: Compare two FixtureHeader model instances and return their differences. """ differences = {} - for (exp_name, exp_value), (got_name, got_value) in zip(expected, got): + for (exp_name, exp_value), (_, got_value) in zip(expected, got): if exp_value != got_value: differences[exp_name] = { "expected ": str(exp_value), @@ -262,9 +229,8 @@ def __init__(self, *, expected_header: FixtureHeader, got_header: FixtureHeader) def test_via_rlp( - client: Client, + eth_rpc: EthRPC, fixture: Fixture, - client_genesis: dict, timing_data: TestCaseTimingData, ): """ @@ -277,12 +243,12 @@ def test_via_rlp( 2. The client's last block's hash matches `fixture.last_block_hash`. """ t_start = time.perf_counter() - genesis_block = get_block(client, 0) + genesis_block = eth_rpc.get_block_by_number(0) timing_data.get_genesis = time.perf_counter() - t_start if genesis_block["hash"] != str(fixture.genesis.block_hash): raise GenesisBlockMismatchException( expected_header=fixture.genesis, got_header=FixtureHeader(**genesis_block) ) - block = get_block(client, "latest") + block = eth_rpc.get_block_by_number("latest") timing_data.get_last_block = time.perf_counter() - timing_data.get_genesis - t_start assert block["hash"] == str(fixture.last_block_hash), "hash mismatch in last block"