diff --git a/SPECIFICATION.md b/SPECIFICATION.md index 2aab993..e031e9e 100644 --- a/SPECIFICATION.md +++ b/SPECIFICATION.md @@ -115,7 +115,7 @@ Requirements: - All keys of `RpcConfig` and `Endpoint` are required. No additional keys must be present, except within `global_metadata`, `profile_metadata`, and `endpoint_metadata`. - Every endpoint name specified in `RpcConfig.default_endpoint` and in `RpcConfig.network_defaults` must exist in `RpcConfig.endpoints`. - These key-value structures can be easily represented in JSON and in most common programming languages. -- EVM `chain_id`'s must be represented using either a decimal string or a hex string. Strings are used because chain id's can be 256 bits and most programming languages do not have native 256 bit integer types. For readability, decimal should be used for small chain id values and hex should be used for values that use the entire 256 bits. +- EVM `chain_id`'s must be represented using either a decimal string or a `0x`-prefixed hex string. Strings are used because chain id's can be 256 bits and most programming languages do not have native 256 bit integer types. For readability, decimal should be used for small chain id values and hex should be used for values that use the entire 256 bits. - Names of endpoints, networks, and profiles should be composed of characters that are either alphanumeric, dashes, underscores, or periods. Names should be at least 1 character long. ##### Metadata diff --git a/python/mesc/interface.py b/python/mesc/interface.py index 9602135..e02055c 100644 --- a/python/mesc/interface.py +++ b/python/mesc/interface.py @@ -71,15 +71,18 @@ def get_endpoint_by_network( raise ValueError('chain_id must be a str or int') chain_id = str(chain_id) network_defaults = config['network_defaults'] - default_name = network_defaults.get(chain_id) + default_name = network_utils.get_by_chain_id(network_defaults, chain_id) # get profile default for network - if profile and profile in config['profiles']: + if profile is not None and profile in config['profiles']: if not config['profiles'][profile]['use_mesc']: return None - name = config['profiles'][profile]['network_defaults'].get( - chain_id, default_name + name = network_utils.get_by_chain_id( + config['profiles'][profile]['network_defaults'], + chain_id, ) + if name is None: + name = default_name else: name = default_name @@ -143,8 +146,12 @@ def find_endpoints( if chain_id is not None: if isinstance(chain_id, int): chain_id = str(chain_id) + chain_id = network_utils.chain_id_to_standard_hex(chain_id) endpoints = [ - endpoint for endpoint in endpoints if endpoint['chain_id'] == chain_id + endpoint + for endpoint in endpoints + if endpoint['chain_id'] is not None + and network_utils.chain_id_to_standard_hex(endpoint['chain_id']) == chain_id ] # check name_contains diff --git a/python/mesc/network_utils.py b/python/mesc/network_utils.py index ed1adc7..c0544e5 100644 --- a/python/mesc/network_utils.py +++ b/python/mesc/network_utils.py @@ -1,5 +1,6 @@ from __future__ import annotations +import typing from .types import RpcConfig from . import network_names @@ -31,3 +32,31 @@ def network_name_to_chain_id( return chain_id else: return None + + +def chain_id_to_standard_hex(chain_id: str) -> str | None: + if chain_id.startswith('0x'): + if len(chain_id) > 2: + as_hex = chain_id + else: + try: + as_hex = hex(int(chain_id)) + except ValueError: + return None + + return '0x' + as_hex[2:].lstrip('0') + + +T = typing.TypeVar('T') + + +def get_by_chain_id(mapping: typing.Mapping[str, T], chain_id: str) -> T | None: + if chain_id in mapping: + return mapping[chain_id] + + standard_mapping = {chain_id_to_standard_hex(k): v for k, v in mapping.items()} + return standard_mapping.get(chain_id_to_standard_hex(chain_id)) + + +def chain_ids_equal(lhs: str, rhs: str) -> bool: + return chain_id_to_standard_hex(lhs) == chain_id_to_standard_hex(rhs) diff --git a/python/mesc/validation.py b/python/mesc/validation.py index c79bbf9..9ed0f16 100644 --- a/python/mesc/validation.py +++ b/python/mesc/validation.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing_extensions import Any +from typing import Sequence from .exceptions import InvalidConfig from .types import rpc_config_types, endpoint_types, profile_types @@ -100,7 +101,9 @@ def validate(config: Any) -> None: # default endpoints of each network actually use that specified network for chain_id, endpoint_name in config['network_defaults'].items(): - if chain_id != config['endpoints'][endpoint_name]['chain_id']: + if not network_utils.chain_ids_equal( + chain_id, config['endpoints'][endpoint_name]['chain_id'] + ): raise InvalidConfig( 'Endpoint is set as the default endpoint of network ' + chain_id @@ -109,7 +112,9 @@ def validate(config: Any) -> None: ) for profile_name, profile in config['profiles'].items(): for chain_id, endpoint_name in profile['network_defaults'].items(): - if chain_id != config['endpoints'][endpoint_name]['chain_id']: + if not network_utils.chain_ids_equal( + chain_id, config['endpoints'][endpoint_name]['chain_id'] + ): raise InvalidConfig( 'Endpoint is set as the default endpoint of network ' + chain_id @@ -165,7 +170,28 @@ def validate(config: Any) -> None: ) # no duplicate default network entries using decimal vs hex - pass + ensure_no_chain_id_collisions( + list(config['network_defaults'].keys()), 'network defaults' + ) + for profile_name, profile in config['profiles'].items(): + ensure_no_chain_id_collisions( + list(config['network_defaults'].keys()), 'profile ' + profile_name + ) + + +def ensure_no_chain_id_collisions(chain_ids: Sequence[str], name: str) -> None: + hex_numbers = set() + for chain_id in chain_ids: + as_hex = network_utils.chain_id_to_standard_hex(chain_id) + if as_hex in hex_numbers: + raise Exception( + 'chain_id collision, ' + + str(name) + + ' has multiple decimal/hex values for chain_id: ' + + str(chain_id) + ) + else: + hex_numbers.add(as_hex) def _check_type( diff --git a/rust/crates/mesc/src/interface.rs b/rust/crates/mesc/src/interface.rs index 2de8e3d..21e20c1 100644 --- a/rust/crates/mesc/src/interface.rs +++ b/rust/crates/mesc/src/interface.rs @@ -18,7 +18,7 @@ pub fn get_default_endpoint(profile: Option<&str>) -> Result, M } /// get endpoint by network -pub fn get_endpoint_by_network( +pub fn get_endpoint_by_network( chain_id: T, profile: Option<&str>, ) -> Result, MescError> { diff --git a/rust/crates/mesc/src/query.rs b/rust/crates/mesc/src/query.rs index 1e48523..57c1984 100644 --- a/rust/crates/mesc/src/query.rs +++ b/rust/crates/mesc/src/query.rs @@ -1,7 +1,7 @@ use crate::{ directory, types::{Endpoint, MescError, RpcConfig}, - MultiEndpointQuery, TryIntoChainId, + ChainId, MultiEndpointQuery, TryIntoChainId, }; use std::collections::HashMap; @@ -10,14 +10,14 @@ pub fn get_default_endpoint( config: &RpcConfig, profile: Option<&str>, ) -> Result, MescError> { - // if using a profile, check if that profile has a default endpoint for chain_id + // if using a profile, check if that profile has a default endpoint if let Some(profile) = profile { if let Some(profile_data) = config.profiles.get(profile) { if !profile_data.use_mesc { - return Ok(None) + return Ok(None); } if let Some(endpoint_name) = profile_data.default_endpoint.as_deref() { - return get_endpoint_by_name(config, endpoint_name) + return get_endpoint_by_name(config, endpoint_name); } } }; @@ -29,7 +29,7 @@ pub fn get_default_endpoint( } /// get endpoint by network -pub fn get_endpoint_by_network( +pub fn get_endpoint_by_network( config: &RpcConfig, chain_id: T, profile: Option<&str>, @@ -40,21 +40,40 @@ pub fn get_endpoint_by_network( if let Some(profile) = profile { if let Some(profile_data) = config.profiles.get(profile) { if !profile_data.use_mesc { - return Ok(None) + return Ok(None); } if let Some(endpoint_name) = profile_data.network_defaults.get(&chain_id) { - return get_endpoint_by_name(config, endpoint_name) + return get_endpoint_by_name(config, endpoint_name); } } }; // check if base configuration has a default endpoint for that chain_id - match config.network_defaults.get(&chain_id) { - Some(name) => get_endpoint_by_name(config, name), + match get_by_chain_id(&config.network_defaults, chain_id)? { + Some(name) => get_endpoint_by_name(config, name.as_str()), None => Ok(None), } } +fn get_by_chain_id( + mapping: &HashMap, + chain_id: T, +) -> Result, MescError> { + let chain_id = chain_id.try_into_chain_id()?; + if let Some(value) = mapping.get(&chain_id) { + Ok(Some(value.clone())) + } else { + let standard_chain_id = chain_id.to_hex_256()?; + let results: Result, _> = mapping + .iter() + .map(|(k, v)| k.to_hex_256().map(|hex| (hex, v.clone()))) + .collect::, _>>() // Collect into a Result, Error> + .map(|pairs| pairs.into_iter().collect::>()); + let standard_mapping = results?; + Ok(standard_mapping.get(&standard_chain_id).cloned()) + } +} + /// get endpoint by name pub fn get_endpoint_by_name(config: &RpcConfig, name: &str) -> Result, MescError> { if let Some(endpoint) = config.endpoints.get(name) { @@ -73,7 +92,7 @@ pub fn get_endpoint_by_query( if let Some(profile) = profile { if let Some(profile_data) = config.profiles.get(profile) { if !profile_data.use_mesc { - return Ok(None) + return Ok(None); } } } @@ -108,7 +127,7 @@ pub fn find_endpoints( let mut candidates: Vec = config.endpoints.clone().into_values().collect(); if let Some(chain_id) = query.chain_id { - candidates.retain(|endpoint| endpoint.chain_id.as_ref() == Some(&chain_id)) + candidates.retain(|endpoint| endpoint.chain_id.as_ref() == Some(&chain_id)); } if let Some(name) = query.name_contains { @@ -133,7 +152,7 @@ pub fn get_global_metadata( if let Some(profile) = profile { if let Some(profile_data) = config.profiles.get(profile) { if !profile_data.use_mesc { - return Ok(HashMap::new()) + return Ok(HashMap::new()); } metadata.extend(profile_data.profile_metadata.clone()) } diff --git a/rust/crates/mesc/src/types/chain_ids.rs b/rust/crates/mesc/src/types/chain_ids.rs index 6b0c4be..09bd4a5 100644 --- a/rust/crates/mesc/src/types/chain_ids.rs +++ b/rust/crates/mesc/src/types/chain_ids.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; /// ChainId is a string representation of an integer chain id /// - TryFrom conversions allow specifying as String, &str, uint, or binary data -#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq, Hash)] +#[derive(Serialize, Deserialize, Debug, Clone, Eq)] pub struct ChainId(String); impl ChainId { @@ -23,12 +23,14 @@ impl ChainId { /// convert to hex representation, zero-padded to 256 bits pub fn to_hex_256(&self) -> Result { let ChainId(chain_id) = self; - if chain_id.starts_with("0x") { - Ok(chain_id.clone()) + if let Some(stripped) = chain_id.strip_prefix("0x") { + Ok(format!("0x{:0>64}", stripped)) } else { - match chain_id.parse::() { - Ok(number) => Ok(format!("0x{:016x}", number)), - Err(_) => Err(MescError::IntegrityError("bad chain_id".to_string())), + match chain_id.parse::() { + Ok(number) => Ok(format!("0x{:064x}", number)), + Err(_) => { + Err(MescError::InvalidChainId("cannot convert chain_id to hex".to_string())) + } } } } @@ -40,12 +42,42 @@ impl ChainId { } } +impl std::hash::Hash for ChainId { + fn hash(&self, state: &mut H) { + match self.to_hex_256() { + Ok(as_hex) => { + as_hex.hash(state); + } + _ => { + let ChainId(contents) = self; + contents.hash(state); + } + } + } +} + impl PartialOrd for ChainId { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } +impl PartialEq for ChainId { + fn eq(&self, other: &Self) -> bool { + let self_string: String = match self.to_hex() { + Ok(s) => s[2..].to_string(), + Err(_) => return self == other, + }; + let other_string = match other.to_hex() { + Ok(s) => s[2..].to_string(), + Err(_) => return self == other, + }; + let self_str = format!("{:0>79}", self_string); + let other_str = format!("{:0>79}", other_string); + self_str.eq(&other_str) + } +} + impl Ord for ChainId { fn cmp(&self, other: &Self) -> std::cmp::Ordering { let self_string: String = match self.to_hex() { @@ -96,7 +128,10 @@ impl TryIntoChainId for ChainId { impl TryIntoChainId for String { fn try_into_chain_id(self) -> Result { - if !self.is_empty() && self.chars().all(|c| c.is_ascii_digit()) { + if !self.is_empty() && + (self.chars().all(|c| c.is_ascii_digit()) || + (self.starts_with("0x") && self[2..].chars().all(|c| c.is_ascii_hexdigit()))) + { Ok(ChainId(self)) } else { Err(MescError::InvalidChainId(self)) @@ -106,7 +141,10 @@ impl TryIntoChainId for String { impl TryIntoChainId for &str { fn try_into_chain_id(self) -> Result { - if self.chars().all(|c| c.is_ascii_digit()) { + if !self.is_empty() && + (self.chars().all(|c| c.is_ascii_digit()) || + (self.starts_with("0x") && self[2..].chars().all(|c| c.is_ascii_hexdigit()))) + { Ok(ChainId(self.to_string())) } else { Err(MescError::InvalidChainId(self.to_string())) diff --git a/tests/generate.py b/tests/generate.py index e16981a..99c0c73 100755 --- a/tests/generate.py +++ b/tests/generate.py @@ -68,7 +68,7 @@ "llamanodes_ethereum": { "name": "llamanodes_ethereum", "url": "https://eth.llamarpc.com", - "chain_id": "1", + "chain_id": "0x01", "endpoint_metadata": {}, }, "llamanodes_optimism": { @@ -77,6 +77,12 @@ "chain_id": "10", "endpoint_metadata": {}, }, + "llamanodes_base": { + "name": "llamanodes_base", + "url": "https://base.llamarpc.com", + "chain_id": "0x2105", + "endpoint_metadata": {}, + }, }, "profiles": { "abc": { @@ -90,8 +96,9 @@ "name": "xyz", "default_endpoint": "llamanodes_ethereum", "network_defaults": { - "1": "llamanodes_ethereum", + "0x00001": "llamanodes_ethereum", "10": "llamanodes_optimism", + "8453": "llamanodes_base", }, "profile_metadata": { "sample_key": "profile_value", @@ -144,6 +151,64 @@ def generate_tests() -> list[Test]: tests = [test for generator in generators for test in generator()] + # for queries that specify chain_id, also specify hexadecimal chain_id + for test in list(tests): + query = test[3] + if query is not None and "profile" in query.get("fields", {}): + if query["fields"].get("chain_id") is not None: + # version without leading zeros + chain_id = query["fields"]["chain_id"] # type: ignore + as_hex = hex(int(chain_id)) # type: ignore + new_test = copy.deepcopy(test) + new_test[3]["fields"]["chain_id"] = as_hex # type: ignore + new_test = (new_test[0] + ", hex chain_id",) + new_test[1:] + tests.append(new_test) + + # version with 1 extra leading zero + chain_id = query["fields"]["chain_id"] # type: ignore + as_hex = "0x" + "0" + hex(int(chain_id))[2:] # type: ignore + new_test = copy.deepcopy(test) + new_test[3]["fields"]["chain_id"] = as_hex # type: ignore + new_test = (new_test[0] + ", hex chain_id",) + new_test[1:] + tests.append(new_test) + + # version with 4 extra leading zeros + chain_id = query["fields"]["chain_id"] # type: ignore + as_hex = "0x" + "0000" + hex(int(chain_id))[2:] # type: ignore + new_test = copy.deepcopy(test) + new_test[3]["fields"]["chain_id"] = as_hex # type: ignore + new_test = (new_test[0] + ", hex chain_id",) + new_test[1:] + tests.append(new_test) + + if ( + query["fields"].get("user_input") is not None + and isinstance(query["fields"]["user_input"], str) # type: ignore + and query["fields"]["user_input"].isdecimal() # type: ignore + ): + # version without leading zeros + chain_id = query["fields"]["user_input"] # type: ignore + as_hex = hex(int(chain_id)) + new_test = copy.deepcopy(test) + new_test[3]["fields"]["user_input"] = as_hex # type: ignore + new_test = (new_test[0] + ", hex chain_id",) + new_test[1:] + tests.append(new_test) + + # version with 1 extra leading zero + chain_id = query["fields"]["user_input"] # type: ignore + as_hex = "0x" + "0" + hex(int(chain_id))[2:] + new_test = copy.deepcopy(test) + new_test[3]["fields"]["user_input"] = as_hex # type: ignore + new_test = (new_test[0] + ", hex chain_id",) + new_test[1:] + tests.append(new_test) + + # version with 4 extra leading zeros + chain_id = query["fields"]["user_input"] # type: ignore + as_hex = "0x" + "0000" + hex(int(chain_id))[2:] + new_test = copy.deepcopy(test) + new_test[3]["fields"]["user_input"] = as_hex # type: ignore + new_test = (new_test[0] + ", hex chain_id",) + new_test[1:] + tests.append(new_test) + # for tests that query with null profile, also query with non-existent profile for test in list(tests): query = test[3] @@ -154,7 +219,6 @@ def generate_tests() -> list[Test]: ): new_test = copy.deepcopy(test) new_test[3]["fields"]["profile"] = "unknown_profile" # type: ignore - new_test = (new_test[0] + ", unknown_profile",) + new_test[1:] tests.append(new_test) @@ -309,6 +373,28 @@ def create_basic_query_tests() -> list[Test]: None, True, ), + ( + "get endpoint by network, base", + {}, + full_config, + { + "query_type": "endpoint_by_network", + "fields": {"chain_id": "8453", "profile": None}, + }, + None, + True, + ), + ( + "get endpoint by network, base, profile", + {}, + full_config, + { + "query_type": "endpoint_by_network", + "fields": {"chain_id": "8453", "profile": "xyz"}, + }, + full_config["endpoints"]["llamanodes_base"], + True, + ), ( "get endpoint by network profile fallback", {}, @@ -655,6 +741,7 @@ def create_basic_query_tests() -> list[Test]: [ full_config["endpoints"]["llamanodes_ethereum"], full_config["endpoints"]["llamanodes_optimism"], + full_config["endpoints"]["llamanodes_base"], ], True, ), @@ -812,7 +899,7 @@ def create_invalid_config_tests() -> list[Test]: ) ) config = copy.deepcopy(full_config) - config['profiles']['abc']["default_endpoint"] = "random_unknown" + config["profiles"]["abc"]["default_endpoint"] = "random_unknown" tests.append( ( "unknown default endpoint", @@ -838,7 +925,7 @@ def create_invalid_config_tests() -> list[Test]: ), ) config = copy.deepcopy(full_config) - config['profiles']['abc']["network_defaults"]["10"] = "random_unknown" + config["profiles"]["abc"]["network_defaults"]["10"] = "random_unknown" tests.append( ( "unknown network defaults", @@ -852,7 +939,7 @@ def create_invalid_config_tests() -> list[Test]: # endpoint name doesn't match config = copy.deepcopy(blank_config) - config['endpoints'] = {'other_name': blank_endpoint} + config["endpoints"] = {"other_name": blank_endpoint} tests.append( ( "unknown network defaults", @@ -867,7 +954,7 @@ def create_invalid_config_tests() -> list[Test]: # profile name doesn't match config = copy.deepcopy(blank_config) - config['profiles'] = {'other_name': blank_profile} + config["profiles"] = {"other_name": blank_profile} tests.append( ( "unknown network defaults", @@ -881,7 +968,7 @@ def create_invalid_config_tests() -> list[Test]: # global default endpoint of network doesn't actually use that network config = copy.deepcopy(full_config) - config['endpoints']['local_ethereum']['chain_id'] = '4' + config["endpoints"]["local_ethereum"]["chain_id"] = "4" tests.append( ( "global default endpoint of network doesn't actually use that network", @@ -895,7 +982,7 @@ def create_invalid_config_tests() -> list[Test]: # profile default endpoint of network doesn't actually use that network config = copy.deepcopy(full_config) - config['endpoints']['llamanodes_ethereum']['chain_id'] = '4' + config["endpoints"]["llamanodes_ethereum"]["chain_id"] = "4" tests.append( ( "global default endpoint of network doesn't actually use that network", @@ -907,6 +994,41 @@ def create_invalid_config_tests() -> list[Test]: ), ) + # conflicting chain_ids in global network defaults + config = copy.deepcopy(full_config) + config["network_defaults"]["8453"] = "llamanodes_base" + config["network_defaults"]["0x2105"] = "llamanodes_base" + tests.append( + ( + "conflicting chain_ids in global network defaults", + {}, + config, + None, + None, + False, + ), + ) + + # conflicting chain_ids in profile network defaults + for profile_name, profile_data in full_config["profiles"].items(): + config = copy.deepcopy(full_config) + config["profiles"][profile_name]["network_defaults"]["8453"] = "llamanodes_base" + config["profiles"][profile_name]["network_defaults"][ + "0x2105" + ] = "llamanodes_base" + tests.append( + ( + "conflicting chain_ids in profile " + + profile_name + + " network defaults", + {}, + config, + None, + None, + False, + ), + ) + # incorrect types tests invalid_type_tests = [ (