From 3057798d384dcb73f3cb88f8c15107368637aa3f Mon Sep 17 00:00:00 2001 From: Haiyue Chen Date: Mon, 13 Jul 2026 17:54:27 +0200 Subject: [PATCH 1/7] refactor: extract foreign-chain-health-check library from config-tester Move the golden reference transactions, per-provider checks, and the check_all_providers orchestration out of the foreign-chain-config-tester CLI into a new production library crate, so the MPC node can reuse them for a startup RPC health check without depending on the operator CLI (which carries clap/serde_yaml/toml). No behavior change: the CLI keeps its arg parsing, config-shape detection, and table rendering, and now consumes the shared crate. Part of #3764. --- Cargo.lock | 18 +- Cargo.toml | 2 + crates/foreign-chain-config-tester/Cargo.toml | 13 +- .../foreign-chain-config-tester/src/config.rs | 2 +- .../foreign-chain-config-tester/src/main.rs | 440 +----------------- .../foreign-chain-config-tester/src/report.rs | 26 +- crates/foreign-chain-health-check/Cargo.toml | 25 + .../src/checks.rs | 11 +- .../src/golden.rs | 15 +- crates/foreign-chain-health-check/src/lib.rs | 393 ++++++++++++++++ .../foreign-chain-health-check/src/network.rs | 18 + .../foreign-chain-health-check/src/results.rs | 25 + 12 files changed, 510 insertions(+), 478 deletions(-) create mode 100644 crates/foreign-chain-health-check/Cargo.toml rename crates/{foreign-chain-config-tester => foreign-chain-health-check}/src/checks.rs (97%) rename crates/{foreign-chain-config-tester => foreign-chain-health-check}/src/golden.rs (96%) create mode 100644 crates/foreign-chain-health-check/src/lib.rs create mode 100644 crates/foreign-chain-health-check/src/network.rs create mode 100644 crates/foreign-chain-health-check/src/results.rs diff --git a/Cargo.lock b/Cargo.lock index 3b93c9be02..99065945d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3714,11 +3714,24 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "foreign-chain-config-tester" version = "3.13.0" +dependencies = [ + "anyhow", + "clap", + "foreign-chain-health-check", + "mpc-node-config", + "serde", + "serde_yaml", + "tokio", + "toml 1.1.2+spec-1.1.0", +] + +[[package]] +name = "foreign-chain-health-check" +version = "3.13.0" dependencies = [ "anyhow", "assert_matches", "bs58 0.5.1", - "clap", "foreign-chain-inspector", "foreign-chain-rpc-auth", "foreign-chain-rpc-interfaces", @@ -3727,11 +3740,8 @@ dependencies = [ "httpmock", "mpc-node-config", "near-mpc-bounded-collections", - "serde", "serde_json", - "serde_yaml", "tokio", - "toml 1.1.2+spec-1.1.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 11d47cf3df..5569dbe331 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/devnet", "crates/e2e-tests", "crates/foreign-chain-config-tester", + "crates/foreign-chain-health-check", "crates/foreign-chain-inspector", "crates/foreign-chain-rpc-auth", "crates/foreign-chain-rpc-interfaces", @@ -54,6 +55,7 @@ attestation = { path = "crates/attestation" } chain-gateway = { path = "crates/chain-gateway" } chain-gateway-test-contract = { path = "crates/chain-gateway-test-contract" } contract-history = { path = "crates/contract-history" } +foreign-chain-health-check = { path = "crates/foreign-chain-health-check" } foreign-chain-inspector = { path = "crates/foreign-chain-inspector" } foreign-chain-rpc-auth = { path = "crates/foreign-chain-rpc-auth" } foreign-chain-rpc-interfaces = { path = "crates/foreign-chain-rpc-interfaces" } diff --git a/crates/foreign-chain-config-tester/Cargo.toml b/crates/foreign-chain-config-tester/Cargo.toml index 22348e8a15..97539031cd 100644 --- a/crates/foreign-chain-config-tester/Cargo.toml +++ b/crates/foreign-chain-config-tester/Cargo.toml @@ -10,24 +10,13 @@ path = "src/main.rs" [dependencies] anyhow = { workspace = true } -bs58 = { workspace = true } clap = { workspace = true } -foreign-chain-inspector = { workspace = true } -foreign-chain-rpc-auth = { workspace = true } -foreign-chain-rpc-interfaces = { workspace = true } -hex = { workspace = true } -http = { workspace = true } +foreign-chain-health-check = { workspace = true } mpc-node-config = { workspace = true } serde = { workspace = true } serde_yaml = { workspace = true } tokio = { workspace = true } toml = { workspace = true } -[dev-dependencies] -assert_matches = { workspace = true } -httpmock = { workspace = true } -near-mpc-bounded-collections = { workspace = true } -serde_json = { workspace = true } - [lints] workspace = true diff --git a/crates/foreign-chain-config-tester/src/config.rs b/crates/foreign-chain-config-tester/src/config.rs index 6ac3333ab6..cd23b835af 100644 --- a/crates/foreign-chain-config-tester/src/config.rs +++ b/crates/foreign-chain-config-tester/src/config.rs @@ -12,7 +12,7 @@ use serde::Deserialize; use serde::de::IntoDeserializer; use serde::de::value::{Error as ValueError, StrDeserializer}; -use crate::golden::Network; +use foreign_chain_health_check::Network; /// Paths where `foreign_chains` may live, most-nested first so a wrapped config /// matches before a barer one. diff --git a/crates/foreign-chain-config-tester/src/main.rs b/crates/foreign-chain-config-tester/src/main.rs index cd0a0773b6..6b73bb9481 100644 --- a/crates/foreign-chain-config-tester/src/main.rs +++ b/crates/foreign-chain-config-tester/src/main.rs @@ -1,36 +1,17 @@ //! Foreign-chain RPC config tester: probe every configured provider with a fixed //! golden request so operators can verify their config without running the node. +//! The probe logic lives in `foreign-chain-health-check`, shared with the node. -mod checks; mod config; -mod golden; mod report; use std::fs; -use std::future::Future; use std::path::PathBuf; use std::process::ExitCode; -use std::time::Duration; use anyhow::Context; use clap::Parser; -use foreign_chain_inspector::abstract_chain::inspector::Abstract; -use foreign_chain_inspector::arbitrum::inspector::Arbitrum; -use foreign_chain_inspector::base::inspector::Base; -use foreign_chain_inspector::bnb::inspector::Bnb; -use foreign_chain_inspector::evm::inspector::EvmChain; -use foreign_chain_inspector::http_client::HttpClient; -use foreign_chain_inspector::hyperevm::inspector::HyperEvm; -use foreign_chain_inspector::polygon::inspector::Polygon; -use foreign_chain_inspector::{RpcAuthentication, build_http_client}; -use foreign_chain_rpc_auth::auth_config_to_rpc_auth; -use foreign_chain_rpc_interfaces::sui::GrpcSuiClient; -use http::{HeaderName, HeaderValue}; -use mpc_node_config::foreign_chains::RpcProviderName; -use mpc_node_config::{ForeignChainConfig, ForeignChainProviderConfig, ForeignChainsConfig}; - -use crate::golden::{AptosVector, BlockHashVector, Network, SuiVector}; -use crate::report::{ProviderResult, Status}; +use foreign_chain_health_check::{Network, check_all_providers}; /// Verify a node's foreign-chain RPC provider configuration. /// @@ -45,7 +26,24 @@ struct Args { /// Network the reference transactions belong to. Auto-detected from the /// config (`chain_id` / `mpc_contract_id`) when omitted. #[arg(long, value_enum)] - network: Option, + network: Option, +} + +/// CLI mirror of [`Network`] so the shared library stays free of a `clap` +/// dependency. +#[derive(Clone, Copy, Debug, clap::ValueEnum)] +enum NetworkArg { + Mainnet, + Testnet, +} + +impl From for Network { + fn from(value: NetworkArg) -> Self { + match value { + NetworkArg::Mainnet => Network::Mainnet, + NetworkArg::Testnet => Network::Testnet, + } + } } #[tokio::main] @@ -55,7 +53,7 @@ async fn main() -> anyhow::Result { .with_context(|| format!("failed to read {}", args.config.display()))?; let foreign_chains = config::parse_foreign_chains(&contents, &args.config)?; let network = match args.network { - Some(network) => network, + Some(network) => network.into(), None => config::detect_network(&contents, &args.config)?.ok_or_else(|| { anyhow::anyhow!( "could not determine network from config (no chain_id / mpc_contract_id found); \ @@ -64,7 +62,7 @@ async fn main() -> anyhow::Result { })?, }; - let results = run(&foreign_chains, network).await; + let results = check_all_providers(&foreign_chains, network).await; print!("{}", report::render(&results)); Ok(if report::any_failed(&results) { @@ -73,397 +71,3 @@ async fn main() -> anyhow::Result { ExitCode::SUCCESS }) } - -async fn run(fc: &ForeignChainsConfig, network: Network) -> Vec { - let golden = golden::golden_set(network); - let mut out = Vec::new(); - - if let Some(cfg) = &fc.base { - run_evm::("base", cfg, golden.base, network, &mut out).await; - } else { - mark_not_configured("base", &mut out); - } - if let Some(cfg) = &fc.bnb { - run_evm::("bnb", cfg, golden.bnb, network, &mut out).await; - } else { - mark_not_configured("bnb", &mut out); - } - if let Some(cfg) = &fc.arbitrum { - run_evm::("arbitrum", cfg, golden.arbitrum, network, &mut out).await; - } else { - mark_not_configured("arbitrum", &mut out); - } - if let Some(cfg) = &fc.polygon { - run_evm::("polygon", cfg, golden.polygon, network, &mut out).await; - } else { - mark_not_configured("polygon", &mut out); - } - if let Some(cfg) = &fc.hyper_evm { - run_evm::("hyper_evm", cfg, golden.hyper_evm, network, &mut out).await; - } else { - mark_not_configured("hyper_evm", &mut out); - } - if let Some(cfg) = &fc.abstract_chain { - run_evm::("abstract", cfg, golden.abstract_chain, network, &mut out).await; - } else { - mark_not_configured("abstract", &mut out); - } - if let Some(cfg) = &fc.bitcoin { - run_bitcoin(cfg, golden.bitcoin, network, &mut out).await; - } else { - mark_not_configured("bitcoin", &mut out); - } - if let Some(cfg) = &fc.starknet { - run_starknet(cfg, golden.starknet, network, &mut out).await; - } else { - mark_not_configured("starknet", &mut out); - } - if let Some(cfg) = &fc.aptos { - run_aptos(cfg, golden.aptos, network, &mut out).await; - } else { - mark_not_configured("aptos", &mut out); - } - if let Some(cfg) = &fc.sui { - run_sui(cfg, golden.sui, network, &mut out).await; - } else { - mark_not_configured("sui", &mut out); - } - - // Configured but not yet supported by the node (see verify_foreign_tx/sign.rs). - if let Some(cfg) = &fc.ethereum { - mark_skipped("ethereum", cfg, "not yet supported by the node", &mut out); - } else { - mark_not_configured("ethereum", &mut out); - } - if let Some(cfg) = &fc.solana { - mark_skipped("solana", cfg, "not yet supported by the node", &mut out); - } else { - mark_not_configured("solana", &mut out); - } - - out -} - -fn no_reference_reason(network: Network) -> String { - format!( - "no {} reference transaction for this chain", - network.label() - ) -} - -fn timeout_of(cfg: &ForeignChainConfig) -> Duration { - Duration::from_secs(cfg.timeout_sec.get()) -} - -fn provider_name(name: &RpcProviderName) -> String { - name.as_str().to_owned() -} - -fn prepare_jsonrpc(provider: &ForeignChainProviderConfig) -> anyhow::Result { - let mut url = provider.rpc_url.clone(); - let auth = auth_config_to_rpc_auth(provider.auth.clone(), &mut url)?; - build_http_client(url, auth).map_err(|e| anyhow::anyhow!("failed to build HTTP client: {e}")) -} - -fn prepare_aptos( - provider: &ForeignChainProviderConfig, -) -> anyhow::Result<(String, Option<(HeaderName, HeaderValue)>)> { - let mut url = provider.rpc_url.clone(); - let auth = auth_config_to_rpc_auth(provider.auth.clone(), &mut url)?; - let header = match auth { - RpcAuthentication::KeyInUrl => None, - RpcAuthentication::CustomHeader { - header_name, - header_value, - } => Some((header_name, header_value)), - }; - Ok((url, header)) -} - -async fn run_check(timeout: Duration, fut: impl Future>) -> Status { - match tokio::time::timeout(timeout, fut).await { - Ok(Ok(())) => Status::Passed, - Ok(Err(e)) => Status::Failed(format!("{e:#}")), - Err(_) => Status::Failed(format!("timed out after {}s", timeout.as_secs())), - } -} - -async fn run_evm( - chain: &'static str, - cfg: &ForeignChainConfig, - vector: Option, - network: Network, - out: &mut Vec, -) { - let Some(vector) = vector else { - mark_skipped(chain, cfg, &no_reference_reason(network), out); - return; - }; - let timeout = timeout_of(cfg); - let parsed = - golden::hex32(vector.tx).and_then(|tx| golden::hex32(vector.block_hash).map(|bh| (tx, bh))); - for (name, provider) in cfg.providers.iter() { - let status = match (&parsed, prepare_jsonrpc(provider)) { - (Err(e), _) => Status::Failed(format!("invalid golden vector: {e:#}")), - (Ok(_), Err(e)) => Status::Failed(format!("{e:#}")), - (Ok((tx, bh)), Ok(client)) => { - run_check(timeout, checks::check_evm::(client, *tx, *bh)).await - } - }; - out.push(ProviderResult { - chain, - provider: provider_name(name), - status, - }); - } -} - -async fn run_bitcoin( - cfg: &ForeignChainConfig, - vector: Option, - network: Network, - out: &mut Vec, -) { - let Some(vector) = vector else { - mark_skipped("bitcoin", cfg, &no_reference_reason(network), out); - return; - }; - let timeout = timeout_of(cfg); - let parsed = - golden::hex32(vector.tx).and_then(|tx| golden::hex32(vector.block_hash).map(|bh| (tx, bh))); - for (name, provider) in cfg.providers.iter() { - let status = match (&parsed, prepare_jsonrpc(provider)) { - (Err(e), _) => Status::Failed(format!("invalid golden vector: {e:#}")), - (Ok(_), Err(e)) => Status::Failed(format!("{e:#}")), - (Ok((tx, bh)), Ok(client)) => { - run_check(timeout, checks::check_bitcoin(client, *tx, *bh)).await - } - }; - out.push(ProviderResult { - chain: "bitcoin", - provider: provider_name(name), - status, - }); - } -} - -async fn run_starknet( - cfg: &ForeignChainConfig, - vector: Option, - network: Network, - out: &mut Vec, -) { - let Some(vector) = vector else { - mark_skipped("starknet", cfg, &no_reference_reason(network), out); - return; - }; - let timeout = timeout_of(cfg); - let parsed = golden::felt32(vector.tx) - .and_then(|tx| golden::felt32(vector.block_hash).map(|bh| (tx, bh))); - for (name, provider) in cfg.providers.iter() { - let status = match (&parsed, prepare_jsonrpc(provider)) { - (Err(e), _) => Status::Failed(format!("invalid golden vector: {e:#}")), - (Ok(_), Err(e)) => Status::Failed(format!("{e:#}")), - (Ok((tx, bh)), Ok(client)) => { - run_check(timeout, checks::check_starknet(client, *tx, *bh)).await - } - }; - out.push(ProviderResult { - chain: "starknet", - provider: provider_name(name), - status, - }); - } -} - -async fn run_aptos( - cfg: &ForeignChainConfig, - vector: Option, - network: Network, - out: &mut Vec, -) { - let Some(vector) = vector else { - mark_skipped("aptos", cfg, &no_reference_reason(network), out); - return; - }; - let timeout = timeout_of(cfg); - let parsed_tx = golden::hex32(vector.tx); - for (name, provider) in cfg.providers.iter() { - let status = match (&parsed_tx, prepare_aptos(provider)) { - (Err(e), _) => Status::Failed(format!("invalid golden vector: {e:#}")), - (Ok(_), Err(e)) => Status::Failed(format!("{e:#}")), - (Ok(tx), Ok((url, header))) => { - run_check( - timeout, - checks::check_aptos( - url, - header, - timeout, - *tx, - vector.event_type_tag, - vector.event_sequence_number, - ), - ) - .await - } - }; - out.push(ProviderResult { - chain: "aptos", - provider: provider_name(name), - status, - }); - } -} - -async fn run_sui( - cfg: &ForeignChainConfig, - vector: Option, - network: Network, - out: &mut Vec, -) { - let Some(vector) = vector else { - mark_skipped("sui", cfg, &no_reference_reason(network), out); - return; - }; - let timeout = timeout_of(cfg); - for (name, provider) in cfg.providers.iter() { - let status = match prepare_sui(provider, timeout) { - Err(e) => Status::Failed(format!("{e:#}")), - Ok(client) => run_check(timeout, checks::check_sui(client, vector.chain_id)).await, - }; - out.push(ProviderResult { - chain: "sui", - provider: provider_name(name), - status, - }); - } -} - -fn prepare_sui( - provider: &ForeignChainProviderConfig, - timeout: Duration, -) -> anyhow::Result { - let mut url = provider.rpc_url.clone(); - let auth = auth_config_to_rpc_auth(provider.auth.clone(), &mut url)?; - let header = match auth { - RpcAuthentication::KeyInUrl => None, - RpcAuthentication::CustomHeader { - header_name, - header_value, - } => Some((header_name, header_value)), - }; - GrpcSuiClient::new(url, header, timeout) - .map_err(|e| anyhow::anyhow!("failed to build the Sui gRPC client: {e}")) -} - -fn mark_skipped( - chain: &'static str, - cfg: &ForeignChainConfig, - reason: &str, - out: &mut Vec, -) { - for name in cfg.providers.keys() { - out.push(ProviderResult::skipped(chain, provider_name(name), reason)); - } -} - -/// A chain absent from the config has no providers to enumerate, so it gets a single -/// placeholder row — this way every supported chain shows up in the report. -fn mark_not_configured(chain: &'static str, out: &mut Vec) { - out.push(ProviderResult::skipped( - chain, - "-".to_string(), - "not configured", - )); -} - -#[cfg(test)] -#[expect(non_snake_case)] -mod tests { - use super::*; - use assert_matches::assert_matches; - use mpc_node_config::{AuthConfig, TokenConfig}; - use near_mpc_bounded_collections::NonEmptyBTreeMap; - use std::num::NonZeroU64; - - fn config_with_provider(auth: AuthConfig) -> ForeignChainConfig { - ForeignChainConfig { - timeout_sec: NonZeroU64::new(5).unwrap(), - max_retries: NonZeroU64::new(1).unwrap(), - providers: NonEmptyBTreeMap::new( - "only".to_string().into(), - ForeignChainProviderConfig { - rpc_url: "https://rpc.example.com".to_string(), - auth, - }, - ), - } - } - - #[tokio::test] - async fn run__should_skip_configured_but_unsupported_chains() { - // Given - let fc = ForeignChainsConfig { - ethereum: Some(config_with_provider(AuthConfig::None)), - ..Default::default() - }; - - // When - let results = run(&fc, Network::Mainnet).await; - - // Then - let ethereum = results - .iter() - .find(|r| r.chain == "ethereum") - .expect("ethereum row"); - assert_matches!( - ðereum.status, - Status::Skipped(reason) if reason.contains("not yet supported") - ); - } - - #[tokio::test] - async fn run__should_fail_provider_when_env_token_is_unset() { - // Given - let auth = AuthConfig::Header { - name: http::HeaderName::from_static("authorization"), - scheme: Some("Bearer".to_string()), - token: TokenConfig::Env { - env: "FCCT_DEFINITELY_UNSET_TOKEN_ENV".to_string(), - }, - }; - let fc = ForeignChainsConfig { - base: Some(config_with_provider(auth)), - ..Default::default() - }; - - // When - let results = run(&fc, Network::Mainnet).await; - - // Then - let base = results - .iter() - .find(|r| r.chain == "base") - .expect("base row"); - let Status::Failed(reason) = &base.status else { - panic!("expected Failed, got a pass/skip"); - }; - assert!(reason.contains("FCCT_DEFINITELY_UNSET_TOKEN_ENV")); - } - - #[tokio::test] - async fn run__should_report_absent_chains_as_not_configured() { - // Given — nothing configured - let fc = ForeignChainsConfig::default(); - - // When - let results = run(&fc, Network::Mainnet).await; - - // Then — every supported chain shows up, all reported "not configured" - assert!(results.iter().any(|r| r.chain == "sui")); - assert!(results.iter().all(|r| matches!( - &r.status, - Status::Skipped(reason) if reason.contains("not configured") - ))); - } -} diff --git a/crates/foreign-chain-config-tester/src/report.rs b/crates/foreign-chain-config-tester/src/report.rs index f214bb14dd..629c48f42f 100644 --- a/crates/foreign-chain-config-tester/src/report.rs +++ b/crates/foreign-chain-config-tester/src/report.rs @@ -1,30 +1,8 @@ -//! Result aggregation and human-readable table rendering. +//! Human-readable table rendering of the shared check results. use std::fmt::Write as _; -#[derive(Debug)] -pub enum Status { - Passed, - Failed(String), - Skipped(String), -} - -#[derive(Debug)] -pub struct ProviderResult { - pub chain: &'static str, - pub provider: String, - pub status: Status, -} - -impl ProviderResult { - pub fn skipped(chain: &'static str, provider: String, reason: impl Into) -> Self { - Self { - chain, - provider, - status: Status::Skipped(reason.into()), - } - } -} +use foreign_chain_health_check::{ProviderResult, Status}; /// Whether any provider check failed (skips do not count as failures). pub fn any_failed(results: &[ProviderResult]) -> bool { diff --git a/crates/foreign-chain-health-check/Cargo.toml b/crates/foreign-chain-health-check/Cargo.toml new file mode 100644 index 0000000000..98ded21e1e --- /dev/null +++ b/crates/foreign-chain-health-check/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "foreign-chain-health-check" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow = { workspace = true } +bs58 = { workspace = true } +foreign-chain-inspector = { workspace = true } +foreign-chain-rpc-auth = { workspace = true } +foreign-chain-rpc-interfaces = { workspace = true } +hex = { workspace = true } +http = { workspace = true } +mpc-node-config = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +assert_matches = { workspace = true } +httpmock = { workspace = true } +near-mpc-bounded-collections = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/foreign-chain-config-tester/src/checks.rs b/crates/foreign-chain-health-check/src/checks.rs similarity index 97% rename from crates/foreign-chain-config-tester/src/checks.rs rename to crates/foreign-chain-health-check/src/checks.rs index 779141e1d8..a8f8e47b2a 100644 --- a/crates/foreign-chain-config-tester/src/checks.rs +++ b/crates/foreign-chain-health-check/src/checks.rs @@ -253,6 +253,7 @@ pub async fn check_aptos( mod tests { use super::*; use crate::golden; + use crate::network::Network; use assert_matches::assert_matches; use httpmock::prelude::*; @@ -274,7 +275,7 @@ mod tests { async fn check_aptos__should_pass_when_provider_returns_golden_event() { // Given let server = MockServer::start_async().await; - let aptos = golden::golden_set(golden::Network::Mainnet).aptos.unwrap(); + let aptos = golden::golden_set(Network::Mainnet).aptos.unwrap(); let tx = aptos.tx; let mock = server .mock_async(|when, then| { @@ -359,7 +360,7 @@ mod tests { #[tokio::test] async fn check_sui__should_pass_when_provider_is_on_the_expected_network() { // Given - let sui = golden::golden_set(golden::Network::Mainnet).sui.unwrap(); + let sui = golden::golden_set(Network::Mainnet).sui.unwrap(); let client = MockSuiClient { chain_id: sui.chain_id.to_string(), }; @@ -375,13 +376,13 @@ mod tests { async fn check_sui__should_fail_when_chain_id_differs() { // Given — a provider on a different network. let client = MockSuiClient { - chain_id: golden::golden_set(golden::Network::Testnet) + chain_id: golden::golden_set(Network::Testnet) .sui .unwrap() .chain_id .to_string(), }; - let expected = golden::golden_set(golden::Network::Mainnet).sui.unwrap(); + let expected = golden::golden_set(Network::Mainnet).sui.unwrap(); // When let result = check_sui(client, expected.chain_id).await; @@ -397,7 +398,7 @@ mod tests { async fn check_aptos__should_fail_when_event_type_tag_differs() { // Given let server = MockServer::start_async().await; - let aptos = golden::golden_set(golden::Network::Mainnet).aptos.unwrap(); + let aptos = golden::golden_set(Network::Mainnet).aptos.unwrap(); let tx = aptos.tx; server .mock_async(|when, then| { diff --git a/crates/foreign-chain-config-tester/src/golden.rs b/crates/foreign-chain-health-check/src/golden.rs similarity index 96% rename from crates/foreign-chain-config-tester/src/golden.rs rename to crates/foreign-chain-health-check/src/golden.rs index 34465dd474..0c8b4ea307 100644 --- a/crates/foreign-chain-config-tester/src/golden.rs +++ b/crates/foreign-chain-health-check/src/golden.rs @@ -4,20 +4,7 @@ use anyhow::Context; -#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)] -pub enum Network { - Mainnet, - Testnet, -} - -impl Network { - pub fn label(self) -> &'static str { - match self { - Network::Mainnet => "mainnet", - Network::Testnet => "testnet", - } - } -} +use crate::network::Network; /// Hashes are hex, with or without a `0x` prefix. #[derive(Clone, Copy)] diff --git a/crates/foreign-chain-health-check/src/lib.rs b/crates/foreign-chain-health-check/src/lib.rs new file mode 100644 index 0000000000..ebb080ca82 --- /dev/null +++ b/crates/foreign-chain-health-check/src/lib.rs @@ -0,0 +1,393 @@ +//! Foreign-chain RPC provider health checks: probe every configured provider +//! with a fixed golden request and report a per-provider result. Shared by the +//! `foreign-chain-config-tester` operator CLI and the MPC node's startup health +//! check so the two never drift on golden vectors or auth handling. + +pub mod checks; +pub mod golden; +pub mod network; +pub mod results; + +use std::future::Future; +use std::time::Duration; + +use foreign_chain_inspector::abstract_chain::inspector::Abstract; +use foreign_chain_inspector::arbitrum::inspector::Arbitrum; +use foreign_chain_inspector::base::inspector::Base; +use foreign_chain_inspector::bnb::inspector::Bnb; +use foreign_chain_inspector::evm::inspector::EvmChain; +use foreign_chain_inspector::http_client::HttpClient; +use foreign_chain_inspector::hyperevm::inspector::HyperEvm; +use foreign_chain_inspector::polygon::inspector::Polygon; +use foreign_chain_inspector::{RpcAuthentication, build_http_client}; +use foreign_chain_rpc_auth::auth_config_to_rpc_auth; +use foreign_chain_rpc_interfaces::sui::GrpcSuiClient; +use http::{HeaderName, HeaderValue}; +use mpc_node_config::foreign_chains::RpcProviderName; +use mpc_node_config::{ForeignChainConfig, ForeignChainProviderConfig, ForeignChainsConfig}; + +pub use network::Network; +pub use results::{ProviderResult, Status}; + +use crate::golden::{AptosVector, BlockHashVector, SuiVector}; + +/// Probe every configured provider on every configured chain with the golden +/// reference transaction for `network`, returning one [`ProviderResult`] per +/// provider. Each provider is checked independently: one bad provider does not +/// stop the others from being reported. Chains that are configured but not yet +/// supported by the node, or that have no reference transaction for `network`, +/// are reported as [`Status::Skipped`]. Sui, being newly supported, gets a +/// placeholder `Skipped` row even when absent from the config so its absence +/// is visible in reports. +pub async fn check_all_providers( + fc: &ForeignChainsConfig, + network: Network, +) -> Vec { + let golden = golden::golden_set(network); + let mut out = Vec::new(); + + if let Some(cfg) = &fc.base { + run_evm::("base", cfg, golden.base, network, &mut out).await; + } + if let Some(cfg) = &fc.bnb { + run_evm::("bnb", cfg, golden.bnb, network, &mut out).await; + } + if let Some(cfg) = &fc.arbitrum { + run_evm::("arbitrum", cfg, golden.arbitrum, network, &mut out).await; + } + if let Some(cfg) = &fc.polygon { + run_evm::("polygon", cfg, golden.polygon, network, &mut out).await; + } + if let Some(cfg) = &fc.hyper_evm { + run_evm::("hyper_evm", cfg, golden.hyper_evm, network, &mut out).await; + } + if let Some(cfg) = &fc.abstract_chain { + run_evm::("abstract", cfg, golden.abstract_chain, network, &mut out).await; + } + if let Some(cfg) = &fc.bitcoin { + run_bitcoin(cfg, golden.bitcoin, network, &mut out).await; + } + if let Some(cfg) = &fc.starknet { + run_starknet(cfg, golden.starknet, network, &mut out).await; + } + if let Some(cfg) = &fc.aptos { + run_aptos(cfg, golden.aptos, network, &mut out).await; + } + if let Some(cfg) = &fc.sui { + run_sui(cfg, golden.sui, network, &mut out).await; + } else { + mark_not_configured("sui", &mut out); + } + + // Configured but not yet supported by the node. + if let Some(cfg) = &fc.ethereum { + mark_skipped("ethereum", cfg, "not yet supported by the node", &mut out); + } + if let Some(cfg) = &fc.solana { + mark_skipped("solana", cfg, "not yet supported by the node", &mut out); + } + + out +} + +fn no_reference_reason(network: Network) -> String { + format!( + "no {} reference transaction for this chain", + network.label() + ) +} + +fn timeout_of(cfg: &ForeignChainConfig) -> Duration { + Duration::from_secs(cfg.timeout_sec.get()) +} + +fn provider_name(name: &RpcProviderName) -> String { + name.as_str().to_owned() +} + +fn prepare_jsonrpc(provider: &ForeignChainProviderConfig) -> anyhow::Result { + let mut url = provider.rpc_url.clone(); + let auth = auth_config_to_rpc_auth(provider.auth.clone(), &mut url)?; + build_http_client(url, auth).map_err(|e| anyhow::anyhow!("failed to build HTTP client: {e}")) +} + +fn prepare_aptos( + provider: &ForeignChainProviderConfig, +) -> anyhow::Result<(String, Option<(HeaderName, HeaderValue)>)> { + let mut url = provider.rpc_url.clone(); + let auth = auth_config_to_rpc_auth(provider.auth.clone(), &mut url)?; + let header = match auth { + RpcAuthentication::KeyInUrl => None, + RpcAuthentication::CustomHeader { + header_name, + header_value, + } => Some((header_name, header_value)), + }; + Ok((url, header)) +} + +async fn run_check(timeout: Duration, fut: impl Future>) -> Status { + match tokio::time::timeout(timeout, fut).await { + Ok(Ok(())) => Status::Passed, + Ok(Err(e)) => Status::Failed(format!("{e:#}")), + Err(_) => Status::Failed(format!("timed out after {}s", timeout.as_secs())), + } +} + +async fn run_evm( + chain: &'static str, + cfg: &ForeignChainConfig, + vector: Option, + network: Network, + out: &mut Vec, +) { + let Some(vector) = vector else { + mark_skipped(chain, cfg, &no_reference_reason(network), out); + return; + }; + let timeout = timeout_of(cfg); + let parsed = + golden::hex32(vector.tx).and_then(|tx| golden::hex32(vector.block_hash).map(|bh| (tx, bh))); + for (name, provider) in cfg.providers.iter() { + let status = match (&parsed, prepare_jsonrpc(provider)) { + (Err(e), _) => Status::Failed(format!("invalid golden vector: {e:#}")), + (Ok(_), Err(e)) => Status::Failed(format!("{e:#}")), + (Ok((tx, bh)), Ok(client)) => { + run_check(timeout, checks::check_evm::(client, *tx, *bh)).await + } + }; + out.push(ProviderResult { + chain, + provider: provider_name(name), + status, + }); + } +} + +async fn run_bitcoin( + cfg: &ForeignChainConfig, + vector: Option, + network: Network, + out: &mut Vec, +) { + let Some(vector) = vector else { + mark_skipped("bitcoin", cfg, &no_reference_reason(network), out); + return; + }; + let timeout = timeout_of(cfg); + let parsed = + golden::hex32(vector.tx).and_then(|tx| golden::hex32(vector.block_hash).map(|bh| (tx, bh))); + for (name, provider) in cfg.providers.iter() { + let status = match (&parsed, prepare_jsonrpc(provider)) { + (Err(e), _) => Status::Failed(format!("invalid golden vector: {e:#}")), + (Ok(_), Err(e)) => Status::Failed(format!("{e:#}")), + (Ok((tx, bh)), Ok(client)) => { + run_check(timeout, checks::check_bitcoin(client, *tx, *bh)).await + } + }; + out.push(ProviderResult { + chain: "bitcoin", + provider: provider_name(name), + status, + }); + } +} + +async fn run_starknet( + cfg: &ForeignChainConfig, + vector: Option, + network: Network, + out: &mut Vec, +) { + let Some(vector) = vector else { + mark_skipped("starknet", cfg, &no_reference_reason(network), out); + return; + }; + let timeout = timeout_of(cfg); + let parsed = golden::felt32(vector.tx) + .and_then(|tx| golden::felt32(vector.block_hash).map(|bh| (tx, bh))); + for (name, provider) in cfg.providers.iter() { + let status = match (&parsed, prepare_jsonrpc(provider)) { + (Err(e), _) => Status::Failed(format!("invalid golden vector: {e:#}")), + (Ok(_), Err(e)) => Status::Failed(format!("{e:#}")), + (Ok((tx, bh)), Ok(client)) => { + run_check(timeout, checks::check_starknet(client, *tx, *bh)).await + } + }; + out.push(ProviderResult { + chain: "starknet", + provider: provider_name(name), + status, + }); + } +} + +async fn run_aptos( + cfg: &ForeignChainConfig, + vector: Option, + network: Network, + out: &mut Vec, +) { + let Some(vector) = vector else { + mark_skipped("aptos", cfg, &no_reference_reason(network), out); + return; + }; + let timeout = timeout_of(cfg); + let parsed_tx = golden::hex32(vector.tx); + for (name, provider) in cfg.providers.iter() { + let status = match (&parsed_tx, prepare_aptos(provider)) { + (Err(e), _) => Status::Failed(format!("invalid golden vector: {e:#}")), + (Ok(_), Err(e)) => Status::Failed(format!("{e:#}")), + (Ok(tx), Ok((url, header))) => { + run_check( + timeout, + checks::check_aptos( + url, + header, + timeout, + *tx, + vector.event_type_tag, + vector.event_sequence_number, + ), + ) + .await + } + }; + out.push(ProviderResult { + chain: "aptos", + provider: provider_name(name), + status, + }); + } +} + +async fn run_sui( + cfg: &ForeignChainConfig, + vector: Option, + network: Network, + out: &mut Vec, +) { + let Some(vector) = vector else { + mark_skipped("sui", cfg, &no_reference_reason(network), out); + return; + }; + let timeout = timeout_of(cfg); + for (name, provider) in cfg.providers.iter() { + let status = match prepare_sui(provider, timeout) { + Err(e) => Status::Failed(format!("{e:#}")), + Ok(client) => run_check(timeout, checks::check_sui(client, vector.chain_id)).await, + }; + out.push(ProviderResult { + chain: "sui", + provider: provider_name(name), + status, + }); + } +} + +fn prepare_sui( + provider: &ForeignChainProviderConfig, + timeout: Duration, +) -> anyhow::Result { + let mut url = provider.rpc_url.clone(); + let auth = auth_config_to_rpc_auth(provider.auth.clone(), &mut url)?; + let header = match auth { + RpcAuthentication::KeyInUrl => None, + RpcAuthentication::CustomHeader { + header_name, + header_value, + } => Some((header_name, header_value)), + }; + GrpcSuiClient::new(url, header, timeout) + .map_err(|e| anyhow::anyhow!("failed to build the Sui gRPC client: {e}")) +} + +fn mark_skipped( + chain: &'static str, + cfg: &ForeignChainConfig, + reason: &str, + out: &mut Vec, +) { + for (name, _) in cfg.providers.iter() { + out.push(ProviderResult::skipped(chain, provider_name(name), reason)); + } +} + +/// A chain absent from the config has no providers to enumerate, so it gets a single +/// placeholder row — this way every supported chain shows up in the report. +fn mark_not_configured(chain: &'static str, out: &mut Vec) { + out.push(ProviderResult::skipped( + chain, + "-".to_string(), + "not configured", + )); +} + +#[cfg(test)] +#[expect(non_snake_case)] +mod tests { + use super::*; + use assert_matches::assert_matches; + use mpc_node_config::{AuthConfig, TokenConfig}; + use near_mpc_bounded_collections::NonEmptyBTreeMap; + use std::num::NonZeroU64; + + fn config_with_provider(auth: AuthConfig) -> ForeignChainConfig { + ForeignChainConfig { + timeout_sec: NonZeroU64::new(5).unwrap(), + max_retries: NonZeroU64::new(1).unwrap(), + providers: NonEmptyBTreeMap::new( + "only".to_string().into(), + ForeignChainProviderConfig { + rpc_url: "https://rpc.example.com".to_string(), + auth, + }, + ), + } + } + + #[tokio::test] + async fn check_all_providers__should_skip_configured_but_unsupported_chains() { + // Given + let fc = ForeignChainsConfig { + ethereum: Some(config_with_provider(AuthConfig::None)), + ..Default::default() + }; + + // When + let results = check_all_providers(&fc, Network::Mainnet).await; + + // Then — plus the placeholder row for unconfigured sui + assert_eq!(results.len(), 2); + assert_eq!(results[0].chain, "sui"); + assert_matches!(results[0].status, Status::Skipped(_)); + assert_eq!(results[1].chain, "ethereum"); + assert_matches!(results[1].status, Status::Skipped(_)); + } + + #[tokio::test] + async fn check_all_providers__should_fail_provider_when_env_token_is_unset() { + // Given + let auth = AuthConfig::Header { + name: http::HeaderName::from_static("authorization"), + scheme: Some("Bearer".to_string()), + token: TokenConfig::Env { + env: "FCCT_DEFINITELY_UNSET_TOKEN_ENV".to_string(), + }, + }; + let fc = ForeignChainsConfig { + base: Some(config_with_provider(auth)), + ..Default::default() + }; + + // When + let results = check_all_providers(&fc, Network::Mainnet).await; + + // Then + assert_eq!(results[0].chain, "base"); + let Status::Failed(reason) = &results[0].status else { + panic!("expected Failed, got a pass/skip"); + }; + assert!(reason.contains("FCCT_DEFINITELY_UNSET_TOKEN_ENV")); + } +} diff --git a/crates/foreign-chain-health-check/src/network.rs b/crates/foreign-chain-health-check/src/network.rs new file mode 100644 index 0000000000..9a8615597a --- /dev/null +++ b/crates/foreign-chain-health-check/src/network.rs @@ -0,0 +1,18 @@ +//! Network identifier for selecting golden reference transactions. Reference +//! transactions are network-specific (a mainnet transaction does not exist on +//! testnet and vice versa). + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Network { + Mainnet, + Testnet, +} + +impl Network { + pub fn label(self) -> &'static str { + match self { + Network::Mainnet => "mainnet", + Network::Testnet => "testnet", + } + } +} diff --git a/crates/foreign-chain-health-check/src/results.rs b/crates/foreign-chain-health-check/src/results.rs new file mode 100644 index 0000000000..e92aa5357d --- /dev/null +++ b/crates/foreign-chain-health-check/src/results.rs @@ -0,0 +1,25 @@ +//! Per-provider check outcome. + +#[derive(Debug)] +pub enum Status { + Passed, + Failed(String), + Skipped(String), +} + +#[derive(Debug)] +pub struct ProviderResult { + pub chain: &'static str, + pub provider: String, + pub status: Status, +} + +impl ProviderResult { + pub fn skipped(chain: &'static str, provider: String, reason: impl Into) -> Self { + Self { + chain, + provider, + status: Status::Skipped(reason.into()), + } + } +} From 673f7b939c3aedfa442a8eda838bf1a0f0638280 Mon Sep 17 00:00:00 2001 From: Haiyue Chen Date: Tue, 14 Jul 2026 13:45:33 +0200 Subject: [PATCH 2/7] test: cover pass/fail/skip in one check_all_providers run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #3797 review feedback: - Add an httpmock-driven check_all_providers test exercising a passing provider, a failing provider on the same chain, and a skipped (unsupported) chain in one run — proving one bad provider does not stop the others from being reported. - Keep the golden and checks modules crate-private; no consumer needs them (network and results stay public). - Drop the stale FCCT_ acronym from the test env-var name. --- crates/foreign-chain-health-check/src/lib.rs | 122 +++++++++++++++++-- 1 file changed, 114 insertions(+), 8 deletions(-) diff --git a/crates/foreign-chain-health-check/src/lib.rs b/crates/foreign-chain-health-check/src/lib.rs index ebb080ca82..8a271e8e84 100644 --- a/crates/foreign-chain-health-check/src/lib.rs +++ b/crates/foreign-chain-health-check/src/lib.rs @@ -3,8 +3,8 @@ //! `foreign-chain-config-tester` operator CLI and the MPC node's startup health //! check so the two never drift on golden vectors or auth handling. -pub mod checks; -pub mod golden; +mod checks; +mod golden; pub mod network; pub mod results; @@ -36,9 +36,8 @@ use crate::golden::{AptosVector, BlockHashVector, SuiVector}; /// provider. Each provider is checked independently: one bad provider does not /// stop the others from being reported. Chains that are configured but not yet /// supported by the node, or that have no reference transaction for `network`, -/// are reported as [`Status::Skipped`]. Sui, being newly supported, gets a -/// placeholder `Skipped` row even when absent from the config so its absence -/// is visible in reports. +/// are reported as [`Status::Skipped`]. An unconfigured sui still yields a +/// placeholder `Skipped` row so its absence is visible in reports. pub async fn check_all_providers( fc: &ForeignChainsConfig, network: Network, @@ -308,7 +307,7 @@ fn mark_skipped( reason: &str, out: &mut Vec, ) { - for (name, _) in cfg.providers.iter() { + for name in cfg.providers.keys() { out.push(ProviderResult::skipped(chain, provider_name(name), reason)); } } @@ -328,6 +327,7 @@ fn mark_not_configured(chain: &'static str, out: &mut Vec) { mod tests { use super::*; use assert_matches::assert_matches; + use httpmock::prelude::*; use mpc_node_config::{AuthConfig, TokenConfig}; use near_mpc_bounded_collections::NonEmptyBTreeMap; use std::num::NonZeroU64; @@ -365,6 +365,22 @@ mod tests { assert_matches!(results[1].status, Status::Skipped(_)); } + #[tokio::test] + async fn check_all_providers__should_report_absent_sui_as_not_configured() { + // Given — nothing configured + let fc = ForeignChainsConfig::default(); + + // When + let results = check_all_providers(&fc, Network::Mainnet).await; + + // Then — sui shows up anyway, reported "not configured" + assert!(results.iter().any(|r| r.chain == "sui")); + assert!(results.iter().all(|r| matches!( + &r.status, + Status::Skipped(reason) if reason.contains("not configured") + ))); + } + #[tokio::test] async fn check_all_providers__should_fail_provider_when_env_token_is_unset() { // Given @@ -372,7 +388,7 @@ mod tests { name: http::HeaderName::from_static("authorization"), scheme: Some("Bearer".to_string()), token: TokenConfig::Env { - env: "FCCT_DEFINITELY_UNSET_TOKEN_ENV".to_string(), + env: "DEFINITELY_UNSET_TOKEN_ENV".to_string(), }, }; let fc = ForeignChainsConfig { @@ -388,6 +404,96 @@ mod tests { let Status::Failed(reason) = &results[0].status else { panic!("expected Failed, got a pass/skip"); }; - assert!(reason.contains("FCCT_DEFINITELY_UNSET_TOKEN_ENV")); + assert!(reason.contains("DEFINITELY_UNSET_TOKEN_ENV")); + } + + fn aptos_event_body(tx: &str, type_tag: &str, sequence_number: u64) -> serde_json::Value { + serde_json::json!({ + "type": "block_metadata_transaction", + "hash": format!("0x{tx}"), + "success": true, + "events": [{ + "guid": { "creation_number": "0", "account_address": "0x1" }, + "sequence_number": sequence_number.to_string(), + "type": type_tag, + "data": { "epoch": "7510" } + }] + }) + } + + fn aptos_provider(rpc_url: String) -> ForeignChainProviderConfig { + ForeignChainProviderConfig { + rpc_url, + auth: AuthConfig::None, + } + } + + #[tokio::test] + async fn check_all_providers__should_report_pass_fail_and_skip_in_one_run() { + // Given — on the same chain, one Aptos provider serves the golden event + // (pass) and another serves a wrong event (fail); a separate unsupported + // chain is skipped. All three are exercised in a single run. + let healthy = MockServer::start_async().await; + let broken = MockServer::start_async().await; + let aptos = golden::golden_set(Network::Mainnet).aptos.unwrap(); + let tx = aptos.tx; + healthy + .mock_async(|when, then| { + when.method(GET) + .path(format!("/transactions/by_hash/0x{tx}")); + then.status(200).json_body(aptos_event_body( + tx, + aptos.event_type_tag, + aptos.event_sequence_number, + )); + }) + .await; + broken + .mock_async(|when, then| { + when.method(GET) + .path(format!("/transactions/by_hash/0x{tx}")); + then.status(200).json_body(aptos_event_body( + tx, + "0xdead::wrong::Event", + aptos.event_sequence_number, + )); + }) + .await; + + let mut providers = NonEmptyBTreeMap::new( + "healthy".to_string().into(), + aptos_provider(healthy.base_url()), + ); + providers.insert( + "broken".to_string().into(), + aptos_provider(broken.base_url()), + ); + let fc = ForeignChainsConfig { + aptos: Some(ForeignChainConfig { + timeout_sec: NonZeroU64::new(5).unwrap(), + max_retries: NonZeroU64::new(1).unwrap(), + providers, + }), + ethereum: Some(config_with_provider(AuthConfig::None)), + ..Default::default() + }; + + // When + let results = check_all_providers(&fc, Network::Mainnet).await; + + // Then — the broken provider does not stop the healthy one from being + // reported, the unsupported chain is skipped, and unconfigured sui + // gets its placeholder row. + assert_eq!(results.len(), 4); + let status = |chain: &str, provider: &str| { + results + .iter() + .find(|r| r.chain == chain && r.provider == provider) + .map(|r| &r.status) + .unwrap_or_else(|| panic!("missing result for {chain}/{provider}")) + }; + assert_matches!(status("aptos", "healthy"), Status::Passed); + assert_matches!(status("aptos", "broken"), Status::Failed(_)); + assert_matches!(status("ethereum", "only"), Status::Skipped(_)); } } From 9031612e810371f00f0af2f4033111405f170071 Mon Sep 17 00:00:00 2001 From: Haiyue Chen Date: Mon, 20 Jul 2026 11:26:51 +0200 Subject: [PATCH 3/7] refactor: parse --network via FromStr, tidy docs --- .../foreign-chain-config-tester/src/main.rs | 28 ++++------------- .../foreign-chain-config-tester/src/report.rs | 2 +- crates/foreign-chain-health-check/src/lib.rs | 30 ++++++++----------- .../foreign-chain-health-check/src/network.rs | 14 +++++++++ 4 files changed, 32 insertions(+), 42 deletions(-) diff --git a/crates/foreign-chain-config-tester/src/main.rs b/crates/foreign-chain-config-tester/src/main.rs index 6b73bb9481..fdb25e4cde 100644 --- a/crates/foreign-chain-config-tester/src/main.rs +++ b/crates/foreign-chain-config-tester/src/main.rs @@ -1,6 +1,5 @@ //! Foreign-chain RPC config tester: probe every configured provider with a fixed //! golden request so operators can verify their config without running the node. -//! The probe logic lives in `foreign-chain-health-check`, shared with the node. mod config; mod report; @@ -23,27 +22,10 @@ struct Args { #[arg(long)] config: PathBuf, - /// Network the reference transactions belong to. Auto-detected from the - /// config (`chain_id` / `mpc_contract_id`) when omitted. - #[arg(long, value_enum)] - network: Option, -} - -/// CLI mirror of [`Network`] so the shared library stays free of a `clap` -/// dependency. -#[derive(Clone, Copy, Debug, clap::ValueEnum)] -enum NetworkArg { - Mainnet, - Testnet, -} - -impl From for Network { - fn from(value: NetworkArg) -> Self { - match value { - NetworkArg::Mainnet => Network::Mainnet, - NetworkArg::Testnet => Network::Testnet, - } - } + /// Network the reference transactions belong to. Auto-detected from + /// the config (`chain_id` / `mpc_contract_id`) when omitted. + #[arg(long, value_name = "NETWORK")] + network: Option, } #[tokio::main] @@ -53,7 +35,7 @@ async fn main() -> anyhow::Result { .with_context(|| format!("failed to read {}", args.config.display()))?; let foreign_chains = config::parse_foreign_chains(&contents, &args.config)?; let network = match args.network { - Some(network) => network.into(), + Some(network) => network, None => config::detect_network(&contents, &args.config)?.ok_or_else(|| { anyhow::anyhow!( "could not determine network from config (no chain_id / mpc_contract_id found); \ diff --git a/crates/foreign-chain-config-tester/src/report.rs b/crates/foreign-chain-config-tester/src/report.rs index 629c48f42f..7786f40c9d 100644 --- a/crates/foreign-chain-config-tester/src/report.rs +++ b/crates/foreign-chain-config-tester/src/report.rs @@ -1,4 +1,4 @@ -//! Human-readable table rendering of the shared check results. +//! Human-readable table rendering of the check results. use std::fmt::Write as _; diff --git a/crates/foreign-chain-health-check/src/lib.rs b/crates/foreign-chain-health-check/src/lib.rs index 8a271e8e84..9c7e02da9a 100644 --- a/crates/foreign-chain-health-check/src/lib.rs +++ b/crates/foreign-chain-health-check/src/lib.rs @@ -1,7 +1,5 @@ //! Foreign-chain RPC provider health checks: probe every configured provider -//! with a fixed golden request and report a per-provider result. Shared by the -//! `foreign-chain-config-tester` operator CLI and the MPC node's startup health -//! check so the two never drift on golden vectors or auth handling. +//! with a fixed golden request and report a per-provider result. mod checks; mod golden; @@ -31,13 +29,11 @@ pub use results::{ProviderResult, Status}; use crate::golden::{AptosVector, BlockHashVector, SuiVector}; -/// Probe every configured provider on every configured chain with the golden -/// reference transaction for `network`, returning one [`ProviderResult`] per -/// provider. Each provider is checked independently: one bad provider does not -/// stop the others from being reported. Chains that are configured but not yet -/// supported by the node, or that have no reference transaction for `network`, -/// are reported as [`Status::Skipped`]. An unconfigured sui still yields a -/// placeholder `Skipped` row so its absence is visible in reports. +/// Probe every configured provider against `network`'s golden reference +/// transaction, one [`ProviderResult`] per provider, each checked independently. +/// Chains with no reference for `network`, or configured but unsupported, are +/// [`Status::Skipped`]; an unconfigured sui still yields a placeholder `Skipped` +/// result so its absence stays visible. pub async fn check_all_providers( fc: &ForeignChainsConfig, network: Network, @@ -312,8 +308,8 @@ fn mark_skipped( } } -/// A chain absent from the config has no providers to enumerate, so it gets a single -/// placeholder row — this way every supported chain shows up in the report. +/// A chain absent from the config has no providers to enumerate; emit one +/// placeholder [`ProviderResult`] so it still appears in the returned results. fn mark_not_configured(chain: &'static str, out: &mut Vec) { out.push(ProviderResult::skipped( chain, @@ -430,9 +426,8 @@ mod tests { #[tokio::test] async fn check_all_providers__should_report_pass_fail_and_skip_in_one_run() { - // Given — on the same chain, one Aptos provider serves the golden event - // (pass) and another serves a wrong event (fail); a separate unsupported - // chain is skipped. All three are exercised in a single run. + // Given — one Aptos provider serves the golden event (pass), another a + // wrong event (fail), and a separate chain is unsupported (skip). let healthy = MockServer::start_async().await; let broken = MockServer::start_async().await; let aptos = golden::golden_set(Network::Mainnet).aptos.unwrap(); @@ -481,9 +476,8 @@ mod tests { // When let results = check_all_providers(&fc, Network::Mainnet).await; - // Then — the broken provider does not stop the healthy one from being - // reported, the unsupported chain is skipped, and unconfigured sui - // gets its placeholder row. + // Then — the broken provider does not suppress the healthy one; 4 rows + // including the skipped chain and unconfigured sui's placeholder. assert_eq!(results.len(), 4); let status = |chain: &str, provider: &str| { results diff --git a/crates/foreign-chain-health-check/src/network.rs b/crates/foreign-chain-health-check/src/network.rs index 9a8615597a..b92b4adf8a 100644 --- a/crates/foreign-chain-health-check/src/network.rs +++ b/crates/foreign-chain-health-check/src/network.rs @@ -16,3 +16,17 @@ impl Network { } } } + +impl std::str::FromStr for Network { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "mainnet" => Ok(Network::Mainnet), + "testnet" => Ok(Network::Testnet), + other => Err(format!( + "unknown network `{other}`, expected `mainnet` or `testnet`" + )), + } + } +} From 0c82631b003e25fada9ce33285ee86865915110b Mon Sep 17 00:00:00 2001 From: Haiyue Chen Date: Mon, 20 Jul 2026 11:29:26 +0200 Subject: [PATCH 4/7] docs: fix 'latest checkpoint' wording, tidy sui probe comment --- crates/foreign-chain-health-check/src/checks.rs | 11 +++++------ crates/foreign-chain-health-check/src/golden.rs | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/foreign-chain-health-check/src/checks.rs b/crates/foreign-chain-health-check/src/checks.rs index a8f8e47b2a..800771bb58 100644 --- a/crates/foreign-chain-health-check/src/checks.rs +++ b/crates/foreign-chain-health-check/src/checks.rs @@ -150,7 +150,7 @@ const CHECKPOINT_PROBE_OFFSET: u64 = 10; /// Sui providers prune the gRPC read path after a few weeks, so unlike the other chains /// there is no long-lived reference transaction to pin extracted values against. Instead /// this verifies the provider's chain identity (the genesis digest never changes) and runs -/// the real inspector over a transaction from the provider's latest checkpoint. +/// the real inspector over a transaction from a recent checkpoint (a few behind the tip). pub async fn check_sui(client: impl SuiRpcClient, expected_chain_id: &str) -> anyhow::Result<()> { let info = client .get_service_info() @@ -181,7 +181,7 @@ pub async fn check_sui(client: impl SuiRpcClient, expected_chain_id: &str) -> an let checkpoint = client .get_checkpoint(probe_height) .await - .context("failed to fetch the latest checkpoint")? + .context("failed to fetch the probe checkpoint")? .checkpoint .context("provider returned no checkpoint")?; let digest = checkpoint @@ -192,10 +192,9 @@ pub async fn check_sui(client: impl SuiRpcClient, expected_chain_id: &str) -> an let tx = golden::base58_32(digest)?; let inspector = SuiInspector::new(client); - // Probe the first event so the extraction pipeline (BCS pass-through, address parsing, - // type-tag normalization, contents-name cross-check) is exercised whenever the probe - // transaction emits events. A transaction with no events (`LogIndexOutOfBounds`) or a - // failed one still proves the provider serves canonical checkpointed data. + // Probe the first event to exercise the full extraction pipeline when the tx emits + // events; a tx with no events (`LogIndexOutOfBounds`) or a failed one still proves the + // provider serves canonical checkpointed data. match inspector .extract( SuiTransactionDigest::from(tx), diff --git a/crates/foreign-chain-health-check/src/golden.rs b/crates/foreign-chain-health-check/src/golden.rs index 0c8b4ea307..d17aed2d11 100644 --- a/crates/foreign-chain-health-check/src/golden.rs +++ b/crates/foreign-chain-health-check/src/golden.rs @@ -23,7 +23,7 @@ pub struct AptosVector { /// Sui fullnodes prune the gRPC read path after a few weeks, so a fixed reference /// transaction would age out. The check instead verifies the provider's chain identity /// (the genesis checkpoint digest, which never changes) and probes a transaction -/// from the provider's latest checkpoint. +/// from a recent checkpoint (a few behind the tip). #[derive(Clone, Copy)] pub struct SuiVector { /// Base58 of the 32-byte genesis checkpoint digest, exactly as `get_service_info` From 3f08655f40eb2a0ebff1abf9f09fb54c6534c00f Mon Sep 17 00:00:00 2001 From: Haiyue Chen Date: Mon, 20 Jul 2026 11:50:30 +0200 Subject: [PATCH 5/7] refactor: dedicated ParseNetworkError, unify sui doc rationale --- Cargo.lock | 1 + crates/foreign-chain-health-check/Cargo.toml | 1 + .../foreign-chain-health-check/src/golden.rs | 6 ++-- crates/foreign-chain-health-check/src/lib.rs | 2 +- .../foreign-chain-health-check/src/network.rs | 28 +++++++++++++------ 5 files changed, 25 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 99065945d4..0ff949daae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3741,6 +3741,7 @@ dependencies = [ "mpc-node-config", "near-mpc-bounded-collections", "serde_json", + "thiserror 2.0.18", "tokio", ] diff --git a/crates/foreign-chain-health-check/Cargo.toml b/crates/foreign-chain-health-check/Cargo.toml index 98ded21e1e..969a1a89e4 100644 --- a/crates/foreign-chain-health-check/Cargo.toml +++ b/crates/foreign-chain-health-check/Cargo.toml @@ -13,6 +13,7 @@ foreign-chain-rpc-interfaces = { workspace = true } hex = { workspace = true } http = { workspace = true } mpc-node-config = { workspace = true } +thiserror = { workspace = true } tokio = { workspace = true } [dev-dependencies] diff --git a/crates/foreign-chain-health-check/src/golden.rs b/crates/foreign-chain-health-check/src/golden.rs index d17aed2d11..f87696449a 100644 --- a/crates/foreign-chain-health-check/src/golden.rs +++ b/crates/foreign-chain-health-check/src/golden.rs @@ -20,10 +20,8 @@ pub struct AptosVector { pub event_sequence_number: u64, } -/// Sui fullnodes prune the gRPC read path after a few weeks, so a fixed reference -/// transaction would age out. The check instead verifies the provider's chain identity -/// (the genesis checkpoint digest, which never changes) and probes a transaction -/// from a recent checkpoint (a few behind the tip). +/// Unlike other chains, Sui is verified by chain identity rather than a pinned +/// reference transaction — see [`check_sui`](crate::checks::check_sui). #[derive(Clone, Copy)] pub struct SuiVector { /// Base58 of the 32-byte genesis checkpoint digest, exactly as `get_service_info` diff --git a/crates/foreign-chain-health-check/src/lib.rs b/crates/foreign-chain-health-check/src/lib.rs index 9c7e02da9a..16db21d17d 100644 --- a/crates/foreign-chain-health-check/src/lib.rs +++ b/crates/foreign-chain-health-check/src/lib.rs @@ -24,7 +24,7 @@ use http::{HeaderName, HeaderValue}; use mpc_node_config::foreign_chains::RpcProviderName; use mpc_node_config::{ForeignChainConfig, ForeignChainProviderConfig, ForeignChainsConfig}; -pub use network::Network; +pub use network::{Network, ParseNetworkError}; pub use results::{ProviderResult, Status}; use crate::golden::{AptosVector, BlockHashVector, SuiVector}; diff --git a/crates/foreign-chain-health-check/src/network.rs b/crates/foreign-chain-health-check/src/network.rs index b92b4adf8a..795d6e8d87 100644 --- a/crates/foreign-chain-health-check/src/network.rs +++ b/crates/foreign-chain-health-check/src/network.rs @@ -9,24 +9,36 @@ pub enum Network { } impl Network { + pub const ALL: &'static [Network] = &[Network::Mainnet, Network::Testnet]; + pub fn label(self) -> &'static str { match self { Network::Mainnet => "mainnet", Network::Testnet => "testnet", } } + + fn labels() -> String { + Self::ALL + .iter() + .map(|n| n.label()) + .collect::>() + .join(", ") + } } +#[derive(Debug, thiserror::Error)] +#[error("expected one of: {}", Network::labels())] +pub struct ParseNetworkError; + impl std::str::FromStr for Network { - type Err = String; + type Err = ParseNetworkError; fn from_str(s: &str) -> Result { - match s { - "mainnet" => Ok(Network::Mainnet), - "testnet" => Ok(Network::Testnet), - other => Err(format!( - "unknown network `{other}`, expected `mainnet` or `testnet`" - )), - } + Self::ALL + .iter() + .copied() + .find(|n| n.label() == s) + .ok_or(ParseNetworkError) } } From c6894029911fc788d0b60921e656587364886a2f Mon Sep 17 00:00:00 2001 From: Haiyue Chen Date: Mon, 20 Jul 2026 12:16:35 +0200 Subject: [PATCH 6/7] fix: restore not-configured rows for all chains; address review nits --- .../foreign-chain-health-check/src/checks.rs | 2 +- crates/foreign-chain-health-check/src/lib.rs | 90 ++++++++++++++----- .../foreign-chain-health-check/src/network.rs | 2 +- 3 files changed, 69 insertions(+), 25 deletions(-) diff --git a/crates/foreign-chain-health-check/src/checks.rs b/crates/foreign-chain-health-check/src/checks.rs index 800771bb58..6ed3528691 100644 --- a/crates/foreign-chain-health-check/src/checks.rs +++ b/crates/foreign-chain-health-check/src/checks.rs @@ -150,7 +150,7 @@ const CHECKPOINT_PROBE_OFFSET: u64 = 10; /// Sui providers prune the gRPC read path after a few weeks, so unlike the other chains /// there is no long-lived reference transaction to pin extracted values against. Instead /// this verifies the provider's chain identity (the genesis digest never changes) and runs -/// the real inspector over a transaction from a recent checkpoint (a few behind the tip). +/// the real inspector over a transaction [`CHECKPOINT_PROBE_OFFSET`] checkpoints behind the tip. pub async fn check_sui(client: impl SuiRpcClient, expected_chain_id: &str) -> anyhow::Result<()> { let info = client .get_service_info() diff --git a/crates/foreign-chain-health-check/src/lib.rs b/crates/foreign-chain-health-check/src/lib.rs index 16db21d17d..1568e6a5dd 100644 --- a/crates/foreign-chain-health-check/src/lib.rs +++ b/crates/foreign-chain-health-check/src/lib.rs @@ -3,8 +3,8 @@ mod checks; mod golden; -pub mod network; -pub mod results; +mod network; +mod results; use std::future::Future; use std::time::Duration; @@ -32,8 +32,8 @@ use crate::golden::{AptosVector, BlockHashVector, SuiVector}; /// Probe every configured provider against `network`'s golden reference /// transaction, one [`ProviderResult`] per provider, each checked independently. /// Chains with no reference for `network`, or configured but unsupported, are -/// [`Status::Skipped`]; an unconfigured sui still yields a placeholder `Skipped` -/// result so its absence stays visible. +/// [`Status::Skipped`]; a chain absent from the config still yields a single +/// placeholder `Skipped` result so its absence stays visible. pub async fn check_all_providers( fc: &ForeignChainsConfig, network: Network, @@ -43,30 +43,48 @@ pub async fn check_all_providers( if let Some(cfg) = &fc.base { run_evm::("base", cfg, golden.base, network, &mut out).await; + } else { + mark_not_configured("base", &mut out); } if let Some(cfg) = &fc.bnb { run_evm::("bnb", cfg, golden.bnb, network, &mut out).await; + } else { + mark_not_configured("bnb", &mut out); } if let Some(cfg) = &fc.arbitrum { run_evm::("arbitrum", cfg, golden.arbitrum, network, &mut out).await; + } else { + mark_not_configured("arbitrum", &mut out); } if let Some(cfg) = &fc.polygon { run_evm::("polygon", cfg, golden.polygon, network, &mut out).await; + } else { + mark_not_configured("polygon", &mut out); } if let Some(cfg) = &fc.hyper_evm { run_evm::("hyper_evm", cfg, golden.hyper_evm, network, &mut out).await; + } else { + mark_not_configured("hyper_evm", &mut out); } if let Some(cfg) = &fc.abstract_chain { run_evm::("abstract", cfg, golden.abstract_chain, network, &mut out).await; + } else { + mark_not_configured("abstract", &mut out); } if let Some(cfg) = &fc.bitcoin { run_bitcoin(cfg, golden.bitcoin, network, &mut out).await; + } else { + mark_not_configured("bitcoin", &mut out); } if let Some(cfg) = &fc.starknet { run_starknet(cfg, golden.starknet, network, &mut out).await; + } else { + mark_not_configured("starknet", &mut out); } if let Some(cfg) = &fc.aptos { run_aptos(cfg, golden.aptos, network, &mut out).await; + } else { + mark_not_configured("aptos", &mut out); } if let Some(cfg) = &fc.sui { run_sui(cfg, golden.sui, network, &mut out).await; @@ -74,12 +92,16 @@ pub async fn check_all_providers( mark_not_configured("sui", &mut out); } - // Configured but not yet supported by the node. + // Configured but not yet supported by the node (see verify_foreign_tx/sign.rs). if let Some(cfg) = &fc.ethereum { mark_skipped("ethereum", cfg, "not yet supported by the node", &mut out); + } else { + mark_not_configured("ethereum", &mut out); } if let Some(cfg) = &fc.solana { mark_skipped("solana", cfg, "not yet supported by the node", &mut out); + } else { + mark_not_configured("solana", &mut out); } out @@ -344,7 +366,7 @@ mod tests { #[tokio::test] async fn check_all_providers__should_skip_configured_but_unsupported_chains() { - // Given + // Given a configured but not-yet-supported chain let fc = ForeignChainsConfig { ethereum: Some(config_with_provider(AuthConfig::None)), ..Default::default() @@ -353,28 +375,51 @@ mod tests { // When let results = check_all_providers(&fc, Network::Mainnet).await; - // Then — plus the placeholder row for unconfigured sui - assert_eq!(results.len(), 2); - assert_eq!(results[0].chain, "sui"); - assert_matches!(results[0].status, Status::Skipped(_)); - assert_eq!(results[1].chain, "ethereum"); - assert_matches!(results[1].status, Status::Skipped(_)); + // Then it is reported skipped as unsupported, not probed + let ethereum = results + .iter() + .find(|r| r.chain == "ethereum") + .expect("ethereum row"); + assert_matches!( + ðereum.status, + Status::Skipped(reason) if reason.contains("not yet supported") + ); } #[tokio::test] - async fn check_all_providers__should_report_absent_sui_as_not_configured() { - // Given — nothing configured + async fn check_all_providers__should_report_every_absent_chain_as_not_configured() { + // Given nothing configured let fc = ForeignChainsConfig::default(); // When let results = check_all_providers(&fc, Network::Mainnet).await; - // Then — sui shows up anyway, reported "not configured" - assert!(results.iter().any(|r| r.chain == "sui")); - assert!(results.iter().all(|r| matches!( - &r.status, - Status::Skipped(reason) if reason.contains("not configured") - ))); + // Then every known chain still appears, each with a "not configured" placeholder + let expected = [ + "base", + "bnb", + "arbitrum", + "polygon", + "hyper_evm", + "abstract", + "bitcoin", + "starknet", + "aptos", + "sui", + "ethereum", + "solana", + ]; + for chain in expected { + let row = results + .iter() + .find(|r| r.chain == chain) + .unwrap_or_else(|| panic!("missing row for {chain}")); + assert_matches!( + &row.status, + Status::Skipped(reason) if reason.contains("not configured") + ); + } + assert_eq!(results.len(), expected.len()); } #[tokio::test] @@ -476,9 +521,8 @@ mod tests { // When let results = check_all_providers(&fc, Network::Mainnet).await; - // Then — the broken provider does not suppress the healthy one; 4 rows - // including the skipped chain and unconfigured sui's placeholder. - assert_eq!(results.len(), 4); + // Then — the broken provider does not suppress the healthy one; pass, + // fail, and skip all coexist in a single run. let status = |chain: &str, provider: &str| { results .iter() diff --git a/crates/foreign-chain-health-check/src/network.rs b/crates/foreign-chain-health-check/src/network.rs index 795d6e8d87..651eeae4f5 100644 --- a/crates/foreign-chain-health-check/src/network.rs +++ b/crates/foreign-chain-health-check/src/network.rs @@ -9,7 +9,7 @@ pub enum Network { } impl Network { - pub const ALL: &'static [Network] = &[Network::Mainnet, Network::Testnet]; + pub(crate) const ALL: &'static [Network] = &[Network::Mainnet, Network::Testnet]; pub fn label(self) -> &'static str { match self { From 58380d580d2f769266bcffae72c897193730aebf Mon Sep 17 00:00:00 2001 From: Haiyue Chen Date: Tue, 21 Jul 2026 11:20:13 +0200 Subject: [PATCH 7/7] refactor: derive ValueEnum behind optional clap feature, tidy sui docs --- Cargo.lock | 2 +- crates/foreign-chain-config-tester/Cargo.toml | 2 +- .../foreign-chain-config-tester/src/main.rs | 3 ++- crates/foreign-chain-health-check/Cargo.toml | 5 +++- crates/foreign-chain-health-check/src/lib.rs | 9 +++++-- .../foreign-chain-health-check/src/network.rs | 27 +------------------ 6 files changed, 16 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0ff949daae..be88fe365d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3732,6 +3732,7 @@ dependencies = [ "anyhow", "assert_matches", "bs58 0.5.1", + "clap", "foreign-chain-inspector", "foreign-chain-rpc-auth", "foreign-chain-rpc-interfaces", @@ -3741,7 +3742,6 @@ dependencies = [ "mpc-node-config", "near-mpc-bounded-collections", "serde_json", - "thiserror 2.0.18", "tokio", ] diff --git a/crates/foreign-chain-config-tester/Cargo.toml b/crates/foreign-chain-config-tester/Cargo.toml index 97539031cd..5017e53d39 100644 --- a/crates/foreign-chain-config-tester/Cargo.toml +++ b/crates/foreign-chain-config-tester/Cargo.toml @@ -11,7 +11,7 @@ path = "src/main.rs" [dependencies] anyhow = { workspace = true } clap = { workspace = true } -foreign-chain-health-check = { workspace = true } +foreign-chain-health-check = { workspace = true, features = ["clap"] } mpc-node-config = { workspace = true } serde = { workspace = true } serde_yaml = { workspace = true } diff --git a/crates/foreign-chain-config-tester/src/main.rs b/crates/foreign-chain-config-tester/src/main.rs index fdb25e4cde..f3775153d3 100644 --- a/crates/foreign-chain-config-tester/src/main.rs +++ b/crates/foreign-chain-config-tester/src/main.rs @@ -1,5 +1,6 @@ //! Foreign-chain RPC config tester: probe every configured provider with a fixed //! golden request so operators can verify their config without running the node. +//! Sui is probed differently — see the README. mod config; mod report; @@ -24,7 +25,7 @@ struct Args { /// Network the reference transactions belong to. Auto-detected from /// the config (`chain_id` / `mpc_contract_id`) when omitted. - #[arg(long, value_name = "NETWORK")] + #[arg(long, value_enum)] network: Option, } diff --git a/crates/foreign-chain-health-check/Cargo.toml b/crates/foreign-chain-health-check/Cargo.toml index 969a1a89e4..1098570f6e 100644 --- a/crates/foreign-chain-health-check/Cargo.toml +++ b/crates/foreign-chain-health-check/Cargo.toml @@ -4,16 +4,19 @@ version.workspace = true edition.workspace = true license.workspace = true +[features] +clap = ["dep:clap"] + [dependencies] anyhow = { workspace = true } bs58 = { workspace = true } +clap = { workspace = true, optional = true } foreign-chain-inspector = { workspace = true } foreign-chain-rpc-auth = { workspace = true } foreign-chain-rpc-interfaces = { workspace = true } hex = { workspace = true } http = { workspace = true } mpc-node-config = { workspace = true } -thiserror = { workspace = true } tokio = { workspace = true } [dev-dependencies] diff --git a/crates/foreign-chain-health-check/src/lib.rs b/crates/foreign-chain-health-check/src/lib.rs index 1568e6a5dd..cc63e9d88f 100644 --- a/crates/foreign-chain-health-check/src/lib.rs +++ b/crates/foreign-chain-health-check/src/lib.rs @@ -1,5 +1,6 @@ //! Foreign-chain RPC provider health checks: probe every configured provider -//! with a fixed golden request and report a per-provider result. +//! with a fixed golden request and report a per-provider result. Sui is the +//! exception — see `run_sui`. mod checks; mod golden; @@ -24,7 +25,7 @@ use http::{HeaderName, HeaderValue}; use mpc_node_config::foreign_chains::RpcProviderName; use mpc_node_config::{ForeignChainConfig, ForeignChainProviderConfig, ForeignChainsConfig}; -pub use network::{Network, ParseNetworkError}; +pub use network::Network; pub use results::{ProviderResult, Status}; use crate::golden::{AptosVector, BlockHashVector, SuiVector}; @@ -278,6 +279,10 @@ async fn run_aptos( } } +/// Sui differs from the other probes: its providers prune historical +/// transactions, so there is no long-lived golden transaction to check +/// against. The probe verifies the provider's chain identity instead — see +/// [`checks::check_sui`] for the mechanism. async fn run_sui( cfg: &ForeignChainConfig, vector: Option, diff --git a/crates/foreign-chain-health-check/src/network.rs b/crates/foreign-chain-health-check/src/network.rs index 651eeae4f5..a91ca6ae7e 100644 --- a/crates/foreign-chain-health-check/src/network.rs +++ b/crates/foreign-chain-health-check/src/network.rs @@ -3,42 +3,17 @@ //! testnet and vice versa). #[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] pub enum Network { Mainnet, Testnet, } impl Network { - pub(crate) const ALL: &'static [Network] = &[Network::Mainnet, Network::Testnet]; - pub fn label(self) -> &'static str { match self { Network::Mainnet => "mainnet", Network::Testnet => "testnet", } } - - fn labels() -> String { - Self::ALL - .iter() - .map(|n| n.label()) - .collect::>() - .join(", ") - } -} - -#[derive(Debug, thiserror::Error)] -#[error("expected one of: {}", Network::labels())] -pub struct ParseNetworkError; - -impl std::str::FromStr for Network { - type Err = ParseNetworkError; - - fn from_str(s: &str) -> Result { - Self::ALL - .iter() - .copied() - .find(|n| n.label() == s) - .ok_or(ParseNetworkError) - } }