From 083d937f9891cb4714b75989ae323e179a7cd1ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5rten=20Blankfors?= Date: Mon, 27 Jul 2026 21:03:51 +0200 Subject: [PATCH] feat: RPC probe alternative implementation --- Cargo.lock | 1 + .../tests/foreign_chain_configuration.rs | 1 + .../tests/foreign_chain_tx_validation.rs | 8 + crates/foreign-chain-health-check/src/lib.rs | 2 + .../src/aptos/inspector.rs | 156 ++++-- .../src/bitcoin/inspector.rs | 27 +- .../src/chain_identity.rs | 54 ++ .../src/evm/inspector.rs | 22 +- crates/foreign-chain-inspector/src/lib.rs | 2 + .../src/starknet/inspector.rs | 27 +- .../src/sui/inspector.rs | 30 +- .../tests/bitcoin_inspector.rs | 20 +- .../tests/evm_inspector.rs | 45 +- .../tests/starknet_inspector.rs | 36 +- .../tests/sui_inspector.rs | 53 +- .../foreign-chain-rpc-interfaces/Cargo.toml | 1 + .../foreign-chain-rpc-interfaces/src/aptos.rs | 77 ++- .../foreign-chain-rpc-interfaces/src/lib.rs | 38 ++ .../src/starknet.rs | 59 +- crates/node-config/src/foreign_chains.rs | 43 +- crates/node/src/coordinator.rs | 1 + .../src/foreign_chain_identity_verifier.rs | 527 ++++++++++++++++++ .../src/foreign_chain_whitelist_verifier.rs | 1 + crates/node/src/indexer/real.rs | 3 + crates/node/src/lib.rs | 1 + .../src/tests/foreign_chain_configuration.rs | 1 + crates/node/src/web.rs | 1 + deployment/cvm-deployment/user-config.toml | 3 + docs/foreign-chain-transactions.md | 36 ++ docs/localnet/mpc-config.template.toml | 5 + .../localnet/mpc-configs/config.yaml.template | 5 + 31 files changed, 1214 insertions(+), 72 deletions(-) create mode 100644 crates/foreign-chain-inspector/src/chain_identity.rs create mode 100644 crates/node/src/foreign_chain_identity_verifier.rs diff --git a/Cargo.lock b/Cargo.lock index 0e0ef5be7d..0504ed7f13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3806,6 +3806,7 @@ dependencies = [ "jsonrpsee", "mpc-primitives", "reqwest 0.13.4", + "rstest", "serde", "serde_json", "sui-rpc", diff --git a/crates/e2e-tests/tests/foreign_chain_configuration.rs b/crates/e2e-tests/tests/foreign_chain_configuration.rs index 517b0edd91..27845f0d8f 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_identity: 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..2fb3a721e8 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_identity: 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_identity: 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_identity: 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_identity: 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_identity: 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_identity: 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_identity: 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_identity: 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..2f3442abeb 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_identity: 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_identity: None, timeout_sec: NonZeroU64::new(5).unwrap(), max_retries: NonZeroU64::new(1).unwrap(), providers, diff --git a/crates/foreign-chain-inspector/src/aptos/inspector.rs b/crates/foreign-chain-inspector/src/aptos/inspector.rs index d265a53051..061c9dec05 100644 --- a/crates/foreign-chain-inspector/src/aptos/inspector.rs +++ b/crates/foreign-chain-inspector/src/aptos/inspector.rs @@ -1,5 +1,8 @@ use crate::aptos::{AptosExtractedValue, AptosTransactionHash}; -use crate::{ForeignChainInspectionError, ForeignChainInspector, HexBytes}; +use crate::{ + ChainIdentity, ChainIdentityFuture, ChainIdentityProbe, ForeignChainInspectionError, + ForeignChainInspector, HexBytes, +}; use foreign_chain_rpc_interfaces::aptos::{ AptosRpcClient, AptosRpcError, TransactionResponse, normalize_event_data, }; @@ -49,29 +52,13 @@ where .client .get_transaction_by_hash(&tx_hash_hex) .await - .map_err(|e| { - let msg = e.to_string(); - match e { - // 404 = definitively absent → a non-transient verdict. - AptosRpcError::ApiError { status: 404, .. } => { - ForeignChainInspectionError::TransactionNotFound - } - // Rate limits and server errors are provider hiccups → transient, so the - // affected provider is dropped from the quorum instead of blocking it. - AptosRpcError::ApiError { - status: 408 | 429, .. - } => ForeignChainInspectionError::RpcRequestFailed(msg), - AptosRpcError::ApiError { status, .. } if status >= 500 => { - ForeignChainInspectionError::RpcRequestFailed(msg) - } - // Remaining 4xx (400/401/403/410, …) are deterministic rejections — - // retrying cannot change them, so they count as substantive verdicts. - AptosRpcError::ApiError { .. } => { - ForeignChainInspectionError::RpcRequestRejected(msg) - } - // Transport failures, including timeouts. - AptosRpcError::Http(_) => ForeignChainInspectionError::RpcRequestFailed(msg), + .map_err(|e| match e { + // 404 on the transaction endpoint = definitively absent → a non-transient + // verdict, rather than the generic "the provider rejected us". + AptosRpcError::ApiError { status: 404, .. } => { + ForeignChainInspectionError::TransactionNotFound } + other => classify_rest_error(other), })?; ensure_hash_matches(&tx_id, &tx.hash)?; @@ -103,6 +90,45 @@ where } } +/// Reports the ledger's `chain_id` in decimal — `1` on mainnet, `2` on testnet. +impl ChainIdentityProbe for AptosInspector +where + Client: AptosRpcClient, +{ + fn chain_identity(&self) -> ChainIdentityFuture<'_> { + Box::pin(async move { + let ledger_info = self + .client + .get_ledger_info() + .await + .map_err(classify_rest_error)?; + Ok(ChainIdentity::from(ledger_info.chain_id.to_string())) + }) + } +} + +/// Maps a REST failure onto the inspector error taxonomy. 404 is deliberately left to the +/// caller: it means "no such transaction" on the transaction endpoint but "wrong base URL" on +/// the ledger index. +fn classify_rest_error(error: AptosRpcError) -> ForeignChainInspectionError { + let message = error.to_string(); + match error { + // Rate limits and server errors are provider hiccups → transient, so the affected + // provider is dropped from the quorum instead of blocking it. + AptosRpcError::ApiError { + status: 408 | 429, .. + } => ForeignChainInspectionError::RpcRequestFailed(message), + AptosRpcError::ApiError { status, .. } if status >= 500 => { + ForeignChainInspectionError::RpcRequestFailed(message) + } + // Remaining 4xx (400/401/403/410, …) are deterministic rejections — retrying cannot + // change them, so they count as substantive verdicts. + AptosRpcError::ApiError { .. } => ForeignChainInspectionError::RpcRequestRejected(message), + // Transport failures, including timeouts. + AptosRpcError::Http(_) => ForeignChainInspectionError::RpcRequestFailed(message), + } +} + /// Rejects a backend that returned a different transaction than queried. A non-hex `returned` /// hash is a malformed response; a well-formed but different hash is a hard inconsistency. fn ensure_hash_matches( @@ -236,46 +262,65 @@ mod tests { use super::*; use assert_matches::assert_matches; use foreign_chain_rpc_interfaces::aptos::{ - AptosEventResponse, AptosRpcError, EventGuid, TransactionResponse, + AptosEventResponse, AptosRpcError, EventGuid, LedgerInfoResponse, TransactionResponse, }; use rstest::rstest; + const MAINNET_CHAIN_ID: u8 = 1; + + /// Both endpoints are configured up front; `Err` carries only the HTTP status because + /// [`AptosRpcError`] is not [`Clone`] and the status is all the classification uses. struct MockAptosClient { - response: Result, + transaction: Result, + chain_id: Result, } impl MockAptosClient { fn success(tx: TransactionResponse) -> Self { - Self { response: Ok(tx) } + Self { + transaction: Ok(tx), + chain_id: Ok(MAINNET_CHAIN_ID), + } } fn api_error(status: u16) -> Self { Self { - response: Err(AptosRpcError::ApiError { - status, - body: format!("http {status}"), - }), + transaction: Err(status), + chain_id: Err(status), + } + } + + fn serving_chain_id(chain_id: u8) -> Self { + Self { + transaction: Err(404), + chain_id: Ok(chain_id), } } } + fn api_error(status: u16) -> AptosRpcError { + AptosRpcError::ApiError { + status, + body: format!("http {status}"), + } + } + impl AptosRpcClient for MockAptosClient { fn get_transaction_by_hash( &self, _tx_hash_hex: &str, ) -> impl Future> + Send { - let r = match &self.response { - Ok(tx) => Ok(tx.clone()), - Err(AptosRpcError::ApiError { status, body }) => Err(AptosRpcError::ApiError { - status: *status, - body: body.clone(), - }), - Err(other) => Err(AptosRpcError::ApiError { - status: 500, - body: other.to_string(), - }), - }; - std::future::ready(r) + std::future::ready(self.transaction.clone().map_err(api_error)) + } + + fn get_ledger_info( + &self, + ) -> impl Future> + Send { + std::future::ready( + self.chain_id + .map(|chain_id| LedgerInfoResponse { chain_id }) + .map_err(api_error), + ) } } @@ -509,6 +554,33 @@ mod tests { } } + #[tokio::test] + async fn chain_identity__should_report_the_ledger_chain_id_in_decimal() { + // Given — a provider on Aptos testnet. + let inspector = AptosInspector::new(MockAptosClient::serving_chain_id(2)); + + // When + let identity = inspector.chain_identity().await.unwrap(); + + // Then + assert_eq!(identity.as_str(), "2"); + } + + #[tokio::test] + async fn chain_identity__should_reject_a_deterministic_rejection_from_the_ledger_index() { + // Given — 404 on the index means the base URL is wrong, not that a tx is missing. + let inspector = AptosInspector::new(MockAptosClient::api_error(404)); + + // When + let result = inspector.chain_identity().await; + + // Then + assert_matches!( + result, + Err(ForeignChainInspectionError::RpcRequestRejected(_)) + ); + } + #[test] fn parse_aptos_address__should_zero_pad_short_address() { // Given diff --git a/crates/foreign-chain-inspector/src/bitcoin/inspector.rs b/crates/foreign-chain-inspector/src/bitcoin/inspector.rs index bc8aa47514..e536a11bff 100644 --- a/crates/foreign-chain-inspector/src/bitcoin/inspector.rs +++ b/crates/foreign-chain-inspector/src/bitcoin/inspector.rs @@ -1,7 +1,10 @@ use jsonrpsee::core::client::ClientT; use crate::bitcoin::{BitcoinExtractedValue, BitcoinTransactionHash}; -use crate::{BlockConfirmations, ForeignChainInspectionError, ForeignChainInspector}; +use crate::{ + BlockConfirmations, ChainIdentity, ChainIdentityFuture, ChainIdentityProbe, + ForeignChainInspectionError, ForeignChainInspector, +}; use foreign_chain_rpc_interfaces::bitcoin::{ GetBlockHashArgs, GetBlockHeaderArgs, GetBlockHeaderVerboseResponse, GetRawTransactionArgs, GetRawTransactionVerboseResponse, TransportBitcoinBlockHash, TransportBitcoinTransactionHash, @@ -16,6 +19,10 @@ const GET_BLOCK_HEADER_METHOD: &str = "getblockheader"; /// https://developer.bitcoin.org/reference/rpc/getblockhash.html const GET_BLOCK_HASH_METHOD: &str = "getblockhash"; +/// Bitcoin has no chain id; the genesis block hash is what distinguishes mainnet from testnet, +/// signet and regtest. +const GENESIS_BLOCK_HEIGHT: u64 = 0; + #[derive(Clone)] pub struct BitcoinInspector { client: Client, @@ -70,6 +77,24 @@ where } } +/// Reports the genesis block hash in the same hex form `getblockhash` and block explorers use +/// — mainnet is `000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f`. +impl ChainIdentityProbe for BitcoinInspector +where + Client: ClientT + Send + Sync, +{ + fn chain_identity(&self) -> ChainIdentityFuture<'_> { + Box::pin(async move { + let args = GetBlockHashArgs { + height: GENESIS_BLOCK_HEIGHT, + }; + let genesis_block_hash: TransportBitcoinBlockHash = + self.client.request(GET_BLOCK_HASH_METHOD, &args).await?; + Ok(ChainIdentity::from(genesis_block_hash.to_string())) + }) + } +} + impl BitcoinInspector where Client: ClientT + Send + Sync, diff --git a/crates/foreign-chain-inspector/src/chain_identity.rs b/crates/foreign-chain-inspector/src/chain_identity.rs new file mode 100644 index 0000000000..bcff3eebd1 --- /dev/null +++ b/crates/foreign-chain-inspector/src/chain_identity.rs @@ -0,0 +1,54 @@ +//! Asking a foreign-chain RPC provider which network it is serving. +//! +//! A provider pointed at the wrong network of the right chain family answers transaction +//! lookups with a plain "not found", which [`crate::FanOut`] treats as a substantive verdict — +//! so one such provider breaks verification for the whole chain. Network identity is the +//! cheapest thing to check that catches this, and unlike a reference transaction it never +//! rots: providers prune transaction history, but not their genesis block or chain id. + +use std::future::Future; +use std::pin::Pin; + +use crate::ForeignChainInspectionError; + +/// The network a provider is serving, as the chain natively reports it. +/// +/// Opaque: the value is only ever compared against the operator's configured expectation, +/// never interpreted. Each [`ChainIdentityProbe`] implementation documents the exact text form +/// it produces, since that is what an operator has to write into their config. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + derive_more::Display, + derive_more::From, + derive_more::Into, +)] +pub struct ChainIdentity(String); + +impl ChainIdentity { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Future returned by [`ChainIdentityProbe::chain_identity`]. +/// +/// Boxed rather than `impl Future` so that inspectors for unrelated chains — which share no +/// types at all — can be collected into one `Vec>` and driven by a +/// single loop. The check runs once per process at startup, never on a signing path, so the +/// allocation buys a lot of simplicity for nothing. +pub type ChainIdentityFuture<'a> = + Pin> + Send + 'a>>; + +/// Asks a single RPC provider which network it serves. +/// +/// Implemented by the per-chain inspectors, so a caller checks identity through exactly the +/// client (URL, auth, transport) that transaction verification would use. +pub trait ChainIdentityProbe: Send + Sync { + fn chain_identity(&self) -> ChainIdentityFuture<'_>; +} diff --git a/crates/foreign-chain-inspector/src/evm/inspector.rs b/crates/foreign-chain-inspector/src/evm/inspector.rs index f7f8caf219..7ee54b77e1 100644 --- a/crates/foreign-chain-inspector/src/evm/inspector.rs +++ b/crates/foreign-chain-inspector/src/evm/inspector.rs @@ -3,8 +3,12 @@ use std::hash::Hash; use jsonrpsee::core::client::ClientT; -use crate::{EthereumFinality, ForeignChainInspectionError, ForeignChainInspector}; +use crate::{ + ChainIdentity, ChainIdentityFuture, ChainIdentityProbe, EthereumFinality, + ForeignChainInspectionError, ForeignChainInspector, +}; +use foreign_chain_rpc_interfaces::NoParams; use foreign_chain_rpc_interfaces::evm::{ BlockNumberOrTag, FinalityTag, GetBlockByNumberArgs, GetBlockByNumberResponse, GetTransactionReceiptARgs, GetTransactionReceiptResponse, H256, Log, @@ -13,6 +17,7 @@ use foreign_chain_rpc_interfaces::evm::{ 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. /// @@ -94,6 +99,21 @@ where } } +/// Reports `eth_chainId` in decimal (`8453` for Base, `137` for Polygon) — the form chain +/// registries and block explorers use, even though the RPC itself answers in hex. +impl ChainIdentityProbe for EvmInspector +where + Client: ClientT + Send + Sync, + Chain: EvmChain + Send + Sync, +{ + fn chain_identity(&self) -> ChainIdentityFuture<'_> { + Box::pin(async move { + let chain_id: U64 = self.client.request(CHAIN_ID_METHOD, &NoParams).await?; + Ok(ChainIdentity::from(chain_id.as_u64().to_string())) + }) + } +} + impl 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 26c60fef74..3b480afa1b 100644 --- a/crates/foreign-chain-inspector/src/lib.rs +++ b/crates/foreign-chain-inspector/src/lib.rs @@ -7,6 +7,7 @@ use jsonrpsee::http_client::{HttpClient, HttpClientBuilder}; use near_mpc_bounded_collections::NonEmptyVec; use thiserror::Error; +pub use chain_identity::{ChainIdentity, ChainIdentityFuture, ChainIdentityProbe}; pub use jsonrpsee::http_client; pub mod abstract_chain; @@ -15,6 +16,7 @@ pub mod arbitrum; pub mod base; pub mod bitcoin; pub mod bnb; +pub mod chain_identity; pub mod contract_interface_conversions; pub mod evm; pub mod hyperevm; diff --git a/crates/foreign-chain-inspector/src/starknet/inspector.rs b/crates/foreign-chain-inspector/src/starknet/inspector.rs index 4f33844852..52efea5ecf 100644 --- a/crates/foreign-chain-inspector/src/starknet/inspector.rs +++ b/crates/foreign-chain-inspector/src/starknet/inspector.rs @@ -1,14 +1,20 @@ use crate::starknet::{StarknetExtractedValue, StarknetTransactionHash}; -use crate::{ForeignChainInspectionError, ForeignChainInspector}; +use crate::{ + ChainIdentity, ChainIdentityFuture, ChainIdentityProbe, ForeignChainInspectionError, + ForeignChainInspector, +}; +use foreign_chain_rpc_interfaces::NoParams; use foreign_chain_rpc_interfaces::starknet::{ - BlockId, GetBlockWithTxHashesArgs, GetBlockWithTxHashesResponse, GetTransactionReceiptArgs, - GetTransactionReceiptResponse, H256, StarknetExecutionStatus, StarknetFinalityStatus, + BlockId, 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 +78,21 @@ where } } +/// Reports `starknet_chainId` as a normalized felt: `0x`-prefixed lowercase hex without +/// leading zeros. The felt spells the network's ASCII name — mainnet's `SN_MAIN` is +/// `0x534e5f4d41494e`, Sepolia's `SN_SEPOLIA` is `0x534e5f5345504f4c4941`. +impl ChainIdentityProbe for StarknetInspector +where + Client: ClientT + Send + Sync, +{ + fn chain_identity(&self) -> ChainIdentityFuture<'_> { + Box::pin(async move { + let chain_id: ChainIdResponse = self.client.request(CHAIN_ID_METHOD, &NoParams).await?; + Ok(ChainIdentity::from(String::from(chain_id))) + }) + } +} + impl StarknetInspector where Client: ClientT + Send + Sync, diff --git a/crates/foreign-chain-inspector/src/sui/inspector.rs b/crates/foreign-chain-inspector/src/sui/inspector.rs index 2efb7b6805..77bad10a5b 100644 --- a/crates/foreign-chain-inspector/src/sui/inspector.rs +++ b/crates/foreign-chain-inspector/src/sui/inspector.rs @@ -1,5 +1,8 @@ use crate::sui::{SuiExtractedValue, SuiTransactionDigest}; -use crate::{ForeignChainInspectionError, ForeignChainInspector, HexBytes}; +use crate::{ + ChainIdentity, ChainIdentityFuture, ChainIdentityProbe, ForeignChainInspectionError, + ForeignChainInspector, HexBytes, +}; use foreign_chain_rpc_interfaces::sui::proto::ExecutedTransaction; use foreign_chain_rpc_interfaces::sui::{Code, Status, SuiRpcClient}; use near_mpc_contract_interface::types::{SuiAddress, SuiEvent}; @@ -100,6 +103,31 @@ where } } +/// Reports the base58 genesis checkpoint digest that `GetServiceInfo` returns as its chain id. +/// Its 4-byte prefix is the chain identifier Sui's docs publish — `0x35834a8a` on mainnet. +impl ChainIdentityProbe for SuiInspector +where + Client: SuiRpcClient, +{ + fn chain_identity(&self) -> ChainIdentityFuture<'_> { + Box::pin(async move { + let service_info = self + .client + .get_service_info() + .await + .map_err(classify_status)?; + service_info + .chain_id + .map(ChainIdentity::from) + .ok_or_else(|| { + ForeignChainInspectionError::MalformedRpcResponse( + "service info is missing the chain id".to_string(), + ) + }) + }) + } +} + /// gRPC status codes carry the verdict semantics directly: `NotFound` is the node's /// deterministic answer for an unknown (or pruned) digest, other deterministic rejections /// (bad request, auth, unimplemented method) must count as substantive verdicts in the diff --git a/crates/foreign-chain-inspector/tests/bitcoin_inspector.rs b/crates/foreign-chain-inspector/tests/bitcoin_inspector.rs index fd8493800f..75e4312a68 100644 --- a/crates/foreign-chain-inspector/tests/bitcoin_inspector.rs +++ b/crates/foreign-chain-inspector/tests/bitcoin_inspector.rs @@ -7,7 +7,8 @@ use crate::common::{ }; use foreign_chain_inspector::{ - BlockConfirmations, ForeignChainInspectionError, ForeignChainInspector, RpcAuthentication, + BlockConfirmations, ChainIdentityProbe, ForeignChainInspectionError, ForeignChainInspector, + RpcAuthentication, bitcoin::{ BitcoinBlockHash, BitcoinExtractedValue, BitcoinTransactionHash, inspector::{BitcoinExtractor, BitcoinInspector}, @@ -372,3 +373,20 @@ async fn inspector_extracts_block_hash_via_http_rpc_client() { let expected_extractions = vec![BitcoinExtractedValue::BlockHash(expected_block_hash)]; assert_eq!(expected_extractions, extracted_values); } + +/// +const MAINNET_GENESIS_BLOCK_HASH: &str = + "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"; + +#[tokio::test] +async fn chain_identity__should_report_the_genesis_block_hash() { + // given + let mock_client = mock_client_from_fixed_response(MAINNET_GENESIS_BLOCK_HASH); + let inspector = BitcoinInspector::new(mock_client); + + // when + let identity = inspector.chain_identity().await.unwrap(); + + // then + assert_eq!(identity.as_str(), MAINNET_GENESIS_BLOCK_HASH); +} diff --git a/crates/foreign-chain-inspector/tests/evm_inspector.rs b/crates/foreign-chain-inspector/tests/evm_inspector.rs index 61db6fdb2a..f1dd18ff4d 100644 --- a/crates/foreign-chain-inspector/tests/evm_inspector.rs +++ b/crates/foreign-chain-inspector/tests/evm_inspector.rs @@ -2,7 +2,9 @@ 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, @@ -716,3 +718,44 @@ evm_inspector_tests!( foreign_chain_inspector::polygon::inspector::Polygon, polygon ); + +/// The [`ChainIdentityProbe`] impl is generic over the chain marker, so one instantiation +/// covers all six EVM chains. +mod chain_identity { + use super::*; + use foreign_chain_inspector::{ChainIdentityProbe, base::inspector::Base}; + use rstest::rstest; + + #[rstest] + #[case::base_mainnet("0x2105", "8453")] + #[case::base_sepolia("0x14a34", "84532")] + #[case::single_digit("0x1", "1")] + #[tokio::test] + async fn chain_identity__should_report_eth_chain_id_in_decimal( + #[case] rpc_response: &str, + #[case] expected: &str, + ) { + // Given + let mock_client = mock_client_from_fixed_response(rpc_response); + let inspector = EvmInspector::<_, Base>::new(mock_client); + + // When + let identity = inspector.chain_identity().await.unwrap(); + + // Then + assert_eq!(identity.as_str(), expected); + } + + #[tokio::test] + async fn chain_identity__should_fail_when_the_provider_returns_a_non_numeric_chain_id() { + // Given + let mock_client = mock_client_from_fixed_response("mainnet"); + let inspector = EvmInspector::<_, Base>::new(mock_client); + + // When + let result = inspector.chain_identity().await; + + // Then + assert_matches!(result, Err(ForeignChainInspectionError::ClientError(_))); + } +} diff --git a/crates/foreign-chain-inspector/tests/starknet_inspector.rs b/crates/foreign-chain-inspector/tests/starknet_inspector.rs index 80caef9552..1f0f5af7ef 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, + ChainIdentityProbe, ForeignChainInspectionError, ForeignChainInspector, RpcAuthentication, + build_http_client, starknet::{ StarknetBlockHash, StarknetExtractedValue, StarknetTransactionHash, inspector::{StarknetExtractor, StarknetFinality, StarknetInspector}, @@ -507,3 +508,36 @@ async fn extract__should_return_event_log_for_specific_index_via_http_rpc_client extracted_values, ); } + +#[rstest] +#[case::sn_main("0x534e5f4d41494e", "0x534e5f4d41494e")] +#[case::sn_sepolia("0x534e5f5345504f4c4941", "0x534e5f5345504f4c4941")] +#[case::padded_and_uppercased("0x00534E5F4D41494E", "0x534e5f4d41494e")] +#[tokio::test] +async fn chain_identity__should_report_the_chain_id_felt_normalized( + #[case] rpc_response: &str, + #[case] expected: &str, +) { + // given + let mock_client = mock_client_from_fixed_response(rpc_response); + let inspector = StarknetInspector::new(mock_client); + + // when + let identity = inspector.chain_identity().await.unwrap(); + + // then + assert_eq!(identity.as_str(), expected); +} + +#[tokio::test] +async fn chain_identity__should_fail_when_the_provider_returns_something_that_is_not_a_felt() { + // given + let mock_client = mock_client_from_fixed_response("SN_MAIN"); + let inspector = StarknetInspector::new(mock_client); + + // when + let result = inspector.chain_identity().await; + + // then + assert_matches!(result, Err(ForeignChainInspectionError::ClientError(_))); +} diff --git a/crates/foreign-chain-inspector/tests/sui_inspector.rs b/crates/foreign-chain-inspector/tests/sui_inspector.rs index ed626e4e0a..124b1cf3b4 100644 --- a/crates/foreign-chain-inspector/tests/sui_inspector.rs +++ b/crates/foreign-chain-inspector/tests/sui_inspector.rs @@ -2,7 +2,7 @@ use assert_matches::assert_matches; use foreign_chain_inspector::{ - ForeignChainInspectionError, ForeignChainInspector, + ChainIdentityProbe, ForeignChainInspectionError, ForeignChainInspector, sui::{ SuiExtractedValue, SuiTransactionDigest, inspector::{SuiExtractor, SuiFinality, SuiInspector}, @@ -17,21 +17,31 @@ use near_mpc_contract_interface::types::{SuiAddress, SuiEvent}; const EVENT_BCS_BYTES: [u8; 4] = [0xde, 0xad, 0xbe, 0xef]; -/// A client that always returns a hard-coded `GetTransaction` response. +/// A client that always returns hard-coded `GetTransaction` and `GetServiceInfo` responses. struct MockSuiClient { response: Result, + service_info: Result, } impl MockSuiClient { fn transaction(tx: ExecutedTransaction) -> Self { Self { response: Ok(GetTransactionResponse::default().with_transaction(tx)), + service_info: Ok(GetServiceInfoResponse::default()), } } fn status(status: Status) -> Self { Self { - response: Err(status), + response: Err(status.clone()), + service_info: Err(status), + } + } + + fn service_info(service_info: GetServiceInfoResponse) -> Self { + Self { + response: Err(Status::not_found("transaction not used by this test")), + service_info: Ok(service_info), } } } @@ -42,7 +52,7 @@ impl SuiRpcClient for MockSuiClient { } async fn get_service_info(&self) -> Result { - unimplemented!("get_service_info() not used by the inspector") + self.service_info.clone() } async fn get_checkpoint(&self, _sequence_number: u64) -> Result { @@ -239,6 +249,7 @@ async fn extract__should_reject_response_missing_transaction_as_malformed() { // Given — a `GetTransactionResponse` whose transaction section is absent entirely. let inspector = SuiInspector::new(MockSuiClient { response: Ok(GetTransactionResponse::default()), + service_info: Ok(GetServiceInfoResponse::default()), }); // When @@ -454,3 +465,37 @@ async fn extract__should_return_empty_when_no_extractors_are_requested() { let expected: Vec = vec![]; assert_eq!(expected, extracted_values); } + +/// Sui's chain id is the base58 genesis checkpoint digest. +const MAINNET_CHAIN_ID: &str = "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S"; + +#[tokio::test] +async fn chain_identity__should_report_the_service_info_chain_id() { + // Given + let inspector = SuiInspector::new(MockSuiClient::service_info( + GetServiceInfoResponse::default().with_chain_id(MAINNET_CHAIN_ID), + )); + + // When + let identity = inspector.chain_identity().await.unwrap(); + + // Then + assert_eq!(identity.as_str(), MAINNET_CHAIN_ID); +} + +#[tokio::test] +async fn chain_identity__should_reject_service_info_without_a_chain_id() { + // Given + let inspector = SuiInspector::new(MockSuiClient::service_info( + GetServiceInfoResponse::default(), + )); + + // When + let result = inspector.chain_identity().await; + + // Then + assert_matches!( + result, + Err(ForeignChainInspectionError::MalformedRpcResponse(_)) + ); +} diff --git a/crates/foreign-chain-rpc-interfaces/Cargo.toml b/crates/foreign-chain-rpc-interfaces/Cargo.toml index 6621be4d58..888adcc21e 100644 --- a/crates/foreign-chain-rpc-interfaces/Cargo.toml +++ b/crates/foreign-chain-rpc-interfaces/Cargo.toml @@ -18,6 +18,7 @@ thiserror = { workspace = true } tonic = { workspace = true } [dev-dependencies] +rstest = { workspace = true } tokio = { workspace = true } [lints] diff --git a/crates/foreign-chain-rpc-interfaces/src/aptos.rs b/crates/foreign-chain-rpc-interfaces/src/aptos.rs index 1f29f379ba..302b9b9776 100644 --- a/crates/foreign-chain-rpc-interfaces/src/aptos.rs +++ b/crates/foreign-chain-rpc-interfaces/src/aptos.rs @@ -1,6 +1,7 @@ use reqwest::Url; use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; use serde::Deserialize; +use serde::de::DeserializeOwned; use std::future::Future; use std::time::Duration; @@ -40,6 +41,15 @@ pub struct EventGuid { pub account_address: String, } +/// Response from `GET /v1/` (the ledger index). +/// +/// Only `chain_id` is kept: it is the field that identifies the network (`1` = mainnet, +/// `2` = testnet), and the rest of the index is ledger state that changes every block. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct LedgerInfoResponse { + pub chain_id: u8, +} + /// Error from the Aptos REST API client. #[derive(Debug, thiserror::Error)] pub enum AptosRpcError { @@ -55,6 +65,10 @@ pub trait AptosRpcClient: Send + Sync { &self, tx_hash_hex: &str, ) -> impl Future> + Send; + + fn get_ledger_info( + &self, + ) -> impl Future> + Send; } #[derive(Clone)] @@ -98,26 +112,39 @@ fn build_request_url(base: &Url, tx_hash_hex: &str) -> Url { url } +/// Any non-2xx status becomes [`AptosRpcError::ApiError`], so the caller can classify it by +/// status code rather than by error text. +async fn get_json( + client: reqwest::Client, + url: Url, +) -> Result { + let response = client.get(url).send().await?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(AptosRpcError::ApiError { + status: status.as_u16(), + body, + }); + } + Ok(response.json::().await?) +} + impl AptosRpcClient for ReqwestAptosClient { fn get_transaction_by_hash( &self, tx_hash_hex: &str, ) -> impl Future> + Send { - let url = build_request_url(&self.base, tx_hash_hex); - let client = self.client.clone(); - async move { - let response = client.get(url).send().await?; - let status = response.status(); - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(AptosRpcError::ApiError { - status: status.as_u16(), - body, - }); - } - let parsed = response.json::().await?; - Ok(parsed) - } + get_json( + self.client.clone(), + build_request_url(&self.base, tx_hash_hex), + ) + } + + fn get_ledger_info( + &self, + ) -> impl Future> + Send { + get_json(self.client.clone(), self.base.clone()) } } @@ -283,6 +310,26 @@ mod tests { assert!(tx.events.is_empty()); } + #[test] + fn deserialize_ledger_info__should_ignore_the_volatile_ledger_state() { + // Given — the index response, whose other fields move every block. + let json = serde_json::json!({ + "chain_id": 1, + "epoch": "9553", + "ledger_version": "2000000000", + "oldest_ledger_version": "0", + "ledger_timestamp": "1753000000000000", + "node_role": "full_node", + "git_hash": "abc1234" + }); + + // When + let info: LedgerInfoResponse = serde_json::from_value(json).unwrap(); + + // Then + assert_eq!(info.chain_id, 1); + } + #[test] fn normalize_event_data__should_sort_object_keys() { // Given diff --git a/crates/foreign-chain-rpc-interfaces/src/lib.rs b/crates/foreign-chain-rpc-interfaces/src/lib.rs index 430eccf20d..8ed8ba2cab 100644 --- a/crates/foreign-chain-rpc-interfaces/src/lib.rs +++ b/crates/foreign-chain-rpc-interfaces/src/lib.rs @@ -1,3 +1,6 @@ +use jsonrpsee::core::traits::ToRpcParams; +use serde::Serialize; + pub mod aptos; pub mod bitcoin; pub mod evm; @@ -17,3 +20,38 @@ macro_rules! to_rpc_params_impl { } pub(crate) use to_rpc_params_impl; + +/// Argument list for RPC methods that take no arguments, e.g. `eth_chainId`. +/// +/// Serializes to an explicit `[]` rather than omitting `params` altogether — some providers +/// reject a request without the field. +pub struct NoParams; + +impl Serialize for NoParams { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let no_parameters: [(); 0] = []; + no_parameters.serialize(serializer) + } +} + +impl ToRpcParams for &NoParams { + to_rpc_params_impl!(); +} + +#[cfg(test)] +#[expect(non_snake_case)] +mod tests { + use super::*; + + #[test] + fn serialize_no_params__should_produce_an_empty_array() { + // Given / When + let serialized = serde_json::to_value(NoParams).unwrap(); + + // Then + assert_eq!(serialized, serde_json::json!([])); + } +} diff --git a/crates/foreign-chain-rpc-interfaces/src/starknet.rs b/crates/foreign-chain-rpc-interfaces/src/starknet.rs index edb5758c54..18fea3d5a2 100644 --- a/crates/foreign-chain-rpc-interfaces/src/starknet.rs +++ b/crates/foreign-chain-rpc-interfaces/src/starknet.rs @@ -99,6 +99,39 @@ impl ToRpcParams for &GetBlockWithTxHashesArgs { to_rpc_params_impl!(); } +/// Response of `starknet_chainId`: the felt encoding the network's ASCII short-string name +/// (`SN_MAIN` → `0x534e5f4d41494e`, `SN_SEPOLIA` → `0x534e5f5345504f4c4941`). +/// +/// The value is normalized on deserialization to [`render_felt`]'s form, so providers that pad +/// or upper-case the felt still yield the same string. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)] +#[serde(try_from = "String")] +pub struct ChainIdResponse(String); + +impl TryFrom for ChainIdResponse { + type Error = String; + + fn try_from(raw: String) -> Result { + parse_felt(&raw).map(|felt| Self(render_felt(felt))) + } +} + +impl From for String { + fn from(response: ChainIdResponse) -> Self { + response.0 + } +} + +/// Canonical text form of a felt: `0x`-prefixed lowercase hex without leading zeros. +fn render_felt(felt: H256) -> String { + let significant_digits = format!("{felt:x}").trim_start_matches('0').to_string(); + if significant_digits.is_empty() { + "0x0".to_string() + } else { + format!("0x{significant_digits}") + } +} + /// Starknet felt values use `0x`-prefixed hex like Ethereum, but may omit leading /// zeros (e.g. `"0x5"` instead of `"0x0000…0005"`). This function zero-pads /// short representations so they can be parsed as an [`H256`]. @@ -136,9 +169,10 @@ where #[expect(non_snake_case)] mod tests { use super::{ - BlockId, GetBlockWithTxHashesArgs, GetBlockWithTxHashesResponse, + BlockId, ChainIdResponse, GetBlockWithTxHashesArgs, GetBlockWithTxHashesResponse, GetTransactionReceiptResponse, StarknetExecutionStatus, StarknetFinalityStatus, parse_felt, }; + use rstest::rstest; const TEST_BLOCK_NUMBER: u64 = 842_750; const TEST_RECEIPT_BLOCK_NUMBER: u64 = 6_195_041; @@ -215,6 +249,29 @@ mod tests { ); } + #[rstest] + #[case::mainnet("0x534e5f4d41494e", "0x534e5f4d41494e")] + #[case::sepolia("0x534e5f5345504f4c4941", "0x534e5f5345504f4c4941")] + #[case::uppercase_is_lowered("0x534E5F4D41494E", "0x534e5f4d41494e")] + #[case::leading_zeros_are_stripped("0x000534e5f4d41494e", "0x534e5f4d41494e")] + #[case::zero("0x0", "0x0")] + fn deserialize_chain_id__should_normalize_the_felt(#[case] raw: &str, #[case] expected: &str) { + // Given / When + let chain_id: ChainIdResponse = serde_json::from_value(serde_json::json!(raw)).unwrap(); + + // Then + assert_eq!(String::from(chain_id), expected); + } + + #[test] + fn deserialize_chain_id__should_reject_a_value_that_is_not_a_felt() { + // Given — 65 hex digits, one more than a felt can hold. + let too_long = serde_json::json!(format!("0x{}", "a".repeat(65))); + + // When / Then + serde_json::from_value::(too_long).unwrap_err(); + } + #[test] fn deserialize_get_block_with_tx_hashes_response__should_accept_short_hex_block_hash() { let json = serde_json::json!({ diff --git a/crates/node-config/src/foreign_chains.rs b/crates/node-config/src/foreign_chains.rs index a063be04af..8a5edf51a0 100644 --- a/crates/node-config/src/foreign_chains.rs +++ b/crates/node-config/src/foreign_chains.rs @@ -47,6 +47,17 @@ pub struct ForeignChainConfig { pub timeout_sec: NonZeroU64, pub max_retries: NonZeroU64, pub providers: NonEmptyBTreeMap, + /// The network every provider of this chain must be serving, in the form the chain natively + /// reports it: an EVM `eth_chainId` in decimal (`8453`), Bitcoin's genesis block hash, + /// Starknet's chain-id felt (`0x534e5f4d41494e`), Aptos' ledger chain id (`1`), Sui's + /// base58 genesis digest. + /// + /// Opt-in: when absent, the node's startup identity check skips this chain. A provider + /// pointed at the wrong network of the right chain family answers transaction lookups with + /// "not found", which is enough to break verification for every other provider too, so + /// setting this is strongly recommended. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_chain_identity: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -178,6 +189,7 @@ impl ForeignChainsConfig { #[cfg(test)] #[expect(non_snake_case)] mod tests { + use super::ForeignChainConfig; use crate::ConfigFile; #[test] @@ -496,6 +508,7 @@ foreign_chains: starknet: timeout_sec: 30 max_retries: 3 + expected_chain_identity: "0x534e5f4d41494e" providers: blast: rpc_url: "https://starknet-mainnet.blastapi.io/" @@ -511,7 +524,35 @@ foreign_chains: config .validate() .expect("config with starknet section should be valid"); - assert!(config.foreign_chains.starknet.is_some()); + let starknet = config + .foreign_chains + .starknet + .expect("starknet section should be present"); + assert_eq!( + starknet.expected_chain_identity.as_deref(), + Some("0x534e5f4d41494e") + ); + } + + #[test] + fn config_parsing__should_leave_expected_chain_identity_unset_when_absent() { + // Given — a chain section written before the identity check existed. + let yaml = r#" +timeout_sec: 30 +max_retries: 3 +providers: + blast: + rpc_url: "https://starknet-mainnet.blastapi.io/" + auth: + kind: none +"#; + + // When + let chain: ForeignChainConfig = + serde_yaml::from_str(yaml).expect("yaml fixture should be correct"); + + // Then + assert_eq!(chain.expected_chain_identity, None); } #[test] diff --git a/crates/node/src/coordinator.rs b/crates/node/src/coordinator.rs index 26eb1bfb86..965b47bb54 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_identity: 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..90604fe525 --- /dev/null +++ b/crates/node/src/foreign_chain_identity_verifier.rs @@ -0,0 +1,527 @@ +//! Log-only check that every configured foreign-chain RPC provider is serving the network the +//! operator expects. +//! +//! Runs once per process, right after the indexer starts, so a misconfigured provider is +//! reported at boot rather than by the first signature request that needs it. The failure it +//! catches is worth catching early: a provider pointed at the wrong network of the right chain +//! family answers transaction lookups with a plain "not found", which +//! [`foreign_chain_inspector::FanOut`] counts as a substantive verdict, so it disagrees with +//! the healthy providers and breaks verification for the whole chain. +//! +//! Opt-in per chain via `expected_chain_identity`, and log-only in both directions: an +//! unreachable provider must not turn a transient RPC blip into a boot loop, and a mismatch is +//! surfaced rather than enforced until the logs show the check is trustworthy. + +use std::time::Duration; + +use foreign_chain_inspector::aptos::inspector::AptosInspector; +use foreign_chain_inspector::bitcoin::inspector::BitcoinInspector; +use foreign_chain_inspector::starknet::inspector::StarknetInspector; +use foreign_chain_inspector::sui::inspector::SuiInspector; +use foreign_chain_inspector::{ + ChainIdentity, ChainIdentityProbe, RpcAuthentication, abstract_chain, arbitrum, base, bnb, + hyperevm, polygon, +}; +use foreign_chain_rpc_auth::auth_config_to_rpc_auth; +use foreign_chain_rpc_interfaces::aptos::ReqwestAptosClient; +use foreign_chain_rpc_interfaces::sui::GrpcSuiClient; +use mpc_node_config::{ForeignChainConfig, ForeignChainsConfig, foreign_chains::RpcProviderName}; +use near_mpc_contract_interface::types as dtos; + +/// What a single provider answered when asked which network it serves. The error side is +/// already rendered, which keeps [`evaluate`] free of I/O types and trivially testable. +type ProbeOutcome = Result; + +pub(crate) async fn run(config: ForeignChainsConfig) { + let per_chain = config + .iter_chains() + .map(|(chain, chain_config)| async move { check_chain(chain, chain_config).await }); + let findings: Vec = futures::future::join_all(per_chain) + .await + .into_iter() + .flatten() + .collect(); + + for finding in &findings { + log_finding(finding); + } + + let mismatches = findings + .iter() + .filter(|finding| matches!(finding.kind, FindingKind::Mismatch { .. })) + .count(); + if mismatches == 0 { + tracing::info!("foreign-chain identity verifier: no provider is on the wrong network"); + } else { + tracing::error!( + mismatches, + "foreign-chain identity verifier: providers are serving the wrong network; \ + transaction verification will fail for the affected chains" + ); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Finding { + chain: dtos::ForeignChain, + provider: Option, + kind: FindingKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum FindingKind { + Match { + identity: ChainIdentity, + }, + Mismatch { + expected: String, + observed: ChainIdentity, + }, + /// The provider could not be asked, for any reason: its client would not build, the request + /// failed, the response was unusable, or the probe exceeded the chain's timeout. + Unreachable { + error: String, + }, + /// The chain has no `expected_chain_identity`, so there is nothing to compare against. + NotConfigured, + /// This binary has no inspector for the chain, so there is nothing to ask. + Unsupported, +} + +async fn check_chain(chain: dtos::ForeignChain, config: &ForeignChainConfig) -> Vec { + let Some(expected) = config.expected_chain_identity.as_deref() else { + return vec![Finding { + chain, + provider: None, + kind: FindingKind::NotConfigured, + }]; + }; + let Some(probes) = build_probes(chain, config) else { + return vec![Finding { + chain, + provider: None, + kind: FindingKind::Unsupported, + }]; + }; + + let timeout = Duration::from_secs(config.timeout_sec.get()); + let probed = probe_all(probes, timeout).await; + evaluate(chain, expected, &probed) +} + +/// Decides what the operator needs to be told, given what the providers answered. +/// +/// `expected` is compared verbatim against each observation apart from surrounding whitespace, +/// which a TOML/YAML value picks up too easily to be worth reporting as a mismatch. +fn evaluate( + chain: dtos::ForeignChain, + expected: &str, + probed: &[(RpcProviderName, ProbeOutcome)], +) -> Vec { + let expected = expected.trim(); + probed + .iter() + .map(|(provider, outcome)| { + let kind = match outcome { + Err(error) => FindingKind::Unreachable { + error: error.clone(), + }, + Ok(observed) if observed.as_str() == expected => FindingKind::Match { + identity: observed.clone(), + }, + Ok(observed) => FindingKind::Mismatch { + expected: expected.to_string(), + observed: observed.clone(), + }, + }; + Finding { + chain, + provider: Some(provider.clone()), + kind, + } + }) + .collect() +} + +/// A provider whose client could not be constructed is carried as an `Err` rather than dropped, +/// so it still shows up in the report instead of silently going unchecked. +type ProviderProbe = (RpcProviderName, anyhow::Result>); + +async fn probe_all( + probes: Vec, + timeout: Duration, +) -> Vec<(RpcProviderName, ProbeOutcome)> { + let probed = probes + .into_iter() + .map(|(provider, probe)| async move { (provider, probe_one(probe, timeout).await) }); + futures::future::join_all(probed).await +} + +async fn probe_one( + probe: anyhow::Result>, + timeout: Duration, +) -> ProbeOutcome { + let probe = probe.map_err(|error| format!("{error:#}"))?; + match tokio::time::timeout(timeout, probe.chain_identity()).await { + Err(_elapsed) => Err(format!("timed out after {}s", timeout.as_secs())), + Ok(Err(error)) => Err(error.to_string()), + Ok(Ok(identity)) => Ok(identity), + } +} + +/// `None` for chains this binary cannot inspect at all: `solana`, `ethereum` and `ton` are +/// accepted by the config but have no inspector, so their identity cannot be checked either. +fn build_probes( + chain: dtos::ForeignChain, + config: &ForeignChainConfig, +) -> Option> { + /// Mirrors [`crate::providers::verify_foreign_tx`]'s client construction, so the identity + /// check goes through the same URL, auth and transport that verification would use. + fn probes( + config: &ForeignChainConfig, + new_inspector: impl Fn(String, RpcAuthentication, Duration) -> anyhow::Result, + ) -> Vec { + let timeout = Duration::from_secs(config.timeout_sec.get()); + config + .providers + .iter() + .map(|(name, provider)| { + let mut url = provider.rpc_url.clone(); + let probe = auth_config_to_rpc_auth(provider.auth.clone(), &mut url) + .and_then(|rpc_auth| new_inspector(url, rpc_auth, timeout)) + .map(|probe| Box::new(probe) as Box); + (name.clone(), probe) + }) + .collect() + } + + /// The jsonrpsee chains take no timeout at construction; [`probe_one`] bounds them, and + /// every other transport, from the outside. + fn over_http( + new_inspector: impl Fn(foreign_chain_inspector::http_client::HttpClient) -> Probe, + ) -> impl Fn(String, RpcAuthentication, Duration) -> anyhow::Result { + move |url, rpc_auth, _timeout| { + Ok(new_inspector(foreign_chain_inspector::build_http_client( + url, rpc_auth, + )?)) + } + } + + let probes = match chain { + dtos::ForeignChain::Bitcoin => probes(config, over_http(BitcoinInspector::new)), + dtos::ForeignChain::Starknet => probes(config, over_http(StarknetInspector::new)), + dtos::ForeignChain::Abstract => probes( + config, + over_http(abstract_chain::inspector::AbstractInspector::new), + ), + dtos::ForeignChain::Base => probes(config, over_http(base::inspector::BaseInspector::new)), + dtos::ForeignChain::Bnb => probes(config, over_http(bnb::inspector::BnbInspector::new)), + dtos::ForeignChain::Arbitrum => probes( + config, + over_http(arbitrum::inspector::ArbitrumInspector::new), + ), + dtos::ForeignChain::HyperEvm => probes( + config, + over_http(hyperevm::inspector::HyperEvmInspector::new), + ), + dtos::ForeignChain::Polygon => { + probes(config, over_http(polygon::inspector::PolygonInspector::new)) + } + dtos::ForeignChain::Aptos => probes(config, |url, rpc_auth, timeout| { + Ok(AptosInspector::new(ReqwestAptosClient::new( + url, + auth_header(rpc_auth), + timeout, + ))) + }), + dtos::ForeignChain::Sui => probes(config, |url, rpc_auth, timeout| { + let client = GrpcSuiClient::new(url, auth_header(rpc_auth), timeout) + .map_err(|e| anyhow::anyhow!("failed to build the Sui gRPC client: {e}"))?; + Ok(SuiInspector::new(client)) + }), + _ => return None, + }; + Some(probes) +} + +fn auth_header(rpc_auth: RpcAuthentication) -> Option<(http::HeaderName, http::HeaderValue)> { + match rpc_auth { + RpcAuthentication::KeyInUrl => None, + RpcAuthentication::CustomHeader { + header_name, + header_value, + } => Some((header_name, header_value)), + } +} + +fn log_finding(finding: &Finding) { + let chain = finding.chain; + let provider = finding.provider.as_ref().map(|p| p.as_str()); + match &finding.kind { + FindingKind::Mismatch { expected, observed } => { + tracing::error!( + ?chain, + provider, + %expected, + %observed, + "foreign-chain provider is serving the wrong network; it will disagree with the \ + other providers and break transaction verification for this chain", + ); + } + FindingKind::Unreachable { error } => { + tracing::warn!( + ?chain, + provider, + error, + "could not verify which network this foreign-chain provider serves", + ); + } + FindingKind::NotConfigured => { + tracing::info!( + ?chain, + "foreign chain has no `expected_chain_identity`; skipping the identity check", + ); + } + FindingKind::Unsupported => { + tracing::info!( + ?chain, + "this node has no inspector for the foreign chain; skipping the identity check", + ); + } + FindingKind::Match { identity } => { + tracing::info!( + ?chain, + provider, + %identity, + "foreign-chain provider serves the expected network", + ); + } + } +} + +#[cfg(test)] +#[expect(non_snake_case)] +mod tests { + use super::*; + use assert_matches::assert_matches; + use mpc_node_config::{AuthConfig, ForeignChainProviderConfig}; + use near_mpc_bounded_collections::NonEmptyBTreeMap; + use std::collections::BTreeMap; + use std::num::NonZeroU64; + + const CHAIN: dtos::ForeignChain = dtos::ForeignChain::Starknet; + const SN_MAIN: &str = "0x534e5f4d41494e"; + const SN_SEPOLIA: &str = "0x534e5f5345504f4c4941"; + + fn provider(name: &str) -> RpcProviderName { + RpcProviderName::from(name.to_string()) + } + + fn observed(name: &str, identity: &str) -> (RpcProviderName, ProbeOutcome) { + ( + provider(name), + Ok(ChainIdentity::from(identity.to_string())), + ) + } + + fn unreachable(name: &str) -> (RpcProviderName, ProbeOutcome) { + (provider(name), Err("connection refused".to_string())) + } + + #[test] + fn evaluate__should_report_a_match_when_the_provider_is_on_the_expected_network() { + // Given + let probed = [observed("blast", SN_MAIN)]; + + // When + let findings = evaluate(CHAIN, SN_MAIN, &probed); + + // Then + assert_matches!( + &findings[..], + [Finding { + kind: FindingKind::Match { .. }, + .. + }] + ); + } + + #[test] + fn evaluate__should_name_the_single_provider_that_is_on_the_wrong_network() { + // Given — the failure this check exists for: one of three providers points at Sepolia. + let probed = [ + observed("blast", SN_MAIN), + observed("misconfigured", SN_SEPOLIA), + observed("publicnode", SN_MAIN), + ]; + + // When + let findings = evaluate(CHAIN, SN_MAIN, &probed); + + // Then + let mismatches: Vec<_> = findings + .iter() + .filter(|f| matches!(f.kind, FindingKind::Mismatch { .. })) + .collect(); + assert_eq!(mismatches.len(), 1); + assert_eq!(mismatches[0].provider, Some(provider("misconfigured"))); + assert_matches!( + &mismatches[0].kind, + FindingKind::Mismatch { expected, observed } + if expected == SN_MAIN && observed.as_str() == SN_SEPOLIA + ); + } + + #[test] + fn evaluate__should_report_an_unreachable_provider_without_claiming_a_mismatch() { + // Given + let probed = [observed("blast", SN_MAIN), unreachable("publicnode")]; + + // When + let findings = evaluate(CHAIN, SN_MAIN, &probed); + + // Then — an RPC we could not reach says nothing about which network it serves. + assert!( + !findings + .iter() + .any(|f| matches!(f.kind, FindingKind::Mismatch { .. })) + ); + assert_matches!( + &findings[1], + Finding { + kind: FindingKind::Unreachable { .. }, + .. + } + ); + } + + #[test] + fn evaluate__should_ignore_whitespace_around_the_configured_identity() { + // Given — a value that picked up padding on its way through TOML/YAML. + let probed = [observed("blast", SN_MAIN)]; + + // When + let findings = evaluate(CHAIN, &format!(" {SN_MAIN}\n"), &probed); + + // Then + assert_matches!( + &findings[..], + [Finding { + kind: FindingKind::Match { .. }, + .. + }] + ); + } + + #[test] + fn evaluate__should_treat_the_comparison_as_case_sensitive() { + // Given — every probe normalizes its output, so a case difference is a real difference + // rather than a rendering artifact the check should paper over. + let probed = [observed("blast", SN_MAIN)]; + + // When + let findings = evaluate(CHAIN, &SN_MAIN.to_uppercase(), &probed); + + // Then + assert_matches!( + &findings[..], + [Finding { + kind: FindingKind::Mismatch { .. }, + .. + }] + ); + } + + fn chain_config(expected_chain_identity: Option<&str>) -> ForeignChainConfig { + let providers = BTreeMap::from([( + provider("public"), + ForeignChainProviderConfig { + rpc_url: "https://starknet-rpc.publicnode.com".to_string(), + auth: AuthConfig::None, + }, + )]); + ForeignChainConfig { + timeout_sec: NonZeroU64::new(30).unwrap(), + max_retries: NonZeroU64::new(3).unwrap(), + providers: NonEmptyBTreeMap::try_from(providers) + .expect("test setup: providers must be non-empty"), + expected_chain_identity: expected_chain_identity.map(str::to_string), + } + } + + #[tokio::test] + async fn check_chain__should_not_touch_the_network_when_no_identity_is_configured() { + // Given — the URL is unreachable, so any probe attempt would surface as `Unreachable`. + let config = chain_config(None); + + // When + let findings = check_chain(CHAIN, &config).await; + + // Then + assert_eq!( + findings, + vec![Finding { + chain: CHAIN, + provider: None, + kind: FindingKind::NotConfigured, + }] + ); + } + + #[tokio::test] + async fn check_chain__should_report_chains_this_binary_cannot_inspect() { + // Given — `solana` is accepted by the config but has no inspector. + let config = chain_config(Some("whatever")); + + // When + let findings = check_chain(dtos::ForeignChain::Solana, &config).await; + + // Then + assert_eq!( + findings, + vec![Finding { + chain: dtos::ForeignChain::Solana, + provider: None, + kind: FindingKind::Unsupported, + }] + ); + } + + #[tokio::test] + async fn probe_all__should_report_a_provider_whose_client_cannot_be_built() { + // Given + let probes = vec![( + provider("broken"), + Err(anyhow::anyhow!("invalid RPC URL: `not a url`")), + )]; + + // When + let probed = probe_all(probes, Duration::from_secs(1)).await; + + // Then — the provider still appears in the report rather than being silently skipped. + assert_matches!( + &probed[..], + [(name, Err(error))] if name == &provider("broken") && error.contains("invalid RPC URL") + ); + } + + struct StalledProbe; + + impl ChainIdentityProbe for StalledProbe { + fn chain_identity(&self) -> foreign_chain_inspector::ChainIdentityFuture<'_> { + Box::pin(std::future::pending()) + } + } + + #[tokio::test(start_paused = true)] + async fn probe_all__should_give_up_on_a_provider_that_never_answers() { + // Given + let probes: Vec = vec![(provider("stalled"), Ok(Box::new(StalledProbe)))]; + + // When + let probed = probe_all(probes, Duration::from_secs(30)).await; + + // Then — startup must never hang on a foreign RPC. + assert_matches!(&probed[..], [(_, Err(error))] if error.contains("timed out")); + } +} diff --git a/crates/node/src/foreign_chain_whitelist_verifier.rs b/crates/node/src/foreign_chain_whitelist_verifier.rs index 98981fd870..0e5e0e3ed8 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_identity: 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/indexer/real.rs b/crates/node/src/indexer/real.rs index d534d99fe7..da07dc03c9 100644 --- a/crates/node/src/indexer/real.rs +++ b/crates/node/src/indexer/real.rs @@ -239,6 +239,9 @@ pub fn spawn_real_indexer( foreign_chain_whitelist_receiver, foreign_chains.clone(), )); + tokio::spawn(crate::foreign_chain_identity_verifier::run( + foreign_chains.clone(), + )); // Returns once the contract state is available. let contract_state_receiver = monitor_contract_state( 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/tests/foreign_chain_configuration.rs b/crates/node/src/tests/foreign_chain_configuration.rs index 457d25777e..3e12f49318 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_identity: 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..32d2b36266 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_identity: 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..ef15cfc2b3 100644 --- a/deployment/cvm-deployment/user-config.toml +++ b/deployment/cvm-deployment/user-config.toml @@ -131,6 +131,7 @@ timeout_sec = 60 [mpc_node_config.node.foreign_chains.bitcoin] timeout_sec = 30 max_retries = 3 +expected_chain_identity = "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943" # Bitcoin testnet3 genesis block hash [mpc_node_config.node.foreign_chains.bitcoin.providers.public] rpc_url = "https://bitcoin-testnet-rpc.publicnode.com" @@ -138,6 +139,7 @@ rpc_url = "https://bitcoin-testnet-rpc.publicnode.com" [mpc_node_config.node.foreign_chains.abstract] timeout_sec = 30 max_retries = 3 +expected_chain_identity = "11124" # Abstract testnet [mpc_node_config.node.foreign_chains.abstract.providers.abstract-testnet] rpc_url = "https://api.testnet.abs.xyz" @@ -145,6 +147,7 @@ rpc_url = "https://api.testnet.abs.xyz" [mpc_node_config.node.foreign_chains.starknet] timeout_sec = 30 max_retries = 3 +expected_chain_identity = "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..f65ea094d1 100644 --- a/docs/foreign-chain-transactions.md +++ b/docs/foreign-chain-transactions.md @@ -647,6 +647,42 @@ 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. +#### Startup identity check + +A provider pointed at the wrong network of the right chain family is not rejected by anything +above — it answers transaction lookups with "not found", which counts as a substantive verdict +in the fan-out and therefore disagrees with the healthy providers. One such provider breaks +verification for the whole chain. + +To catch that at boot rather than at the first verification request, a chain may declare the +network its providers must be serving: + +```yaml +foreign_chains: + starknet: + timeout_sec: 30 + max_retries: 3 + expected_chain_identity: "0x534e5f4d41494e" # SN_MAIN + providers: ... +``` + +The node asks every provider of that chain which network it serves and logs each answer once, at +startup. Optional per chain; absent means the chain is skipped. The value is compared verbatim +(whitespace aside) against what the chain natively reports: + +| Chain | Source | Form | Mainnet | +|---|---|---|---| +| base, bnb, arbitrum, polygon, hyper_evm, abstract | `eth_chainId` | decimal | `8453` (Base) | +| bitcoin | `getblockhash 0` | genesis block hash | `000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f` | +| starknet | `starknet_chainId` | felt, lowercase, no leading zeros | `0x534e5f4d41494e` | +| aptos | `GET /v1/` | decimal chain id | `1` | +| sui | `GetServiceInfo` | base58 genesis digest | `4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S` | + +The check is log-only in both directions: a mismatch is reported at `error` but does not stop the +node, and an unreachable provider is reported at `warn` so a transient RPC outage cannot turn +startup into a crash loop. Chains with no inspector in this binary (`solana`, `ethereum`) are +reported as skipped. + ## 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..84af90897b 100644 --- a/docs/localnet/mpc-config.template.toml +++ b/docs/localnet/mpc-config.template.toml @@ -58,6 +58,7 @@ timeout_sec = 60 [node.foreign_chains.bitcoin] timeout_sec = 30 max_retries = 3 +expected_chain_identity = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" # mainnet genesis block hash [node.foreign_chains.bitcoin.providers.public] rpc_url = "https://bitcoin-rpc.publicnode.com" @@ -68,6 +69,7 @@ kind = "none" [node.foreign_chains.abstract] timeout_sec = 30 max_retries = 3 +expected_chain_identity = "11124" # Abstract testnet; mainnet is 2741 [node.foreign_chains.abstract.providers.public] rpc_url = "https://api.testnet.abs.xyz" @@ -78,6 +80,7 @@ kind = "none" [node.foreign_chains.starknet] timeout_sec = 30 max_retries = 3 +expected_chain_identity = "0x534e5f4d41494e" # SN_MAIN; Sepolia is 0x534e5f5345504f4c4941 [node.foreign_chains.starknet.providers.public] rpc_url = "https://starknet-rpc.publicnode.com" @@ -88,6 +91,7 @@ kind = "none" [node.foreign_chains.aptos] timeout_sec = 30 max_retries = 3 +expected_chain_identity = "1" # mainnet; testnet is 2 [node.foreign_chains.aptos.providers.public] rpc_url = "https://fullnode.mainnet.aptoslabs.com/v1" @@ -98,6 +102,7 @@ kind = "none" [node.foreign_chains.sui] timeout_sec = 30 max_retries = 3 +expected_chain_identity = "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S" # mainnet genesis checkpoint digest [node.foreign_chains.sui.providers.public] rpc_url = "https://archive.mainnet.sui.io" diff --git a/docs/localnet/mpc-configs/config.yaml.template b/docs/localnet/mpc-configs/config.yaml.template index d8594248d6..777af7e454 100644 --- a/docs/localnet/mpc-configs/config.yaml.template +++ b/docs/localnet/mpc-configs/config.yaml.template @@ -32,6 +32,7 @@ foreign_chains: bitcoin: timeout_sec: 30 max_retries: 3 + expected_chain_identity: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" # mainnet genesis block hash providers: public: rpc_url: "https://bitcoin-rpc.publicnode.com" @@ -40,6 +41,7 @@ foreign_chains: abstract: timeout_sec: 30 max_retries: 3 + expected_chain_identity: "11124" # Abstract testnet; mainnet is 2741 providers: public: rpc_url: "https://api.testnet.abs.xyz" @@ -48,6 +50,7 @@ foreign_chains: starknet: timeout_sec: 30 max_retries: 3 + expected_chain_identity: "0x534e5f4d41494e" # SN_MAIN; Sepolia is 0x534e5f5345504f4c4941 providers: public: rpc_url: "https://starknet-rpc.publicnode.com" @@ -56,6 +59,7 @@ foreign_chains: aptos: timeout_sec: 30 max_retries: 3 + expected_chain_identity: "1" # mainnet; testnet is 2 providers: public: rpc_url: "https://fullnode.mainnet.aptoslabs.com/v1" @@ -64,6 +68,7 @@ foreign_chains: sui: timeout_sec: 30 max_retries: 3 + expected_chain_identity: "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S" # mainnet genesis checkpoint digest providers: public: rpc_url: "https://archive.mainnet.sui.io"