diff --git a/crates/foreign-chain-health-check/src/probe.rs b/crates/foreign-chain-health-check/src/probe.rs index 3a98b19761..a7006ce42d 100644 --- a/crates/foreign-chain-health-check/src/probe.rs +++ b/crates/foreign-chain-health-check/src/probe.rs @@ -4,6 +4,13 @@ use std::collections::BTreeMap; use std::time::Duration; +use foreign_chain_inspector::abstract_chain::inspector::Abstract; +use foreign_chain_inspector::arbitrum::inspector::Arbitrum; +use foreign_chain_inspector::base::inspector::Base; +use foreign_chain_inspector::bnb::inspector::Bnb; +use foreign_chain_inspector::evm::inspector::{EvmChain, EvmInspector}; +use foreign_chain_inspector::hyperevm::inspector::HyperEvm; +use foreign_chain_inspector::polygon::inspector::Polygon; use foreign_chain_inspector::starknet::inspector::StarknetInspector; use foreign_chain_inspector::{ FanOut, ForeignChainInspectionError, NetworkFingerprint, ProviderFailure, @@ -65,7 +72,6 @@ impl ProbeReport { &self.rows } - /// Only configured chains appear, never reports on a chain the operator did not configure. pub fn counts_per_chain(&self) -> BTreeMap { let mut counts: BTreeMap = BTreeMap::new(); for row in &self.rows { @@ -95,7 +101,14 @@ pub async fn probe_all_providers(config: &ForeignChainsConfig) -> ProbeReport { }) .await } - // TODO(#4003): probe the remaining chains. + ForeignChain::Abstract => probe_evm::(chain, chain_config).await, + ForeignChain::Arbitrum => probe_evm::(chain, chain_config).await, + ForeignChain::Base => probe_evm::(chain, chain_config).await, + ForeignChain::Bnb => probe_evm::(chain, chain_config).await, + ForeignChain::HyperEvm => probe_evm::(chain, chain_config).await, + ForeignChain::Polygon => probe_evm::(chain, chain_config).await, + // TODO(#4003): probe Bitcoin, Aptos and Sui. Ethereum, Solana and Ton have no + // inspector, so there is nothing to probe them with. _ => rows_of(chain, chain_config, ProviderStatus::ProbeNotImplemented), } }); @@ -104,6 +117,16 @@ pub async fn probe_all_providers(config: &ForeignChainsConfig) -> ProbeReport { ProbeReport { rows: report_rows } } +async fn probe_evm(chain: ForeignChain, config: &ForeignChainConfig) -> Vec +where + Chain: EvmChain + Clone + Send + Sync + 'static, +{ + probe_chain(chain, config, |provider| { + Ok(EvmInspector::<_, Chain>::new(prepare_jsonrpc(provider)?)) + }) + .await +} + async fn probe_chain( chain: ForeignChain, config: &ForeignChainConfig, @@ -175,18 +198,6 @@ fn rows_of( .collect() } -/// A provider answers what it likes and the report reaches logs and metric labels, so the length is -/// capped well clear of the longest real fingerprint: Bitcoin's genesis hash, at 66 characters. -fn bounded(observed: NetworkFingerprint) -> NetworkFingerprint { - const MAX_CHARS: usize = 96; - - let observed = observed.to_string(); - match observed.char_indices().nth(MAX_CHARS) { - None => NetworkFingerprint::from(observed), - Some((cutoff, _)) => NetworkFingerprint::from(format!("{}…", &observed[..cutoff])), - } -} - fn classify( expected: &NetworkFingerprint, reported: Result, @@ -195,7 +206,7 @@ fn classify( Ok(observed) if &observed == expected => ProviderStatus::Healthy, Ok(observed) => ProviderStatus::WrongNetwork { expected: expected.clone(), - observed: bounded(observed), + observed, }, Err(error) => match error.provider_failure() { Some(ProviderFailure::Unreachable) => ProviderStatus::Unreachable, @@ -224,6 +235,53 @@ mod tests { const PADDED_UPPERCASE_MAINNET: &str = "0x00534E5F4D41494E"; /// Reserved as "discard", so nothing listens there. const CLOSED_PORT_URL: &str = "http://127.0.0.1:9"; + /// For a chain with no probe: the value is never read, only whether it is set at all. + const ANY_FINGERPRINT: &str = "any-fingerprint"; + + struct EvmMainnet { + chain: ForeignChain, + chain_id: u64, + } + + impl EvmMainnet { + /// The form an operator configures. + fn expected(&self) -> String { + self.chain_id.to_string() + } + + /// The `0xXXX` hex quantity an RPC provider answers to an `eth_chainId` request. + fn answered(&self) -> String { + format!("{:#x}", self.chain_id) + } + } + + /// Every EVM chain the probe covers, with its mainnet chain id. + const EVM_MAINNETS: [EvmMainnet; 6] = [ + EvmMainnet { + chain: ForeignChain::Abstract, + chain_id: 2741, + }, + EvmMainnet { + chain: ForeignChain::Arbitrum, + chain_id: 42161, + }, + EvmMainnet { + chain: ForeignChain::Base, + chain_id: 8453, + }, + EvmMainnet { + chain: ForeignChain::Bnb, + chain_id: 56, + }, + EvmMainnet { + chain: ForeignChain::HyperEvm, + chain_id: 999, + }, + EvmMainnet { + chain: ForeignChain::Polygon, + chain_id: 137, + }, + ]; fn provider(rpc_url: &str) -> ForeignChainProviderConfig { ForeignChainProviderConfig { @@ -271,6 +329,30 @@ mod tests { } } + fn bitcoin_only(config: ForeignChainConfig) -> ForeignChainsConfig { + ForeignChainsConfig { + bitcoin: Some(config), + ..Default::default() + } + } + + fn must_put_chain( + chains: &mut ForeignChainsConfig, + chain: ForeignChain, + config: ForeignChainConfig, + ) { + let slot = match chain { + ForeignChain::Abstract => &mut chains.abstract_chain, + ForeignChain::Arbitrum => &mut chains.arbitrum, + ForeignChain::Base => &mut chains.base, + ForeignChain::Bnb => &mut chains.bnb, + ForeignChain::HyperEvm => &mut chains.hyper_evm, + ForeignChain::Polygon => &mut chains.polygon, + other => panic!("no config slot wired for `{other:?}`"), + }; + *slot = Some(config); + } + async fn mock_chain_id<'a>( server: &'a httpmock::MockServer, chain_id: &str, @@ -386,8 +468,8 @@ mod tests { assert_eq!( must_status_of(&report, ForeignChain::Starknet, "publicnode"), ProviderStatus::WrongNetwork { - expected: NetworkFingerprint::from(MAINNET.to_string()), - observed: NetworkFingerprint::from(SEPOLIA.to_string()), + expected: NetworkFingerprint::new(MAINNET), + observed: NetworkFingerprint::new(SEPOLIA), } ); } @@ -635,20 +717,17 @@ mod tests { async fn probe_all_providers__should_report_a_chain_with_no_fingerprint_probe_as_not_implemented() { // Given - let config = ForeignChainsConfig { - base: Some(chain_config( - Some("8453"), - one_provider("publicnode", CLOSED_PORT_URL), - )), - ..Default::default() - }; + let config = bitcoin_only(chain_config( + Some(ANY_FINGERPRINT), + one_provider("publicnode", CLOSED_PORT_URL), + )); // When let report = probe_all_providers(&config).await; // Then assert_eq!( - must_status_of(&report, ForeignChain::Base, "publicnode"), + must_status_of(&report, ForeignChain::Bitcoin, "publicnode"), ProviderStatus::ProbeNotImplemented ); } @@ -663,8 +742,8 @@ mod tests { Some(MAINNET), one_provider("publicnode", &server.base_url()), )), - base: Some(chain_config( - Some("8453"), + bitcoin: Some(chain_config( + Some(ANY_FINGERPRINT), one_provider("publicnode", CLOSED_PORT_URL), )), ..Default::default() @@ -679,12 +758,71 @@ mod tests { ProviderStatus::Healthy ); assert_eq!( - must_status_of(&report, ForeignChain::Base, "publicnode"), + must_status_of(&report, ForeignChain::Bitcoin, "publicnode"), ProviderStatus::ProbeNotImplemented ); assert_eq!(report.counts_per_chain().len(), 2); } + #[tokio::test] + async fn probe_all_providers__should_report_every_evm_chain_on_its_expected_network_as_healthy() + { + // Given + let mut servers = Vec::new(); + let mut config = ForeignChainsConfig::default(); + for mainnet in EVM_MAINNETS { + let server = httpmock::MockServer::start_async().await; + mock_chain_id(&server, &mainnet.answered()).await; + must_put_chain( + &mut config, + mainnet.chain, + chain_config( + Some(&mainnet.expected()), + one_provider("publicnode", &server.base_url()), + ), + ); + servers.push(server); + } + + // When + let report = probe_all_providers(&config).await; + + // Then + for EvmMainnet { chain, .. } in EVM_MAINNETS { + assert_eq!( + must_status_of(&report, chain, "publicnode"), + ProviderStatus::Healthy, + "{chain:?}" + ); + } + } + + #[tokio::test] + async fn probe_all_providers__should_report_an_evm_provider_on_another_network_as_wrong_network() + { + // Given + let server = httpmock::MockServer::start_async().await; + mock_chain_id(&server, "0x14a34").await; + let mut config = ForeignChainsConfig::default(); + must_put_chain( + &mut config, + ForeignChain::Base, + chain_config(Some("8453"), one_provider("publicnode", &server.base_url())), + ); + + // When + let report = probe_all_providers(&config).await; + + // Then + assert_eq!( + must_status_of(&report, ForeignChain::Base, "publicnode"), + ProviderStatus::WrongNetwork { + expected: NetworkFingerprint::new("8453"), + observed: NetworkFingerprint::new("84532"), + } + ); + } + #[tokio::test] async fn probe_all_providers__should_retry_a_provider_that_refused_with_a_rate_limit_code() { // Given @@ -726,13 +864,19 @@ mod tests { else { panic!("expected the flood to read as the wrong network"); }; - assert!(observed.to_string().chars().count() < 100); + let observed = observed.to_string(); + assert!(observed.ends_with("_TRUNCATED"), "{observed}"); + assert_eq!( + observed.chars().count(), + NetworkFingerprint::MAX_CHARS, + "{observed}" + ); } #[test] fn classify__should_report_a_transaction_level_error_as_malformed() { // Given - let expected = NetworkFingerprint::from(MAINNET.to_string()); + let expected = NetworkFingerprint::new(MAINNET); // When let status = classify( diff --git a/crates/foreign-chain-inspector/src/evm/inspector.rs b/crates/foreign-chain-inspector/src/evm/inspector.rs index f7f8caf219..00a9a3e405 100644 --- a/crates/foreign-chain-inspector/src/evm/inspector.rs +++ b/crates/foreign-chain-inspector/src/evm/inspector.rs @@ -3,16 +3,20 @@ use std::hash::Hash; use jsonrpsee::core::client::ClientT; -use crate::{EthereumFinality, ForeignChainInspectionError, ForeignChainInspector}; +use crate::{ + EthereumFinality, ForeignChainInspectionError, ForeignChainInspector, NO_PARAMS, + NetworkFingerprint, NetworkFingerprintInspector, +}; use foreign_chain_rpc_interfaces::evm::{ - BlockNumberOrTag, FinalityTag, GetBlockByNumberArgs, GetBlockByNumberResponse, + BlockNumberOrTag, ChainIdResponse, FinalityTag, GetBlockByNumberArgs, GetBlockByNumberResponse, GetTransactionReceiptARgs, GetTransactionReceiptResponse, H256, Log, ReturnFullTransactionObjects, U64, }; const GET_TRANSACTION_RECEIPT_METHOD: &str = "eth_getTransactionReceipt"; const GET_BLOCK_BY_NUMBER_METHOD: &str = "eth_getBlockByNumber"; +const CHAIN_ID_METHOD: &str = "eth_chainId"; /// Marker trait for EVM-compatible chain type parameters. /// @@ -37,6 +41,25 @@ pub struct EvmInspector { _chain: std::marker::PhantomData, } +impl NetworkFingerprintInspector for EvmInspector +where + Client: ClientT + Send + Sync, + Chain: Send + Sync, +{ + async fn network_fingerprint(&self) -> Result { + let chain_id: ChainIdResponse = self + .client + .request(CHAIN_ID_METHOD, NO_PARAMS) + .await + .map_err(ForeignChainInspectionError::classify_rpc_client_error)?; + Ok(NetworkFingerprint::new(chain_id.canonical_text())) + } + + fn canonical_fingerprint(fingerprint: &str) -> NetworkFingerprint { + NetworkFingerprint::new(ChainIdResponse(fingerprint.to_owned()).canonical_text()) + } +} + impl ForeignChainInspector for EvmInspector where Client: ClientT + Send + Sync, diff --git a/crates/foreign-chain-inspector/src/lib.rs b/crates/foreign-chain-inspector/src/lib.rs index cc1a46c655..56cb041219 100644 --- a/crates/foreign-chain-inspector/src/lib.rs +++ b/crates/foreign-chain-inspector/src/lib.rs @@ -41,11 +41,39 @@ pub trait ForeignChainInspector { ) -> impl Future, ForeignChainInspectionError>> + Send; } +/// Parameters for an RPC that takes none. Sent as an explicit empty array. +pub(crate) const NO_PARAMS: [(); 0] = []; + /// The network a provider serves, as the chain itself reports it: a chain id or a genesis hash, in /// one canonical text form per chain. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Display, From)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Display)] pub struct NetworkFingerprint(String); +impl NetworkFingerprint { + /// Values are compared after the cut, so this must exceed every fingerprint in use. The + /// longest is Bitcoin's genesis hash at 64 characters. + pub const MAX_CHARS: usize = 96; + const CUT_SHORT_MARKER: &str = "_TRUNCATED"; + + /// Text longer than [`Self::MAX_CHARS`] is cut short, a very long string answered by + /// faulty providers does not reach logs and metric labels in full length. + pub fn new(fingerprint: impl Into) -> Self { + const KEPT_CHARS: usize = + NetworkFingerprint::MAX_CHARS - NetworkFingerprint::CUT_SHORT_MARKER.len(); + + let fingerprint = fingerprint.into(); + let mut characters = fingerprint.chars(); + let within_cap: String = characters.by_ref().take(Self::MAX_CHARS).collect(); + match characters.next() { + None => Self(within_cap), + Some(_) => { + let kept: String = within_cap.chars().take(KEPT_CHARS).collect(); + Self(format!("{kept}{}", Self::CUT_SHORT_MARKER)) + } + } + } +} + /// Reports the [`NetworkFingerprint`] of the provider an inspector talks to, in the form /// [`Self::canonical_fingerprint`] produces. pub trait NetworkFingerprintInspector { @@ -685,4 +713,46 @@ mod tests { // Then assert_eq!(failure, expected); } + + #[test] + fn network_fingerprint_new__should_keep_the_longest_real_fingerprint_whole() { + // Given + let bitcoin_genesis_hash = + "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"; + + // When + let fingerprint = NetworkFingerprint::new(bitcoin_genesis_hash); + + // Then + assert_eq!(fingerprint.to_string(), bitcoin_genesis_hash); + } + + #[test] + fn network_fingerprint_new__should_keep_an_answer_exactly_at_length() { + // Given + let answered = "a".repeat(NetworkFingerprint::MAX_CHARS); + + // When + let fingerprint = NetworkFingerprint::new(&answered); + + // Then + assert_eq!(fingerprint.to_string(), answered); + } + + #[test] + fn network_fingerprint_new__should_truncate_long_provider_results_based_on_chars() { + // Given + // Four bytes wide character + let wide_char = "\u{1F642}"; + let answered = wide_char.repeat(200); + + // When + let fingerprint = NetworkFingerprint::new(answered); + + // Then + let reported = fingerprint.to_string(); + assert!(reported.starts_with(wide_char)); + assert!(reported.ends_with(NetworkFingerprint::CUT_SHORT_MARKER)); + assert_eq!(reported.chars().count(), NetworkFingerprint::MAX_CHARS); + } } diff --git a/crates/foreign-chain-inspector/src/starknet/inspector.rs b/crates/foreign-chain-inspector/src/starknet/inspector.rs index 68aa496fe9..eee604bb61 100644 --- a/crates/foreign-chain-inspector/src/starknet/inspector.rs +++ b/crates/foreign-chain-inspector/src/starknet/inspector.rs @@ -1,6 +1,6 @@ use crate::starknet::{StarknetExtractedValue, StarknetTransactionHash}; use crate::{ - ForeignChainInspectionError, ForeignChainInspector, NetworkFingerprint, + ForeignChainInspectionError, ForeignChainInspector, NO_PARAMS, NetworkFingerprint, NetworkFingerprintInspector, }; use foreign_chain_rpc_interfaces::starknet::{ @@ -14,8 +14,6 @@ use near_mpc_contract_interface::types::{StarknetFelt, StarknetLog}; const GET_TRANSACTION_RECEIPT_METHOD: &str = "starknet_getTransactionReceipt"; const GET_BLOCK_WITH_TX_HASHES_METHOD: &str = "starknet_getBlockWithTxHashes"; const CHAIN_ID_METHOD: &str = "starknet_chainId"; -/// `starknet_chainId` takes no arguments. Sent as an explicit empty array. -const NO_PARAMS: [(); 0] = []; #[derive(Clone)] pub struct StarknetInspector { @@ -38,13 +36,11 @@ where .request(CHAIN_ID_METHOD, NO_PARAMS) .await .map_err(ForeignChainInspectionError::classify_rpc_client_error)?; - Ok(chain_id.canonical_text().into()) + Ok(Self::canonical_fingerprint(&chain_id.0)) } fn canonical_fingerprint(fingerprint: &str) -> NetworkFingerprint { - ChainIdResponse(fingerprint.to_owned()) - .canonical_text() - .into() + NetworkFingerprint::new(ChainIdResponse(fingerprint.to_owned()).canonical_text()) } } diff --git a/crates/foreign-chain-inspector/tests/abstract_rpc_manual.rs b/crates/foreign-chain-inspector/tests/abstract_rpc_manual.rs index eb4fda328d..4c1bcea8b2 100644 --- a/crates/foreign-chain-inspector/tests/abstract_rpc_manual.rs +++ b/crates/foreign-chain-inspector/tests/abstract_rpc_manual.rs @@ -1,20 +1,18 @@ use assert_matches::assert_matches; use foreign_chain_inspector::{ - EthereumFinality, ForeignChainInspector, RpcAuthentication, + EthereumFinality, ForeignChainInspector, NetworkFingerprintInspector, RpcAuthentication, abstract_chain::{ AbstractBlockHash, AbstractTransactionHash, inspector::{AbstractExtractedValue, AbstractExtractor, AbstractInspector}, }, }; +const ABSTRACT_RPC_URL: &str = "https://api.testnet.abs.xyz"; + #[tokio::test] #[ignore = "manual test to sanity check against live Abstract RPC provider"] async fn inspector_extracts_block_hash_against_live_rpc_provider() { // given - // Note: Replace with your actual Abstract RPC endpoint URL - // Example: QuickNode Abstract endpoint - const ABSTRACT_RPC_URL: &str = "https://api.testnet.abs.xyz"; - let threshold = EthereumFinality::Finalized; // Example transaction from Abstract testnet @@ -56,3 +54,27 @@ async fn inspector_extracts_block_hash_against_live_rpc_provider() { ); assert_matches!(extracted_values[1], AbstractExtractedValue::Log(_)); } + +/// Abstract testnet's chain id, as shipped in `expected_network_fingerprint`. +const EXPECTED_NETWORK_FINGERPRINT: &str = "11124"; + +#[tokio::test] +#[ignore = "manual test to sanity check against live Abstract RPC provider"] +async fn network_fingerprint_matches_the_shipped_config_value_against_live_rpc_provider() { + // given + let http_client = foreign_chain_inspector::build_http_client( + ABSTRACT_RPC_URL.to_string(), + RpcAuthentication::KeyInUrl, + ) + .unwrap(); + let inspector = AbstractInspector::new(http_client); + + // when + let fingerprint = inspector + .network_fingerprint() + .await + .expect("network_fingerprint should succeed"); + + // then + assert_eq!(fingerprint.to_string(), EXPECTED_NETWORK_FINGERPRINT); +} diff --git a/crates/foreign-chain-inspector/tests/arbitrum_rpc_manual.rs b/crates/foreign-chain-inspector/tests/arbitrum_rpc_manual.rs index f84724af8b..19bbc8e42d 100644 --- a/crates/foreign-chain-inspector/tests/arbitrum_rpc_manual.rs +++ b/crates/foreign-chain-inspector/tests/arbitrum_rpc_manual.rs @@ -1,18 +1,18 @@ use assert_matches::assert_matches; use foreign_chain_inspector::{ - EthereumFinality, ForeignChainInspector, RpcAuthentication, + EthereumFinality, ForeignChainInspector, NetworkFingerprintInspector, RpcAuthentication, arbitrum::{ ArbitrumBlockHash, ArbitrumTransactionHash, inspector::{ArbitrumExtractedValue, ArbitrumExtractor, ArbitrumInspector}, }, }; +const ARBITRUM_RPC_URL: &str = "https://arb1.arbitrum.io/rpc"; + #[tokio::test] #[ignore = "manual test to sanity check against live Arbitrum RPC provider"] async fn inspector_extracts_block_hash_against_live_rpc_provider() { // given - const ARBITRUM_RPC_URL: &str = "https://arb1.arbitrum.io/rpc"; - let threshold = EthereumFinality::Finalized; // Example transaction on Arbitrum One with 3 logs; @@ -58,3 +58,27 @@ async fn inspector_extracts_block_hash_against_live_rpc_provider() { assert_matches!(extracted_values[2], ArbitrumExtractedValue::Log(_)); assert_matches!(extracted_values[3], ArbitrumExtractedValue::Log(_)); } + +/// Arbitrum One's chain id, as shipped in `expected_network_fingerprint`. +const EXPECTED_NETWORK_FINGERPRINT: &str = "42161"; + +#[tokio::test] +#[ignore = "manual test to sanity check against live Arbitrum RPC provider"] +async fn network_fingerprint_matches_the_shipped_config_value_against_live_rpc_provider() { + // given + let http_client = foreign_chain_inspector::build_http_client( + ARBITRUM_RPC_URL.to_string(), + RpcAuthentication::KeyInUrl, + ) + .unwrap(); + let inspector = ArbitrumInspector::new(http_client); + + // when + let fingerprint = inspector + .network_fingerprint() + .await + .expect("network_fingerprint should succeed"); + + // then + assert_eq!(fingerprint.to_string(), EXPECTED_NETWORK_FINGERPRINT); +} diff --git a/crates/foreign-chain-inspector/tests/base_rpc_manual.rs b/crates/foreign-chain-inspector/tests/base_rpc_manual.rs index 018001a778..29c2050914 100644 --- a/crates/foreign-chain-inspector/tests/base_rpc_manual.rs +++ b/crates/foreign-chain-inspector/tests/base_rpc_manual.rs @@ -1,18 +1,18 @@ use assert_matches::assert_matches; use foreign_chain_inspector::{ - EthereumFinality, ForeignChainInspector, RpcAuthentication, + EthereumFinality, ForeignChainInspector, NetworkFingerprintInspector, RpcAuthentication, base::{ BaseBlockHash, BaseTransactionHash, inspector::{BaseExtractedValue, BaseExtractor, BaseInspector}, }, }; +const BASE_RPC_URL: &str = "https://mainnet.base.org"; + #[tokio::test] #[ignore = "manual test to sanity check against live Base RPC provider"] async fn inspector_extracts_block_hash_against_live_rpc_provider() { // given - const BASE_RPC_URL: &str = "https://mainnet.base.org"; - let threshold = EthereumFinality::Finalized; // Example transaction on Base mainnet (block 33554432) with 16 logs; @@ -58,3 +58,27 @@ async fn inspector_extracts_block_hash_against_live_rpc_provider() { assert_matches!(extracted_values[2], BaseExtractedValue::Log(_)); assert_matches!(extracted_values[3], BaseExtractedValue::Log(_)); } + +/// Base mainnet's chain id, as shipped in `expected_network_fingerprint`. +const EXPECTED_NETWORK_FINGERPRINT: &str = "8453"; + +#[tokio::test] +#[ignore = "manual test to sanity check against live Base RPC provider"] +async fn network_fingerprint_matches_the_shipped_config_value_against_live_rpc_provider() { + // given + let http_client = foreign_chain_inspector::build_http_client( + BASE_RPC_URL.to_string(), + RpcAuthentication::KeyInUrl, + ) + .unwrap(); + let inspector = BaseInspector::new(http_client); + + // when + let fingerprint = inspector + .network_fingerprint() + .await + .expect("network_fingerprint should succeed"); + + // then + assert_eq!(fingerprint.to_string(), EXPECTED_NETWORK_FINGERPRINT); +} diff --git a/crates/foreign-chain-inspector/tests/bnb_rpc_manual.rs b/crates/foreign-chain-inspector/tests/bnb_rpc_manual.rs index 940f78760f..fc6bcafda1 100644 --- a/crates/foreign-chain-inspector/tests/bnb_rpc_manual.rs +++ b/crates/foreign-chain-inspector/tests/bnb_rpc_manual.rs @@ -1,18 +1,18 @@ use assert_matches::assert_matches; use foreign_chain_inspector::{ - EthereumFinality, ForeignChainInspector, RpcAuthentication, + EthereumFinality, ForeignChainInspector, NetworkFingerprintInspector, RpcAuthentication, bnb::{ BnbBlockHash, BnbTransactionHash, inspector::{BnbExtractedValue, BnbExtractor, BnbInspector}, }, }; +const BNB_RPC_URL: &str = "https://bsc-rpc.publicnode.com"; + #[tokio::test] #[ignore = "manual test to sanity check against live BNB RPC provider"] async fn inspector_extracts_block_hash_against_live_rpc_provider() { // given - const BNB_RPC_URL: &str = "https://bsc-rpc.publicnode.com"; - let threshold = EthereumFinality::Finalized; // Example DEX swap transaction on BNB with 3 logs @@ -58,3 +58,27 @@ async fn inspector_extracts_block_hash_against_live_rpc_provider() { assert_matches!(extracted_values[2], BnbExtractedValue::Log(_)); assert_matches!(extracted_values[3], BnbExtractedValue::Log(_)); } + +/// BNB mainnet's chain id, as shipped in `expected_network_fingerprint`. +const EXPECTED_NETWORK_FINGERPRINT: &str = "56"; + +#[tokio::test] +#[ignore = "manual test to sanity check against live BNB RPC provider"] +async fn network_fingerprint_matches_the_shipped_config_value_against_live_rpc_provider() { + // given + let http_client = foreign_chain_inspector::build_http_client( + BNB_RPC_URL.to_string(), + RpcAuthentication::KeyInUrl, + ) + .unwrap(); + let inspector = BnbInspector::new(http_client); + + // when + let fingerprint = inspector + .network_fingerprint() + .await + .expect("network_fingerprint should succeed"); + + // then + assert_eq!(fingerprint.to_string(), EXPECTED_NETWORK_FINGERPRINT); +} diff --git a/crates/foreign-chain-inspector/tests/evm_inspector.rs b/crates/foreign-chain-inspector/tests/evm_inspector.rs index 61db6fdb2a..b4f71b2280 100644 --- a/crates/foreign-chain-inspector/tests/evm_inspector.rs +++ b/crates/foreign-chain-inspector/tests/evm_inspector.rs @@ -2,10 +2,14 @@ pub mod common; -use crate::common::{FixedResponseRpcClient, SequentialResponseMockClientBuilder}; +use crate::common::{ + FixedResponseRpcClient, SequentialResponseMockClientBuilder, mock_client_from_fixed_response, +}; use foreign_chain_inspector::{ - EthereumFinality, ForeignChainInspectionError, ForeignChainInspector, RpcAuthentication, + EthereumFinality, ForeignChainInspectionError, ForeignChainInspector, + NetworkFingerprintInspector, RpcAuthentication, + base::inspector::Base, build_http_client, evm::inspector::{EvmChain, EvmExtractedValue, EvmExtractor, EvmInspector}, }; @@ -716,3 +720,51 @@ evm_inspector_tests!( foreign_chain_inspector::polygon::inspector::Polygon, polygon ); + +// Base mainnet, standing in for every EVM chain: the fingerprint call has no chain-specific parts. +const CHAIN_ID_8453: &str = "0x2105"; +const PADDED_CHAIN_ID_8453: &str = "0x002105"; + +#[tokio::test] +async fn network_fingerprint__should_return_the_chain_id_in_decimal() { + // Given + let inspector = + EvmInspector::<_, Base>::new(mock_client_from_fixed_response(PADDED_CHAIN_ID_8453)); + + // When + let fingerprint = inspector + .network_fingerprint() + .await + .expect("network_fingerprint should succeed"); + + // Then + assert_eq!(fingerprint.to_string(), "8453"); +} + +#[tokio::test] +async fn network_fingerprint__should_ask_the_provider_for_its_chain_id() { + // Given + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST).body_includes(r#""method":"eth_chainId""#); + then.status(200).json_body(serde_json::json!({ + "jsonrpc": "2.0", + "id": 0, + "result": CHAIN_ID_8453, + })); + }) + .await; + let client = build_http_client(server.url("/"), RpcAuthentication::KeyInUrl).unwrap(); + let inspector = EvmInspector::<_, Base>::new(client); + + // When + let fingerprint = inspector + .network_fingerprint() + .await + .expect("network_fingerprint should succeed"); + + // Then + mock.assert_async().await; + assert_eq!(fingerprint.to_string(), "8453"); +} diff --git a/crates/foreign-chain-inspector/tests/hyperevm_rpc_manual.rs b/crates/foreign-chain-inspector/tests/hyperevm_rpc_manual.rs index 22104ad470..9d40e87421 100644 --- a/crates/foreign-chain-inspector/tests/hyperevm_rpc_manual.rs +++ b/crates/foreign-chain-inspector/tests/hyperevm_rpc_manual.rs @@ -1,18 +1,18 @@ use assert_matches::assert_matches; use foreign_chain_inspector::{ - EthereumFinality, ForeignChainInspector, RpcAuthentication, + EthereumFinality, ForeignChainInspector, NetworkFingerprintInspector, RpcAuthentication, hyperevm::{ HyperEvmBlockHash, HyperEvmTransactionHash, inspector::{HyperEvmExtractedValue, HyperEvmExtractor, HyperEvmInspector}, }, }; +const HYPEREVM_RPC_URL: &str = "https://rpc.hyperliquid.xyz/evm"; + #[tokio::test] #[ignore = "manual test to sanity check against live HyperEVM RPC provider"] async fn inspector_extracts_block_hash_against_live_rpc_provider() { // given - const HYPEREVM_RPC_URL: &str = "https://rpc.hyperliquid.xyz/evm"; - let threshold = EthereumFinality::Finalized; // Example transaction on HyperEVM (block 0x20c6dc5) with 3 logs; @@ -58,3 +58,27 @@ async fn inspector_extracts_block_hash_against_live_rpc_provider() { assert_matches!(extracted_values[2], HyperEvmExtractedValue::Log(_)); assert_matches!(extracted_values[3], HyperEvmExtractedValue::Log(_)); } + +/// HyperEVM mainnet's chain id, as shipped in `expected_network_fingerprint`. +const EXPECTED_NETWORK_FINGERPRINT: &str = "999"; + +#[tokio::test] +#[ignore = "manual test to sanity check against live HyperEVM RPC provider"] +async fn network_fingerprint_matches_the_shipped_config_value_against_live_rpc_provider() { + // given + let http_client = foreign_chain_inspector::build_http_client( + HYPEREVM_RPC_URL.to_string(), + RpcAuthentication::KeyInUrl, + ) + .unwrap(); + let inspector = HyperEvmInspector::new(http_client); + + // when + let fingerprint = inspector + .network_fingerprint() + .await + .expect("network_fingerprint should succeed"); + + // then + assert_eq!(fingerprint.to_string(), EXPECTED_NETWORK_FINGERPRINT); +} diff --git a/crates/foreign-chain-inspector/tests/polygon_rpc_manual.rs b/crates/foreign-chain-inspector/tests/polygon_rpc_manual.rs index 611eedeb32..aa33a7dfd3 100644 --- a/crates/foreign-chain-inspector/tests/polygon_rpc_manual.rs +++ b/crates/foreign-chain-inspector/tests/polygon_rpc_manual.rs @@ -1,18 +1,18 @@ use assert_matches::assert_matches; use foreign_chain_inspector::{ - EthereumFinality, ForeignChainInspector, RpcAuthentication, + EthereumFinality, ForeignChainInspector, NetworkFingerprintInspector, RpcAuthentication, polygon::{ PolygonBlockHash, PolygonTransactionHash, inspector::{PolygonExtractedValue, PolygonExtractor, PolygonInspector}, }, }; +const POLYGON_RPC_URL: &str = "https://polygon.drpc.org"; + #[tokio::test] #[ignore = "manual test to sanity check against live Polygon RPC provider"] async fn inspector_extracts_block_hash_against_live_rpc_provider() { // given - const POLYGON_RPC_URL: &str = "https://polygon.drpc.org"; - let threshold = EthereumFinality::Finalized; // Example transaction on Polygon (block 0x5276e5d) with 8 logs; @@ -58,3 +58,27 @@ async fn inspector_extracts_block_hash_against_live_rpc_provider() { assert_matches!(extracted_values[2], PolygonExtractedValue::Log(_)); assert_matches!(extracted_values[3], PolygonExtractedValue::Log(_)); } + +/// Polygon mainnet's chain id, as shipped in `expected_network_fingerprint`. +const EXPECTED_NETWORK_FINGERPRINT: &str = "137"; + +#[tokio::test] +#[ignore = "manual test to sanity check against live Polygon RPC provider"] +async fn network_fingerprint_matches_the_shipped_config_value_against_live_rpc_provider() { + // given + let http_client = foreign_chain_inspector::build_http_client( + POLYGON_RPC_URL.to_string(), + RpcAuthentication::KeyInUrl, + ) + .unwrap(); + let inspector = PolygonInspector::new(http_client); + + // when + let fingerprint = inspector + .network_fingerprint() + .await + .expect("network_fingerprint should succeed"); + + // then + assert_eq!(fingerprint.to_string(), EXPECTED_NETWORK_FINGERPRINT); +} diff --git a/crates/foreign-chain-rpc-interfaces/src/evm.rs b/crates/foreign-chain-rpc-interfaces/src/evm.rs index d7c364d0d7..a43a735ca8 100644 --- a/crates/foreign-chain-rpc-interfaces/src/evm.rs +++ b/crates/foreign-chain-rpc-interfaces/src/evm.rs @@ -6,6 +6,8 @@ use serde::{Deserialize, Serialize}; pub use ethereum_types::{H160, H256, U64}; +use ethereum_types::U256; + /// Partial RPC response for `eth_getTransactionReceipt`. /// #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] @@ -100,6 +102,37 @@ pub struct Log { pub topics: Vec, } +/// RPC response for `eth_chainId`: the EIP-155 chain id as a hex quantity. +/// +/// +/// Kept as text rather than parsed into a [`U64`]. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)] +#[serde(transparent)] +pub struct ChainIdResponse(pub String); + +impl ChainIdResponse { + /// Decimal, the form EIP-155 chain ids are published in. A `0x` prefix reads as hex, anything + /// else as decimal; text that is neither is returned unchanged. + pub fn canonical_text(self) -> String { + let hex = self + .0 + .strip_prefix("0x") + .or_else(|| self.0.strip_prefix("0X")); + let (digits, radix) = match hex { + Some(digits) => (digits, 16), + None => (self.0.as_str(), 10), + }; + // Empty digits parse as zero, which would report a bare `0x` as chain 0. + if digits.is_empty() { + return self.0; + } + match U256::from_str_radix(digits, radix) { + Ok(chain_id) => chain_id.to_string(), + Err(_) => self.0, + } + } +} + impl ToRpcParams for &GetTransactionReceiptARgs { to_rpc_params_impl!(); } @@ -107,3 +140,41 @@ impl ToRpcParams for &GetTransactionReceiptARgs { impl ToRpcParams for &GetBlockByNumberArgs { to_rpc_params_impl!(); } + +#[cfg(test)] +#[expect(non_snake_case)] +mod tests { + use super::ChainIdResponse; + use rstest::rstest; + + /// Base mainnet. + const CHAIN_ID_8453: &str = "0x2105"; + + #[rstest] + #[case::hex(CHAIN_ID_8453, "8453")] + // Padded and upper-cased, as a provider may send it. Arbitrum, whose id has hex letters. + #[case::padded("0x002105", "8453")] + #[case::upper_cased_digits("0xA4B1", "42161")] + // Spellings only an operator writes: the published decimal, and an upper-cased prefix. + #[case::decimal("8453", "8453")] + #[case::upper_cased_prefix("0X2105", "8453")] + #[case::zero("0x0", "0")] + // Wider than a u64, which EIP-155 permits. + #[case::wider_than_a_u64("0x1ffffffffffffffff", "36893488147419103231")] + // Reported as answered by the provider. + #[case::not_a_number("mainnet", "mainnet")] + #[case::empty_hex("0x", "0x")] + fn chain_id_response__should_canonicalize_what_a_provider_answers( + #[case] answered: &str, + #[case] expected: &str, + ) { + // Given + let json = serde_json::json!(answered); + + // When + let response: ChainIdResponse = serde_json::from_value(json).unwrap(); + + // Then + assert_eq!(response.canonical_text(), expected); + } +} diff --git a/docs/foreign-chain-transactions.md b/docs/foreign-chain-transactions.md index 4975589a3c..6642fcf915 100644 --- a/docs/foreign-chain-transactions.md +++ b/docs/foreign-chain-transactions.md @@ -534,8 +534,11 @@ Not every chain has a fingerprint probe. The table lists the ones that do, with | chain | probe | |---|---| | starknet | `starknet_chainId` | +| base, bnb, arbitrum, polygon, hyper_evm, abstract | `eth_chainId` | -Starknet's fingerprint is the chain id felt in lowercase `0x` hex without leading zeros. Both providers and operators are free to pad and upper-case it, so the reported and the configured value are normalized before they are compared. +The reported and the configured value are normalized before they are compared, because the same fingerprint has several legal spellings. Starknet's is the chain id felt in lowercase `0x` hex without leading zeros, which providers and operators alike are free to pad and upper-case. The EVM chain id is compared in decimal, the form it is published and configured in, while `eth_chainId` answers a `0x` hex quantity. + +An answer that is no fingerprint at all is reported as the wrong network, carrying the text the provider sent, so the report says what was actually claimed. An answer longer than any real fingerprint is cut short and ends in `_TRUNCATED`, because it is repeated into logs and metric labels. #### Why drop-and-log on local-config mismatch, not hard-crash @@ -691,10 +694,11 @@ The fingerprint is set per chain rather than once per deployment, so a config ca each value must match the network of the `rpc_url` beside it. The value is always a quoted string, including the fingerprints that look numeric. -Only the chains with a fingerprint probe read the field at all — starknet today, the rest as their -probes are written. For those chains, leaving it unset is not a silent skip: every provider of the -chain is reported as `MissingExpectedFingerprint`, because silence reads as healthy on a dashboard. A -chain with no probe yet reports `ProbeNotImplemented` whether the field is set or not. +Only the chains with a fingerprint probe read the field at all — starknet and the EVM chains today, +the rest as their probes are written. For those chains, leaving it unset is not a silent skip: every +provider of the chain is reported as `MissingExpectedFingerprint`, because silence reads as healthy +on a dashboard. A chain with no probe yet reports `ProbeNotImplemented` whether the field is set or +not. ## Risks