diff --git a/crates/e2e-tests/tests/foreign_chain_configuration.rs b/crates/e2e-tests/tests/foreign_chain_configuration.rs index 517b0edd91..6056d483dd 100644 --- a/crates/e2e-tests/tests/foreign_chain_configuration.rs +++ b/crates/e2e-tests/tests/foreign_chain_configuration.rs @@ -18,6 +18,7 @@ const SOLANA_RPC_URL: &str = "https://rpc.public.example.com"; fn solana_foreign_chains_config() -> ForeignChainsConfig { ForeignChainsConfig { solana: Some(ForeignChainConfig { + expected_chain_id: None, timeout_sec: NonZeroU64::new(30).unwrap(), max_retries: NonZeroU64::new(3).unwrap(), providers: NonEmptyBTreeMap::new( diff --git a/crates/e2e-tests/tests/foreign_chain_tx_validation.rs b/crates/e2e-tests/tests/foreign_chain_tx_validation.rs index 08f3cfb353..813df8afc8 100644 --- a/crates/e2e-tests/tests/foreign_chain_tx_validation.rs +++ b/crates/e2e-tests/tests/foreign_chain_tx_validation.rs @@ -57,6 +57,7 @@ struct MockServerUrls { fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig { ForeignChainsConfig { bitcoin: Some(ForeignChainConfig { + expected_chain_id: None, timeout_sec: NonZeroU64::new(30).unwrap(), max_retries: NonZeroU64::new(3).unwrap(), providers: NonEmptyBTreeMap::new( @@ -73,6 +74,7 @@ fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig { ), }), abstract_chain: Some(ForeignChainConfig { + expected_chain_id: None, timeout_sec: NonZeroU64::new(30).unwrap(), max_retries: NonZeroU64::new(3).unwrap(), providers: NonEmptyBTreeMap::new( @@ -84,6 +86,7 @@ fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig { ), }), bnb: Some(ForeignChainConfig { + expected_chain_id: None, timeout_sec: NonZeroU64::new(30).unwrap(), max_retries: NonZeroU64::new(3).unwrap(), providers: NonEmptyBTreeMap::new( @@ -100,6 +103,7 @@ fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig { ), }), starknet: Some(ForeignChainConfig { + expected_chain_id: None, timeout_sec: NonZeroU64::new(30).unwrap(), max_retries: NonZeroU64::new(3).unwrap(), providers: NonEmptyBTreeMap::new( @@ -111,6 +115,7 @@ fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig { ), }), base: Some(ForeignChainConfig { + expected_chain_id: None, timeout_sec: NonZeroU64::new(30).unwrap(), max_retries: NonZeroU64::new(3).unwrap(), providers: NonEmptyBTreeMap::new( @@ -128,6 +133,7 @@ fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig { ), }), arbitrum: Some(ForeignChainConfig { + expected_chain_id: None, timeout_sec: NonZeroU64::new(30).unwrap(), max_retries: NonZeroU64::new(3).unwrap(), providers: NonEmptyBTreeMap::new( @@ -139,6 +145,7 @@ fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig { ), }), hyper_evm: Some(ForeignChainConfig { + expected_chain_id: None, timeout_sec: NonZeroU64::new(30).unwrap(), max_retries: NonZeroU64::new(3).unwrap(), providers: NonEmptyBTreeMap::new( @@ -150,6 +157,7 @@ fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig { ), }), polygon: Some(ForeignChainConfig { + expected_chain_id: None, timeout_sec: NonZeroU64::new(30).unwrap(), max_retries: NonZeroU64::new(3).unwrap(), providers: common::build_providers_from_urls(&urls.polygon, "polygon"), diff --git a/crates/foreign-chain-health-check/src/lib.rs b/crates/foreign-chain-health-check/src/lib.rs index cc63e9d88f..69a963ed22 100644 --- a/crates/foreign-chain-health-check/src/lib.rs +++ b/crates/foreign-chain-health-check/src/lib.rs @@ -357,6 +357,7 @@ mod tests { fn config_with_provider(auth: AuthConfig) -> ForeignChainConfig { ForeignChainConfig { + expected_chain_id: None, timeout_sec: NonZeroU64::new(5).unwrap(), max_retries: NonZeroU64::new(1).unwrap(), providers: NonEmptyBTreeMap::new( @@ -515,6 +516,7 @@ mod tests { ); let fc = ForeignChainsConfig { aptos: Some(ForeignChainConfig { + expected_chain_id: None, timeout_sec: NonZeroU64::new(5).unwrap(), max_retries: NonZeroU64::new(1).unwrap(), providers, diff --git a/crates/foreign-chain-inspector/src/lib.rs b/crates/foreign-chain-inspector/src/lib.rs index 26c60fef74..f04e402253 100644 --- a/crates/foreign-chain-inspector/src/lib.rs +++ b/crates/foreign-chain-inspector/src/lib.rs @@ -35,6 +35,19 @@ pub trait ForeignChainInspector { ) -> impl Future, ForeignChainInspectionError>> + Send; } +/// The network a provider is serving, as the chain natively reports it. +/// +/// The returned value is opaque config data: it is only ever compared verbatim against the +/// operator's configured expectation, never computed with. Implementations must therefore +/// return the chain's canonical text form (e.g. Starknet's chain-id felt as lowercase +/// `0x`-prefixed hex without leading zeros), normalizing any formatting freedom the RPC +/// leaves to providers. +pub trait ChainIdentity { + fn chain_identity( + &self, + ) -> impl Future> + Send; +} + /// Combines multiple inspectors that target the same chain into a single inspector. /// /// All inner inspectors are queried concurrently. The fan-out treats every @@ -168,6 +181,29 @@ where } } +impl FanOut +where + Inspector: ChainIdentity + Clone + Send + Sync + 'static, +{ + /// Queries every inner inspector's [`ChainIdentity`] concurrently. + /// + /// Unlike [`FanOut::extract`], results are not folded into a single verdict: callers + /// diagnosing a misconfigured provider need to know *which* one is wrong, so this + /// returns one entry per inner inspector, in the order the inspectors were passed to + /// [`FanOut::new`]. + pub async fn chain_identities(&self) -> Vec> { + let mut join_set = tokio::task::JoinSet::new(); + for (idx, inspector) in self.inspectors.iter().enumerate() { + let inspector = inspector.clone(); + join_set.spawn(async move { (idx, inspector.chain_identity().await) }); + } + + let mut results = join_set.join_all().await; + results.sort_by_key(|(idx, _)| *idx); + results.into_iter().map(|(_, identity)| identity).collect() + } +} + #[derive(Debug, Clone)] pub enum RpcAuthentication { /// The key is in the URL (e.g., Alchemy, QuickNode). diff --git a/crates/foreign-chain-inspector/src/starknet/inspector.rs b/crates/foreign-chain-inspector/src/starknet/inspector.rs index 4f33844852..075a255327 100644 --- a/crates/foreign-chain-inspector/src/starknet/inspector.rs +++ b/crates/foreign-chain-inspector/src/starknet/inspector.rs @@ -1,14 +1,16 @@ use crate::starknet::{StarknetExtractedValue, StarknetTransactionHash}; -use crate::{ForeignChainInspectionError, ForeignChainInspector}; +use crate::{ChainIdentity, ForeignChainInspectionError, ForeignChainInspector}; use foreign_chain_rpc_interfaces::starknet::{ - BlockId, GetBlockWithTxHashesArgs, GetBlockWithTxHashesResponse, GetTransactionReceiptArgs, - GetTransactionReceiptResponse, H256, StarknetExecutionStatus, StarknetFinalityStatus, + BlockId, ChainIdArgs, ChainIdResponse, GetBlockWithTxHashesArgs, GetBlockWithTxHashesResponse, + GetTransactionReceiptArgs, GetTransactionReceiptResponse, H256, StarknetExecutionStatus, + StarknetFinalityStatus, }; use jsonrpsee::core::client::ClientT; 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"; #[derive(Clone)] pub struct StarknetInspector { @@ -72,6 +74,16 @@ where } } +impl ChainIdentity for StarknetInspector +where + Client: ClientT + Send + Sync, +{ + async fn chain_identity(&self) -> Result { + let response: ChainIdResponse = self.client.request(CHAIN_ID_METHOD, &ChainIdArgs).await?; + Ok(response.0) + } +} + impl StarknetInspector where Client: ClientT + Send + Sync, diff --git a/crates/foreign-chain-inspector/tests/fanout.rs b/crates/foreign-chain-inspector/tests/fanout.rs index 0a237d721f..28d35c22bf 100644 --- a/crates/foreign-chain-inspector/tests/fanout.rs +++ b/crates/foreign-chain-inspector/tests/fanout.rs @@ -12,7 +12,7 @@ use std::{io, sync::Arc}; use assert_matches::assert_matches; use foreign_chain_inspector::{ - BlockConfirmations, FanOut, ForeignChainInspectionError, ForeignChainInspector, + BlockConfirmations, ChainIdentity, FanOut, ForeignChainInspectionError, ForeignChainInspector, }; use near_mpc_bounded_collections::NonEmptyVec; @@ -482,6 +482,64 @@ mod all_transient { } } +mod chain_identities { + use super::*; + + /// Hand-rolled rather than mockall-generated: `FanOut::chain_identities` clones the + /// inspector into a spawned task, and a shared closure survives the clone without + /// re-wiring expectations. + #[derive(Clone)] + struct FixedIdentityInspector( + Arc Result + Send + Sync>, + ); + + impl FixedIdentityInspector { + fn ok(identity: &str) -> Self { + let identity = identity.to_string(); + Self(Arc::new(move || Ok(identity.clone()))) + } + + fn unreachable() -> Self { + Self(Arc::new(|| { + Err(ForeignChainInspectionError::RpcRequestFailed( + "timeout".to_string(), + )) + })) + } + } + + impl ChainIdentity for FixedIdentityInspector { + async fn chain_identity(&self) -> Result { + (self.0)() + } + } + + #[tokio::test] + async fn chain_identities__should_return_one_result_per_inspector_in_input_order() { + // Given + let fan_out = FanOut::new( + NonEmptyVec::try_from(vec![ + FixedIdentityInspector::ok("0x534e5f4d41494e"), + FixedIdentityInspector::unreachable(), + FixedIdentityInspector::ok("0x534e5f5345504f4c4941"), + ]) + .expect("test must provide at least one inspector"), + ); + + // When + let identities = fan_out.chain_identities().await; + + // Then + assert_eq!(identities.len(), 3); + assert_matches!(&identities[0], Ok(id) if id == "0x534e5f4d41494e"); + assert_matches!( + &identities[1], + Err(ForeignChainInspectionError::RpcRequestFailed(_)) + ); + assert_matches!(&identities[2], Ok(id) if id == "0x534e5f5345504f4c4941"); + } +} + mod tolerate_transient { use super::*; diff --git a/crates/foreign-chain-inspector/tests/starknet_inspector.rs b/crates/foreign-chain-inspector/tests/starknet_inspector.rs index 80caef9552..81060b057d 100644 --- a/crates/foreign-chain-inspector/tests/starknet_inspector.rs +++ b/crates/foreign-chain-inspector/tests/starknet_inspector.rs @@ -7,7 +7,8 @@ use crate::common::{ }; use foreign_chain_inspector::{ - ForeignChainInspectionError, ForeignChainInspector, RpcAuthentication, build_http_client, + ChainIdentity, ForeignChainInspectionError, ForeignChainInspector, RpcAuthentication, + build_http_client, starknet::{ StarknetBlockHash, StarknetExtractedValue, StarknetTransactionHash, inspector::{StarknetExtractor, StarknetFinality, StarknetInspector}, @@ -314,6 +315,40 @@ async fn extract__should_return_correct_log_for_specific_index() { ); } +#[tokio::test] +async fn chain_identity__should_return_canonical_chain_id() { + // given: SN_MAIN's chain id, padded and uppercased the way a provider is free to send it. + let mock_client = mock_client_from_fixed_response("0x00534E5F4D41494E"); + let inspector = StarknetInspector::new(mock_client); + + // when + let identity = inspector + .chain_identity() + .await + .expect("chain_identity should succeed"); + + // then + assert_eq!(identity, "0x534e5f4d41494e"); +} + +#[tokio::test] +async fn chain_identity__should_propagate_rpc_client_errors() { + // given + let mock_client = FixedResponseRpcClient::new(|| { + Err(RpcClientError::Transport(Box::new(std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + "connection refused", + )))) + }); + let inspector = StarknetInspector::new(mock_client); + + // when + let response = inspector.chain_identity().await; + + // then + assert_matches!(response, Err(ForeignChainInspectionError::ClientError(_))); +} + fn test_receipt() -> GetTransactionReceiptResponse { let mut block_hash_bytes = [0u8; 32]; block_hash_bytes[31] = 5; diff --git a/crates/foreign-chain-rpc-interfaces/src/starknet.rs b/crates/foreign-chain-rpc-interfaces/src/starknet.rs index edb5758c54..36b8807201 100644 --- a/crates/foreign-chain-rpc-interfaces/src/starknet.rs +++ b/crates/foreign-chain-rpc-interfaces/src/starknet.rs @@ -62,6 +62,51 @@ impl ToRpcParams for &GetTransactionReceiptArgs { to_rpc_params_impl!(); } +/// Request args for `starknet_chainId` (the method takes no parameters). +pub struct ChainIdArgs; + +impl Serialize for ChainIdArgs { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + // `starknet_chainId` expects an empty parameter array: [] + let request_parameters: [(); 0] = []; + request_parameters.serialize(serializer) + } +} + +impl ToRpcParams for &ChainIdArgs { + to_rpc_params_impl!(); +} + +/// RPC response for `starknet_chainId`: the felt identifying the network, in its canonical +/// text form — lowercase, `0x`-prefixed, no leading zeros (e.g. `0x534e5f4d41494e` for +/// `SN_MAIN`). Deserialization normalizes the provider's felt formatting, so equal chain +/// ids always compare equal as strings. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct ChainIdResponse( + #[serde(deserialize_with = "deserialize_canonical_felt_text")] pub String, +); + +fn deserialize_canonical_felt_text<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let felt = deserialize_starknet_felt(deserializer)?; + Ok(canonical_felt_text(felt)) +} + +fn canonical_felt_text(felt: H256) -> String { + let hex = format!("{felt:x}"); + let digits = hex.trim_start_matches('0'); + if digits.is_empty() { + "0x0".to_string() + } else { + format!("0x{digits}") + } +} + /// Block identifier accepted by Starknet block-lookup RPCs. The inspector's canonical-chain /// check uses `Number`; add more variants only when a caller actually needs them. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] @@ -136,8 +181,9 @@ where #[expect(non_snake_case)] mod tests { use super::{ - BlockId, GetBlockWithTxHashesArgs, GetBlockWithTxHashesResponse, - GetTransactionReceiptResponse, StarknetExecutionStatus, StarknetFinalityStatus, parse_felt, + BlockId, ChainIdArgs, ChainIdResponse, GetBlockWithTxHashesArgs, + GetBlockWithTxHashesResponse, GetTransactionReceiptResponse, StarknetExecutionStatus, + StarknetFinalityStatus, parse_felt, }; const TEST_BLOCK_NUMBER: u64 = 842_750; @@ -196,6 +242,66 @@ mod tests { ); } + #[test] + fn serialize_chain_id_args__should_produce_empty_array() { + // Given + let args = ChainIdArgs; + + // When + let serialized = serde_json::to_value(&args).unwrap(); + + // Then + assert_eq!(serialized, serde_json::json!([])); + } + + #[test] + fn deserialize_chain_id_response__should_keep_canonical_felt_unchanged() { + // Given + let json = serde_json::json!("0x534e5f4d41494e"); + + // When + let response: ChainIdResponse = serde_json::from_value(json).unwrap(); + + // Then + assert_eq!(response.0, "0x534e5f4d41494e"); + } + + #[test] + fn deserialize_chain_id_response__should_normalize_padded_uppercase_felt() { + // Given + let json = serde_json::json!("0x00534E5F4D41494E"); + + // When + let response: ChainIdResponse = serde_json::from_value(json).unwrap(); + + // Then + assert_eq!(response.0, "0x534e5f4d41494e"); + } + + #[test] + fn deserialize_chain_id_response__should_normalize_zero_felt() { + // Given + let json = serde_json::json!("0x0000"); + + // When + let response: ChainIdResponse = serde_json::from_value(json).unwrap(); + + // Then + assert_eq!(response.0, "0x0"); + } + + #[test] + fn deserialize_chain_id_response__should_reject_non_felt_string() { + // Given + let json = serde_json::json!("SN_MAIN"); + + // When + let result: Result = serde_json::from_value(json); + + // Then + result.expect_err("a non-hex chain id must fail deserialization"); + } + #[test] fn serialize_get_block_with_tx_hashes_args__should_wrap_block_id_in_array() { // given diff --git a/crates/node-config/src/foreign_chains.rs b/crates/node-config/src/foreign_chains.rs index a063be04af..bec8cfea57 100644 --- a/crates/node-config/src/foreign_chains.rs +++ b/crates/node-config/src/foreign_chains.rs @@ -46,6 +46,12 @@ pub struct ForeignChainsConfig { pub struct ForeignChainConfig { pub timeout_sec: NonZeroU64, pub max_retries: NonZeroU64, + /// The network identity every provider of this chain is expected to report, in the + /// chain's canonical text form (e.g. `0x534e5f4d41494e` — `SN_MAIN` — for Starknet + /// mainnet). When set, the node checks each provider against it at startup and logs + /// any divergence; when absent, the check is skipped. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_chain_id: Option, pub providers: NonEmptyBTreeMap, } @@ -514,6 +520,70 @@ foreign_chains: assert!(config.foreign_chains.starknet.is_some()); } + #[test] + fn config_parsing__should_accept_expected_chain_id() { + // Given + let yaml = r#" +my_near_account_id: test.near +near_responder_account_id: test.near +number_of_responder_keys: 1 +web_ui: + host: localhost + port: 8080 +migration_web_ui: + host: localhost + port: 8081 +pprof_bind_address: 127.0.0.1:34001 +indexer: + validate_genesis: false + sync_mode: Latest + finality: optimistic + concurrency: 1 + mpc_contract_id: mpc-contract.test.near +triple: + concurrency: 1 + desired_triples_to_buffer: 1 + timeout_sec: 60 + parallel_triple_generation_stagger_time_sec: 1 +presignature: + concurrency: 1 + desired_presignatures_to_buffer: 1 + timeout_sec: 60 +signature: + timeout_sec: 60 +ckd: + timeout_sec: 60 +foreign_chains: + starknet: + timeout_sec: 30 + max_retries: 3 + expected_chain_id: "0x534e5f4d41494e" + providers: + blast: + rpc_url: "https://starknet-mainnet.blastapi.io/" + auth: + kind: none +"#; + + // When + let config: ConfigFile = + serde_yaml::from_str(yaml).expect("yaml fixture should be correct"); + + // Then + config + .validate() + .expect("config with expected_chain_id should be valid"); + let starknet = config + .foreign_chains + .starknet + .as_ref() + .expect("starknet config should be present"); + assert_eq!( + starknet.expected_chain_id.as_deref(), + Some("0x534e5f4d41494e") + ); + } + #[test] fn configured_chains__should_preserve_url_for_non_path_auth() { // Given diff --git a/crates/node/src/coordinator.rs b/crates/node/src/coordinator.rs index 26eb1bfb86..810c316f3a 100644 --- a/crates/node/src/coordinator.rs +++ b/crates/node/src/coordinator.rs @@ -1294,6 +1294,7 @@ mod tests { // Given: a node config covering Solana. let foreign_chains = ForeignChainsConfig { solana: Some(ForeignChainConfig { + expected_chain_id: None, timeout_sec: NonZeroU64::new(30).unwrap(), max_retries: NonZeroU64::new(3).unwrap(), providers: near_mpc_bounded_collections::NonEmptyBTreeMap::new( diff --git a/crates/node/src/foreign_chain_identity_verifier.rs b/crates/node/src/foreign_chain_identity_verifier.rs new file mode 100644 index 0000000000..76ac67818c --- /dev/null +++ b/crates/node/src/foreign_chain_identity_verifier.rs @@ -0,0 +1,255 @@ +//! Log-only startup check that every configured foreign-chain RPC provider serves the +//! network the operator expects (`expected_chain_id` in the chain's config). +//! +//! A provider pointed at the wrong network (right chain, wrong network — e.g. Sepolia +//! instead of mainnet) answers RPCs normally, so nothing fails until a real verification +//! request arrives. At that point the misconfigured provider disagrees with its healthy +//! siblings and [`foreign_chain_inspector::FanOut`] rejects the whole chain with +//! [`ForeignChainInspectionError::InspectorResponseMismatch`] — an outage, not a warning. +//! This check surfaces the misconfiguration at boot instead, naming the provider. + +use std::time::Duration; + +use foreign_chain_inspector::http_client::HttpClient; +use foreign_chain_inspector::{ChainIdentity, FanOut, ForeignChainInspectionError}; +use mpc_node_config::foreign_chains::RpcProviderName; +use mpc_node_config::{ForeignChainConfig, ForeignChainsConfig}; +use near_mpc_contract_interface::types as dtos; + +use crate::providers::verify_foreign_tx::ForeignChainInspectors; + +/// One-shot check of every chain that has an `expected_chain_id` configured. +/// +/// TODO(#3764): covers Starknet only; extend as the other inspectors implement +/// [`ChainIdentity`]. +pub(crate) async fn run(config: ForeignChainsConfig) { + let inspectors: ForeignChainInspectors = + match ForeignChainInspectors::build(&config) { + Ok(inspectors) => inspectors, + Err(error) => { + tracing::warn!( + ?error, + "foreign-chain identity check: failed to build inspectors" + ); + return; + } + }; + + if let (Some(chain_config), Some(chain_inspectors)) = (&config.starknet, &inspectors.starknet) { + check_chain(dtos::ForeignChain::Starknet, chain_config, chain_inspectors).await; + } +} + +async fn check_chain( + chain: dtos::ForeignChain, + config: &ForeignChainConfig, + inspectors: &FanOut, +) where + Inspector: ChainIdentity + Clone + Send + Sync + 'static, +{ + let Some(expected) = config.expected_chain_id.as_deref() else { + tracing::info!( + ?chain, + "foreign-chain identity check skipped: no expected_chain_id configured" + ); + return; + }; + + let timeout = Duration::from_secs(config.timeout_sec.get()); + let identities = match tokio::time::timeout(timeout, inspectors.chain_identities()).await { + Ok(identities) => identities, + Err(_) => { + tracing::warn!(?chain, ?timeout, "foreign-chain identity check timed out"); + return; + } + }; + + // `ForeignChainInspectors` builds the fan-out in provider config order, so zipping + // against the provider map restores each result's provider name. + let observed: Vec<(RpcProviderName, Result)> = + config.providers.keys().cloned().zip(identities).collect(); + + let diagnostics = compare_identities(chain, expected, &observed); + if diagnostics.is_empty() { + tracing::info!( + ?chain, + expected, + "foreign-chain identity check: all providers report the expected chain identity" + ); + } + for diagnostic in &diagnostics { + log_diagnostic(diagnostic); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct IdentityDiagnostic { + chain: dtos::ForeignChain, + provider: RpcProviderName, + kind: IdentityDiagnosticKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum IdentityDiagnosticKind { + /// The provider answered with a different network identity than configured: it serves + /// the wrong network and will break foreign-tx verification for this chain. + Mismatch { expected: String, observed: String }, + /// The provider could not be queried; possibly transient, nothing definitive to report. + Unreachable { error: String }, +} + +fn compare_identities( + chain: dtos::ForeignChain, + expected: &str, + observed: &[(RpcProviderName, Result)], +) -> Vec { + observed + .iter() + .filter_map(|(provider, result)| { + let kind = match result { + Ok(identity) if identity == expected => return None, + Ok(identity) => IdentityDiagnosticKind::Mismatch { + expected: expected.to_string(), + observed: identity.clone(), + }, + Err(error) => IdentityDiagnosticKind::Unreachable { + error: error.to_string(), + }, + }; + Some(IdentityDiagnostic { + chain, + provider: provider.clone(), + kind, + }) + }) + .collect() +} + +fn log_diagnostic(diagnostic: &IdentityDiagnostic) { + let chain = diagnostic.chain; + let provider = diagnostic.provider.as_str(); + match &diagnostic.kind { + IdentityDiagnosticKind::Mismatch { expected, observed } => { + tracing::error!( + ?chain, + provider, + expected, + observed, + "foreign-chain provider serves the wrong network" + ); + } + IdentityDiagnosticKind::Unreachable { error } => { + tracing::warn!( + ?chain, + provider, + error, + "foreign-chain provider unreachable during identity check" + ); + } + } +} + +#[cfg(test)] +#[expect(non_snake_case)] +mod tests { + use super::*; + use assert_matches::assert_matches; + + const SN_MAIN: &str = "0x534e5f4d41494e"; + const SN_SEPOLIA: &str = "0x534e5f5345504f4c4941"; + + fn provider(name: &str) -> RpcProviderName { + RpcProviderName::from(name.to_string()) + } + + #[test] + fn compare_identities__should_be_empty_when_all_providers_match() { + // Given + let observed = vec![ + (provider("alchemy"), Ok(SN_MAIN.to_string())), + (provider("blast"), Ok(SN_MAIN.to_string())), + ]; + + // When + let diagnostics = compare_identities(dtos::ForeignChain::Starknet, SN_MAIN, &observed); + + // Then + assert!( + diagnostics.is_empty(), + "expected no diagnostics, got: {diagnostics:?}" + ); + } + + #[test] + fn compare_identities__should_emit_mismatch_naming_the_wrong_provider() { + // Given: "blast" is configured against Sepolia instead of mainnet. + let observed = vec![ + (provider("alchemy"), Ok(SN_MAIN.to_string())), + (provider("blast"), Ok(SN_SEPOLIA.to_string())), + ]; + + // When + let diagnostics = compare_identities(dtos::ForeignChain::Starknet, SN_MAIN, &observed); + + // Then + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].provider, provider("blast")); + assert_matches!( + &diagnostics[0].kind, + IdentityDiagnosticKind::Mismatch { expected, observed } + if expected == SN_MAIN && observed == SN_SEPOLIA + ); + } + + #[test] + fn compare_identities__should_emit_unreachable_when_provider_errors() { + // Given + let observed = vec![( + provider("alchemy"), + Err(ForeignChainInspectionError::RpcRequestFailed( + "timeout".to_string(), + )), + )]; + + // When + let diagnostics = compare_identities(dtos::ForeignChain::Starknet, SN_MAIN, &observed); + + // Then + assert_eq!(diagnostics.len(), 1); + assert_matches!( + &diagnostics[0].kind, + IdentityDiagnosticKind::Unreachable { .. } + ); + } + + #[test] + fn compare_identities__should_report_each_provider_independently() { + // Given: one healthy, one on the wrong network, one unreachable. + let observed = vec![ + (provider("alchemy"), Ok(SN_MAIN.to_string())), + (provider("blast"), Ok(SN_SEPOLIA.to_string())), + ( + provider("quicknode"), + Err(ForeignChainInspectionError::RpcRequestFailed( + "connection refused".to_string(), + )), + ), + ]; + + // When + let diagnostics = compare_identities(dtos::ForeignChain::Starknet, SN_MAIN, &observed); + + // Then + assert_eq!(diagnostics.len(), 2); + assert_matches!( + &diagnostics[0], + IdentityDiagnostic { provider, kind: IdentityDiagnosticKind::Mismatch { .. }, .. } + if *provider == RpcProviderName::from("blast".to_string()) + ); + assert_matches!( + &diagnostics[1], + IdentityDiagnostic { provider, kind: IdentityDiagnosticKind::Unreachable { .. }, .. } + if *provider == RpcProviderName::from("quicknode".to_string()) + ); + } +} diff --git a/crates/node/src/foreign_chain_whitelist_verifier.rs b/crates/node/src/foreign_chain_whitelist_verifier.rs index 98981fd870..d82cb9cc13 100644 --- a/crates/node/src/foreign_chain_whitelist_verifier.rs +++ b/crates/node/src/foreign_chain_whitelist_verifier.rs @@ -374,6 +374,7 @@ mod tests { .map(|(name, cfg)| (RpcProviderName::from(name.to_string()), cfg.clone())) .collect(); ForeignChainConfig { + expected_chain_id: None, timeout_sec: std::num::NonZeroU64::new(30).unwrap(), max_retries: std::num::NonZeroU64::new(3).unwrap(), providers: NonEmptyBTreeMap::try_from(map) diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index 890277443f..5397ac4bc0 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -28,6 +28,7 @@ pub mod cli; pub mod config; mod coordinator; mod db; +mod foreign_chain_identity_verifier; mod foreign_chain_whitelist_verifier; mod home_paths; mod indexer; diff --git a/crates/node/src/providers/verify_foreign_tx.rs b/crates/node/src/providers/verify_foreign_tx.rs index 55f35f8c90..7653ca55d9 100644 --- a/crates/node/src/providers/verify_foreign_tx.rs +++ b/crates/node/src/providers/verify_foreign_tx.rs @@ -43,7 +43,7 @@ pub(crate) struct ForeignChainInspectors { } impl ForeignChainInspectors { - fn build(config: &ForeignChainsConfig) -> anyhow::Result { + pub(crate) fn build(config: &ForeignChainsConfig) -> anyhow::Result { fn build_fanout( chain_config: Option<&ForeignChainConfig>, new_inspector: impl Fn(String, RpcAuthentication, Duration) -> anyhow::Result, diff --git a/crates/node/src/run.rs b/crates/node/src/run.rs index a3f0a8d9c8..9e4fb858e5 100644 --- a/crates/node/src/run.rs +++ b/crates/node/src/run.rs @@ -209,6 +209,10 @@ pub async fn run_mpc_node(config: StartConfig) -> anyhow::Result<()> { let _web_server_join_handle = root_runtime.spawn(web_server); + root_runtime.spawn(crate::foreign_chain_identity_verifier::run( + node_config.foreign_chains.clone(), + )); + // Create Indexer and wait for indexer to be synced. let (indexer_exit_sender, indexer_exit_receiver) = oneshot::channel(); // Dedicated cancellation token for the indexer thread. Cancelled after diff --git a/crates/node/src/tests/foreign_chain_configuration.rs b/crates/node/src/tests/foreign_chain_configuration.rs index 457d25777e..ce269c98f7 100644 --- a/crates/node/src/tests/foreign_chain_configuration.rs +++ b/crates/node/src/tests/foreign_chain_configuration.rs @@ -44,6 +44,7 @@ async fn foreign_chain_configuration_auto_registered_to_contract_on_startup__sho let foreign_chains = ForeignChainsConfig { solana: Some(ForeignChainConfig { + expected_chain_id: None, timeout_sec: NonZeroU64::new(30).unwrap(), max_retries: NonZeroU64::new(3).unwrap(), providers, diff --git a/crates/node/src/web.rs b/crates/node/src/web.rs index 1ad580f54d..08262f8998 100644 --- a/crates/node/src/web.rs +++ b/crates/node/src/web.rs @@ -418,6 +418,7 @@ mod tests { fn test_chain(provider_name: &str, rpc_url: &str, auth: AuthConfig) -> ForeignChainConfig { ForeignChainConfig { + expected_chain_id: None, timeout_sec: NonZeroU64::new(30).unwrap(), max_retries: NonZeroU64::new(3).unwrap(), providers: NonEmptyBTreeMap::new( diff --git a/deployment/cvm-deployment/user-config.toml b/deployment/cvm-deployment/user-config.toml index 46d33f75e9..7205d4e586 100644 --- a/deployment/cvm-deployment/user-config.toml +++ b/deployment/cvm-deployment/user-config.toml @@ -145,6 +145,7 @@ rpc_url = "https://api.testnet.abs.xyz" [mpc_node_config.node.foreign_chains.starknet] timeout_sec = 30 max_retries = 3 +expected_chain_id = "0x534e5f5345504f4c4941" # SN_SEPOLIA [mpc_node_config.node.foreign_chains.starknet.providers.publicnode] rpc_url = "https://starknet-sepolia-rpc.publicnode.com" diff --git a/docs/foreign-chain-transactions.md b/docs/foreign-chain-transactions.md index 219a5c0f65..e25b66a47f 100644 --- a/docs/foreign-chain-transactions.md +++ b/docs/foreign-chain-transactions.md @@ -647,6 +647,13 @@ Auth variants are explicitly modeled because providers differ in how they expect to be supplied (e.g., bearer tokens, custom headers, query params, or URL path tokens), and some providers require no auth at all. +A chain section may also set `expected_chain_id` — the network identity every provider of that +chain is expected to report, in the chain's canonical text form (e.g. `"0x534e5f4d41494e"`, +`SN_MAIN`, for Starknet mainnet). When set, the node queries each provider's identity at startup +and logs an error for any provider serving a different network (a common misconfiguration: +right chain, wrong network, which would otherwise surface only as failed verifications). The +check is log-only and skipped when the field is absent. Currently implemented for Starknet. + ## Risks * **RPC trust and correctness**: Verification relies on centralized RPC providers. A malicious diff --git a/docs/localnet/mpc-config.template.toml b/docs/localnet/mpc-config.template.toml index eb28cbcf38..be7649e2fc 100644 --- a/docs/localnet/mpc-config.template.toml +++ b/docs/localnet/mpc-config.template.toml @@ -78,6 +78,7 @@ kind = "none" [node.foreign_chains.starknet] timeout_sec = 30 max_retries = 3 +expected_chain_id = "0x534e5f4d41494e" # SN_MAIN [node.foreign_chains.starknet.providers.public] rpc_url = "https://starknet-rpc.publicnode.com" diff --git a/docs/localnet/mpc-configs/config.yaml.template b/docs/localnet/mpc-configs/config.yaml.template index d8594248d6..c05930e5ef 100644 --- a/docs/localnet/mpc-configs/config.yaml.template +++ b/docs/localnet/mpc-configs/config.yaml.template @@ -48,6 +48,7 @@ foreign_chains: starknet: timeout_sec: 30 max_retries: 3 + expected_chain_id: "0x534e5f4d41494e" # SN_MAIN providers: public: rpc_url: "https://starknet-rpc.publicnode.com"