diff --git a/Cargo.lock b/Cargo.lock index aa5bb11753..c4b90ac8b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3714,6 +3714,20 @@ 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", @@ -3727,11 +3741,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 696db3f87f..d3f5265d87 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..5017e53d39 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, features = ["clap"] } 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..f3775153d3 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. +//! Sui is probed differently — see the README. -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. /// @@ -42,8 +23,8 @@ struct Args { #[arg(long)] config: PathBuf, - /// Network the reference transactions belong to. Auto-detected from the - /// config (`chain_id` / `mpc_contract_id`) when omitted. + /// Network the reference transactions belong to. Auto-detected from + /// the config (`chain_id` / `mpc_contract_id`) when omitted. #[arg(long, value_enum)] network: Option, } @@ -64,7 +45,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 +54,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..7786f40c9d 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 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..1098570f6e --- /dev/null +++ b/crates/foreign-chain-health-check/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "foreign-chain-health-check" +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 } +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 94% rename from crates/foreign-chain-config-tester/src/checks.rs rename to crates/foreign-chain-health-check/src/checks.rs index 779141e1d8..6ed3528691 100644 --- a/crates/foreign-chain-config-tester/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 [`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() @@ -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), @@ -253,6 +252,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 +274,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 +359,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 +375,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 +397,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 93% rename from crates/foreign-chain-config-tester/src/golden.rs rename to crates/foreign-chain-health-check/src/golden.rs index 34465dd474..f87696449a 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)] @@ -33,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 the provider's latest checkpoint. +/// 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 new file mode 100644 index 0000000000..cc63e9d88f --- /dev/null +++ b/crates/foreign-chain-health-check/src/lib.rs @@ -0,0 +1,542 @@ +//! Foreign-chain RPC provider health checks: probe every configured provider +//! with a fixed golden request and report a per-provider result. Sui is the +//! exception — see `run_sui`. + +mod checks; +mod golden; +mod network; +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 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`]; 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, +) -> 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, + }); + } +} + +/// 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, + 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; 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, + "-".to_string(), + "not configured", + )); +} + +#[cfg(test)] +#[expect(non_snake_case)] +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; + + 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 a configured but not-yet-supported chain + let fc = ForeignChainsConfig { + ethereum: Some(config_with_provider(AuthConfig::None)), + ..Default::default() + }; + + // When + let results = check_all_providers(&fc, Network::Mainnet).await; + + // 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_every_absent_chain_as_not_configured() { + // Given nothing configured + let fc = ForeignChainsConfig::default(); + + // When + let results = check_all_providers(&fc, Network::Mainnet).await; + + // 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] + 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: "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("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 — 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(); + 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 suppress the healthy one; pass, + // fail, and skip all coexist in a single run. + 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(_)); + } +} 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..a91ca6ae7e --- /dev/null +++ b/crates/foreign-chain-health-check/src/network.rs @@ -0,0 +1,19 @@ +//! 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)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +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()), + } + } +}