diff --git a/Cargo.lock b/Cargo.lock index b8c41b2a89..a4198ea153 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3724,15 +3724,16 @@ dependencies = [ "anyhow", "assert_matches", "bs58 0.5.1", - "clap", "foreign-chain-inspector", "foreign-chain-rpc-auth", "foreign-chain-rpc-interfaces", "hex", "http", "httpmock", + "jsonrpsee", "mpc-node-config", "near-mpc-bounded-collections", + "serde", "serde_json", "tokio", ] @@ -5983,6 +5984,7 @@ dependencies = [ "ed25519-dalek", "elliptic-curve", "flume", + "foreign-chain-health-check", "foreign-chain-inspector", "foreign-chain-rpc-auth", "foreign-chain-rpc-interfaces", diff --git a/crates/e2e-tests/README.md b/crates/e2e-tests/README.md index ab6eac0c79..7b54c0f1ef 100644 --- a/crates/e2e-tests/README.md +++ b/crates/e2e-tests/README.md @@ -217,6 +217,7 @@ pub struct MpcClusterConfig { pub struct ForeignChainsClusterConfig { pub node_configs: Vec, // per-node; empty = default for all + pub node_health_check_identities: Vec, // per-node startup-check identities pub whitelisted_chains: BTreeSet, // voted in during setup } diff --git a/crates/e2e-tests/src/cluster.rs b/crates/e2e-tests/src/cluster.rs index 73a69db54a..5e311545d9 100644 --- a/crates/e2e-tests/src/cluster.rs +++ b/crates/e2e-tests/src/cluster.rs @@ -110,6 +110,9 @@ pub struct ForeignChainsClusterConfig { /// Per-node configs. If empty, all nodes get the default (empty) config; /// if non-empty, must have exactly `num_nodes` entries. pub node_configs: Vec, + /// Per-node expected identities for the startup foreign-chain health check. + /// Nodes without an entry get the default (empty) identities. + pub node_health_check_identities: Vec, pub whitelist: BTreeMap, } @@ -1392,6 +1395,12 @@ fn start_mpc_nodes( } else { config.foreign_chains.node_configs[i].clone() }; + let health_check_identities = config + .foreign_chains + .node_health_check_identities + .get(i) + .cloned() + .unwrap_or_default(); let setup = MpcNodeSetup::new(MpcNodeSetupArgs { node_index: i, @@ -1408,6 +1417,7 @@ fn start_mpc_nodes( near_genesis_path: genesis_path.clone(), near_boot_nodes: boot_nodes.clone(), foreign_chains_config, + health_check_identities, })?; nodes.push(MpcNodeState::Running(setup.start()?)); } @@ -1435,6 +1445,7 @@ fn start_mpc_nodes( near_genesis_path: genesis_path.clone(), near_boot_nodes: boot_nodes.clone(), foreign_chains_config: Default::default(), + health_check_identities: Default::default(), })?; nodes.push(MpcNodeState::Running(setup.start()?)); } diff --git a/crates/e2e-tests/src/foreign_chain_mock.rs b/crates/e2e-tests/src/foreign_chain_mock.rs index e30ec007c4..1328cf144d 100644 --- a/crates/e2e-tests/src/foreign_chain_mock.rs +++ b/crates/e2e-tests/src/foreign_chain_mock.rs @@ -215,6 +215,85 @@ pub fn setup_evm_mock(server: &MockServer, auth: MockAuthExpectation) -> usize { mock_id } +/// Number of JSON-RPC calls a healthy EVM identity probe makes: `eth_chainId`, the finalized +/// head, the probe block, the receipt, the finality-head re-check, and the canonical block. +pub const EVM_IDENTITY_PROBE_CALLS: usize = 6; + +/// Mock EVM JSON-RPC server for the startup identity health check. Serves `chain_id_hex` for +/// `eth_chainId`, a finalized head at 0x64 (100), and the probe block at head − 10 = 0x5a (90) +/// carrying one transaction whose receipt is canonical and succeeded. A provider whose +/// `chain_id_hex` decodes to the configured identity passes; a different one is rejected right +/// after the `eth_chainId` call, so its hit count stays at 1. +pub fn setup_evm_identity_mock( + server: &MockServer, + auth: MockAuthExpectation, + chain_id_hex: &str, +) -> usize { + // Head 0x64 (100); the probe block is head − HEAD_PROBE_OFFSET(10) = 0x5a (90), which is also + // the transaction's (and canonical) block, so one block response serves every height lookup. + const FINALIZED_HEAD: &str = "0x64"; + const PROBE_BLOCK: &str = "0x5a"; + let chain_id_hex = chain_id_hex.to_string(); + let mock_id = server + .mock(|when, then| { + auth.apply(when.method(POST)); + then.respond_with(move |req: &HttpMockRequest| { + let body: serde_json::Value = + serde_json::from_slice(req.body().as_ref()).expect("valid json-rpc request"); + let id = body["id"].clone(); + let method = body["method"].as_str().expect("method field"); + + let result = match method { + "eth_chainId" => serde_json::Value::String(chain_id_hex.clone()), + "eth_getBlockByNumber" => { + let block_id = body["params"][0].as_str().expect("block id param"); + if block_id.starts_with("0x") { + // A specific height: the probe/canonical block, carrying the tx. + serde_json::json!({ + "number": block_id, + "hash": format!("0x{MOCK_BLOCK_HASH}"), + "transactions": [format!("0x{MOCK_TX_ID}")], + }) + } else { + // A finality tag ("finalized"): the head. + serde_json::json!({ + "number": FINALIZED_HEAD, + "hash": format!("0x{MOCK_BLOCK_HASH}"), + "transactions": [], + }) + } + } + "eth_getTransactionReceipt" => { + let transaction_hash = body["params"][0].as_str().expect("tx hash param"); + serde_json::json!({ + "transactionHash": transaction_hash, + "blockHash": format!("0x{MOCK_BLOCK_HASH}"), + "blockNumber": PROBE_BLOCK, + "status": "0x1", + // No logs: the probe treats an out-of-bounds log index as healthy. + "logs": [], + }) + } + other => return jsonrpc_error(id, other), + }; + + let response_body = serde_json::json!({ + "jsonrpc": "2.0", + "result": result, + "id": id, + }); + HttpMockResponse::builder() + .status(200) + .header("content-type", "application/json") + .body(serde_json::to_string(&response_body).unwrap()) + .build() + }); + }) + .id; + register_unauthorized_catch_all(server, &auth); + mock_id +} + pub fn setup_starknet_mock(server: &MockServer, auth: MockAuthExpectation) -> usize { let mock_id = server .mock(|when, then| { diff --git a/crates/e2e-tests/src/mpc_node.rs b/crates/e2e-tests/src/mpc_node.rs index 3dd3437463..5516d13429 100644 --- a/crates/e2e-tests/src/mpc_node.rs +++ b/crates/e2e-tests/src/mpc_node.rs @@ -199,6 +199,8 @@ pub struct MpcNodeSetup { // Foreign chains configuration foreign_chains_config: mpc_node_config::ForeignChainsConfig, + // Expected identities for the startup foreign-chain health check. + health_check_identities: mpc_node_config::ExpectedIdentities, // Config file path (written on creation) config_path: PathBuf, @@ -240,6 +242,7 @@ impl MpcNodeSetup { triples_to_buffer: args.triples_to_buffer, presignatures_to_buffer: args.presignatures_to_buffer, foreign_chains_config: args.foreign_chains_config, + health_check_identities: args.health_check_identities, config_path, }; @@ -488,6 +491,9 @@ impl MpcNodeSetup { ckd: CKDConfig { timeout_sec: 60 }, keygen: KeygenConfig { timeout_sec: 60 }, foreign_chains: self.foreign_chains_config.clone(), + foreign_chain_health_check: mpc_node_config::ForeignChainHealthCheckConfig { + identities: self.health_check_identities.clone(), + }, }, pccs_endpoints: mpc_node_config::default_pccs_endpoints(), }; @@ -522,6 +528,8 @@ pub struct MpcNodeSetupArgs { pub near_boot_nodes: String, /// Foreign chains configuration for this node. pub foreign_chains_config: mpc_node_config::ForeignChainsConfig, + /// Expected chain identities for this node's startup foreign-chain health check. + pub health_check_identities: mpc_node_config::ExpectedIdentities, } /// Ports allocated for a single MPC node. diff --git a/crates/e2e-tests/tests/common.rs b/crates/e2e-tests/tests/common.rs index 816d0dd0a8..747858d016 100644 --- a/crates/e2e-tests/tests/common.rs +++ b/crates/e2e-tests/tests/common.rs @@ -40,6 +40,7 @@ pub const SIGTERM_HANDLER_PORT_SEED: u16 = 22; pub const DISTINCT_RECONSTRUCTION_THRESHOLDS_PORT_SEED: u16 = 23; pub const UPDATE_PARTICIPANT_URL_PORT_SEED: u16 = 24; pub const AVAILABLE_FOREIGN_CHAINS_PORT_SEED: u16 = 25; +pub const STARTUP_FOREIGN_CHAIN_HEALTH_PORT_SEED: u16 = 26; /// Start a cluster, wait for Running state and presignatures to buffer. /// diff --git a/crates/e2e-tests/tests/e2e.rs b/crates/e2e-tests/tests/e2e.rs index 6ea1a76866..b4c7c292a8 100644 --- a/crates/e2e-tests/tests/e2e.rs +++ b/crates/e2e-tests/tests/e2e.rs @@ -14,6 +14,7 @@ mod parallel_sign_calls; mod request_during_resharing; mod request_lifecycle; mod sigterm_handler; +mod startup_foreign_chain_health; mod submit_participant_info; mod timeout_metric; mod update_participant_url; diff --git a/crates/e2e-tests/tests/startup_foreign_chain_health.rs b/crates/e2e-tests/tests/startup_foreign_chain_health.rs new file mode 100644 index 0000000000..a8f5d049b2 --- /dev/null +++ b/crates/e2e-tests/tests/startup_foreign_chain_health.rs @@ -0,0 +1,139 @@ +use std::num::NonZeroU64; +use std::time::{Duration, Instant}; + +use crate::common; + +use e2e_tests::foreign_chain_mock::{ + EVM_IDENTITY_PROBE_CALLS, MockAuthExpectation, MockServerExt, setup_evm_identity_mock, +}; +use httpmock::MockServer; +use mpc_node_config::{ + ExpectedIdentities, ForeignChainConfig, ForeignChainProviderConfig, ForeignChainsConfig, +}; +use near_mpc_bounded_collections::NonEmptyBTreeMap; + +const PROBE_TIMEOUT: Duration = Duration::from_secs(60); +/// `base` mainnet chain id: decimal in the config identity, hex over `eth_chainId`. +const BASE_CHAIN_ID: &str = "8453"; +const BASE_CHAIN_ID_HEX: &str = "0x2105"; +/// A different network's chain id, to make a provider fail the identity check. +const WRONG_CHAIN_ID_HEX: &str = "0x1"; + +fn provider(rpc_url: String) -> ForeignChainProviderConfig { + ForeignChainProviderConfig { + rpc_url, + auth: Default::default(), + } +} + +fn base_chains( + providers: NonEmptyBTreeMap< + mpc_node_config::foreign_chains::RpcProviderName, + ForeignChainProviderConfig, + >, +) -> ForeignChainsConfig { + ForeignChainsConfig { + base: Some(ForeignChainConfig { + timeout_sec: NonZeroU64::new(30).unwrap(), + max_retries: NonZeroU64::new(3).unwrap(), + providers, + }), + ..Default::default() + } +} + +/// Poll a mock's hit count until it reaches `target`, or fail after `timeout`. Used to +/// synchronize on the detached startup probe without matching log lines. +async fn wait_for_calls(mock: &MockServerExt, target: usize, timeout: Duration) { + let start = Instant::now(); + loop { + if mock.calls() >= target { + return; + } + if start.elapsed() >= timeout { + panic!( + "timed out waiting for {target} calls; the provider got {}", + mock.calls() + ); + } + tokio::time::sleep(Duration::from_millis(250)).await; + } +} + +/// The startup foreign-chain health check runs on the real mpc-node startup path, probing each +/// node's configured providers by chain identity plus a recent transaction. +/// +/// Node 0 (`base` + the matching identity, two providers) passes one and fails the other; +/// node 1 (`base` configured but no identity) fails before contacting its provider; node 2 +/// (nothing configured) probes nothing. +#[tokio::test] +#[expect(non_snake_case)] +async fn startup_health_check__should_probe_providers_by_chain_identity() { + // Given three mock providers: `healthy` reports the expected chain id and serves a + // verifiable recent transaction, `broken` reports a different network's chain id, and + // `idle` (node 1's) must never be contacted. + let healthy_server = MockServer::start_async().await; + let broken_server = MockServer::start_async().await; + let idle_server = MockServer::start_async().await; + let healthy_id = setup_evm_identity_mock( + &healthy_server, + MockAuthExpectation::None, + BASE_CHAIN_ID_HEX, + ); + let broken_id = setup_evm_identity_mock( + &broken_server, + MockAuthExpectation::None, + WRONG_CHAIN_ID_HEX, + ); + let idle_id = + setup_evm_identity_mock(&idle_server, MockAuthExpectation::None, BASE_CHAIN_ID_HEX); + let healthy_mock = MockServerExt::new(healthy_server, healthy_id); + let broken_mock = MockServerExt::new(broken_server, broken_id); + let idle_mock = MockServerExt::new(idle_server, idle_id); + + let mut node0_providers = NonEmptyBTreeMap::new( + "healthy".to_string().into(), + provider(healthy_mock.server.base_url()), + ); + node0_providers.insert( + "broken".to_string().into(), + provider(broken_mock.server.base_url()), + ); + let node0_chains = base_chains(node0_providers); + let node1_chains = base_chains(NonEmptyBTreeMap::new( + "idle".to_string().into(), + provider(idle_mock.server.base_url()), + )); + + // Node 0 has the `base` identity; node 1 has none, so its `base` check fails without probing. + let node0_identities = ExpectedIdentities { + base: Some(BASE_CHAIN_ID.to_string()), + ..Default::default() + }; + + // When + let (cluster, _running) = + common::must_setup_cluster(common::STARTUP_FOREIGN_CHAIN_HEALTH_PORT_SEED, |c| { + c.foreign_chains.node_configs = + vec![node0_chains, node1_chains, ForeignChainsConfig::default()]; + c.foreign_chains.node_health_check_identities = vec![node0_identities]; + }) + .await; + + // Then — node 0's healthy provider ran the full identity probe over HTTP. + wait_for_calls(&healthy_mock, EVM_IDENTITY_PROBE_CALLS, PROBE_TIMEOUT).await; + // The wrong-network provider is rejected right after the chain-id call. + assert_eq!( + broken_mock.calls(), + 1, + "broken provider reported the wrong chain id and must be rejected after eth_chainId" + ); + // Node 1 configured `base` but no identity, so its check fails before touching the provider. + assert_eq!( + idle_mock.calls(), + 0, + "node 1 has no configured identity and must fail before probing its provider" + ); + + drop(cluster); +} diff --git a/crates/foreign-chain-config-tester/Cargo.toml b/crates/foreign-chain-config-tester/Cargo.toml index 5017e53d39..97539031cd 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, features = ["clap"] } +foreign-chain-health-check = { workspace = true } mpc-node-config = { workspace = true } serde = { workspace = true } serde_yaml = { workspace = true } diff --git a/crates/foreign-chain-config-tester/README.md b/crates/foreign-chain-config-tester/README.md index bafe45f834..21cae85e4f 100644 --- a/crates/foreign-chain-config-tester/README.md +++ b/crates/foreign-chain-config-tester/README.md @@ -5,13 +5,41 @@ config, so a misconfiguration (unreachable URL, wrong/expired API key, or a provider pointed at the wrong network) is caught before the node hits it in production. -For each configured provider it runs a fixed request against a known reference -transaction — the same inspector and auth handling the node uses — and compares -the result against a known-good value. Sui is the exception: its providers prune -transactions after a few weeks, so the check instead verifies the provider's -chain identity and inspects a transaction from its latest checkpoint. Every -provider is checked independently: one bad provider does not stop the others -from being reported. +For each configured provider it verifies the provider's chain identity (a +constant that is never pruned) against the configured expected value, then runs +the node's real inspector — with the same auth handling the node uses — over a +recently produced transaction, so the check exercises the production path +without depending on months-old archived history. Every provider is checked +independently: one bad provider does not stop the others from being reported. + +The expected identity of each identity-probed chain comes from configuration — +there are no built-in values, so the check works for any network, including +local or custom ones. A configured chain without an identity fails its check: + +```yaml +foreign_chain_health_check: + identities: + starknet: "0x534e5f4d41494e" # felt; decode hex as ASCII + base: "8453" # EVM numeric chain id + bitcoin: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" # genesis hash + aptos: "1" # ledger chain id + sui: "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S" # base58 genesis checkpoint digest +``` + +Well-known values: + +| Chain | Identity | Mainnet | Testnet | +|----------|-------------------------|------------------------------------------------|------------------------------------------------| +| starknet | `starknet_chainId` felt | `0x534e5f4d41494e` (`SN_MAIN`) | `0x534e5f5345504f4c4941` (`SN_SEPOLIA`) | +| base | `eth_chainId` | `8453` | | +| bnb | `eth_chainId` | `56` | | +| arbitrum | `eth_chainId` | `42161` | | +| polygon | `eth_chainId` | `137` | | +| hyper_evm| `eth_chainId` | `999` | | +| abstract | `eth_chainId` | `2741` | `11124` | +| bitcoin | genesis block hash | `000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f` | `000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943` (testnet3) | +| aptos | ledger `chain_id` | `1` | `2` | +| sui | genesis digest (base58) | `4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S` | `69WiPg3DAQiwdxfncX6wYQ2siKwAe6L9BZthQea3JNMD` | ## Usage @@ -26,16 +54,6 @@ cargo run -p foreign-chain-config-tester -- --config /path/to/user-config.toml - the launcher config (`foreign_chains` under `node`); - the legacy `config.yaml` (`foreign_chains` at the top level). -### Network - -Reference transactions are network-specific. The network is auto-detected from -the config (`chain_id`, falling back to `mpc_contract_id`). Override it — or set -it for configs that carry no such field — with `--network`: - -```bash -cargo run -p foreign-chain-config-tester -- --config user-config.toml --network testnet -``` - ## Output A row per provider, a summary line, and the reason for each failure listed diff --git a/crates/foreign-chain-config-tester/src/config.rs b/crates/foreign-chain-config-tester/src/config.rs index cd23b835af..b8f606edf6 100644 --- a/crates/foreign-chain-config-tester/src/config.rs +++ b/crates/foreign-chain-config-tester/src/config.rs @@ -7,12 +7,9 @@ use std::path::Path; use anyhow::{Context, bail}; -use mpc_node_config::{ChainId, ForeignChainsConfig}; -use serde::Deserialize; -use serde::de::IntoDeserializer; -use serde::de::value::{Error as ValueError, StrDeserializer}; +use mpc_node_config::ForeignChainsConfig; -use foreign_chain_health_check::Network; +use foreign_chain_health_check::ExpectedIdentities; /// Paths where `foreign_chains` may live, most-nested first so a wrapped config /// matches before a barer one. @@ -22,34 +19,19 @@ const FOREIGN_CHAINS_PATHS: &[&[&str]] = &[ &["foreign_chains"], ]; -const CHAIN_ID_PATHS: &[&[&str]] = &[ - &["mpc_node_config", "near_init", "chain_id"], - &["near_init", "chain_id"], +/// Where the per-chain expected identities live: an `identities` map (chain label -> +/// expected identity) under a sibling of `foreign_chains`. +const EXPECTED_IDENTITY_PATHS: &[&[&str]] = &[ + &[ + "mpc_node_config", + "node", + "foreign_chain_health_check", + "identities", + ], + &["node", "foreign_chain_health_check", "identities"], + &["foreign_chain_health_check", "identities"], ]; -const CONTRACT_ID_PATHS: &[&[&str]] = &[ - &["mpc_node_config", "node", "indexer", "mpc_contract_id"], - &["node", "indexer", "mpc_contract_id"], - &["indexer", "mpc_contract_id"], -]; - -fn classify_network(chain_id: Option<&str>, contract_id: Option<&str>) -> Option { - let parsed = chain_id.and_then(|id| { - let de: StrDeserializer<'_, ValueError> = id.into_deserializer(); - ChainId::deserialize(de).ok() - }); - match parsed { - Some(ChainId::Mainnet) => return Some(Network::Mainnet), - Some(ChainId::Testnet) => return Some(Network::Testnet), - _ => {} - } - match contract_id { - Some(id) if id.ends_with(".testnet") => Some(Network::Testnet), - Some(id) if id.ends_with(".near") || id == "v1.signer" => Some(Network::Mainnet), - _ => None, - } -} - enum Format { Yaml, Toml, @@ -63,62 +45,61 @@ fn format_from_path(path: &Path) -> anyhow::Result { } } -/// Empty when no `foreign_chains` section is present. -pub fn parse_foreign_chains(contents: &str, path: &Path) -> anyhow::Result { +/// Deserializes the subtree at the first of `paths` present in the config into `T`; +/// `T::default()` when none matches. +fn find_and_parse( + contents: &str, + path: &Path, + paths: &[&[&str]], + what: &str, +) -> anyhow::Result +where + T: serde::de::DeserializeOwned + Default, +{ match format_from_path(path)? { Format::Yaml => { let root: serde_yaml::Value = serde_yaml::from_str(contents).context("parse YAML config")?; - for keys in FOREIGN_CHAINS_PATHS { + for keys in paths { if let Some(section) = keys.iter().try_fold(&root, |v, k| v.get(k)) { return serde_yaml::from_value(section.clone()) - .context("parse foreign_chains section"); + .with_context(|| format!("parse {what} section")); } } - Ok(ForeignChainsConfig::default()) + Ok(T::default()) } Format::Toml => { let root: toml::Value = toml::from_str(contents).context("parse TOML config")?; - for keys in FOREIGN_CHAINS_PATHS { + for keys in paths { if let Some(section) = keys.iter().try_fold(&root, |v, k| v.get(*k)) { return section .clone() .try_into() - .context("parse foreign_chains section"); + .with_context(|| format!("parse {what} section")); } } - Ok(ForeignChainsConfig::default()) + Ok(T::default()) } } } -fn toml_str<'a>(root: &'a toml::Value, path: &[&str]) -> Option<&'a str> { - path.iter().try_fold(root, |v, k| v.get(*k))?.as_str() +/// Empty when no `foreign_chains` section is present. +pub fn parse_foreign_chains(contents: &str, path: &Path) -> anyhow::Result { + find_and_parse(contents, path, FOREIGN_CHAINS_PATHS, "foreign_chains") } -fn yaml_str<'a>(root: &'a serde_yaml::Value, path: &[&str]) -> Option<&'a str> { - path.iter().try_fold(root, |v, k| v.get(k))?.as_str() -} - -/// `None` when the config carries no conclusive network signal. -pub fn detect_network(contents: &str, path: &Path) -> anyhow::Result> { - Ok(match format_from_path(path)? { - Format::Yaml => { - let root: serde_yaml::Value = - serde_yaml::from_str(contents).context("parse YAML config")?; - classify_network( - CHAIN_ID_PATHS.iter().find_map(|p| yaml_str(&root, p)), - CONTRACT_ID_PATHS.iter().find_map(|p| yaml_str(&root, p)), - ) - } - Format::Toml => { - let root: toml::Value = toml::from_str(contents).context("parse TOML config")?; - classify_network( - CHAIN_ID_PATHS.iter().find_map(|p| toml_str(&root, p)), - CONTRACT_ID_PATHS.iter().find_map(|p| toml_str(&root, p)), - ) - } - }) +/// Per-chain expected identities from config. Absent chains stay `None` (their check then +/// fails until configured); an unknown chain key or non-string value is a hard error. +pub fn detect_expected_identities( + contents: &str, + path: &Path, +) -> anyhow::Result { + find_and_parse( + contents, + path, + EXPECTED_IDENTITY_PATHS, + "foreign_chain_health_check.identities", + ) } #[cfg(test)] @@ -222,48 +203,55 @@ foreign_chains: } #[test] - fn detect_network__should_read_chain_id_from_dstack_toml() { - // Given - let toml = "[mpc_node_config.near_init]\nchain_id = \"testnet\"\n"; + fn detect_expected_identities__should_read_seeded_value_from_dstack_toml() { + // Given an `identities` map nested under the dstack `mpc_node_config.node` prefix + let toml = "[mpc_node_config.node.foreign_chain_health_check.identities]\n\ + starknet = \"0x534e5f4d41494e\"\n"; // When - let network = detect_network(toml, Path::new("user-config.toml")).unwrap(); + let ids = detect_expected_identities(toml, Path::new("user-config.toml")).unwrap(); // Then - assert_eq!(network, Some(Network::Testnet)); + assert_eq!(ids.starknet.as_deref(), Some("0x534e5f4d41494e")); } #[test] - fn detect_network__should_fall_back_to_contract_id() { + fn detect_expected_identities__should_read_seeded_value_from_top_level_yaml() { // Given - let yaml = "indexer:\n mpc_contract_id: v1.signer-prod.testnet\n"; + let yaml = "foreign_chain_health_check:\n identities:\n starknet: \"0x534e5f5345504f4c4941\"\n"; // When - let network = detect_network(yaml, Path::new("config.yaml")).unwrap(); + let ids = detect_expected_identities(yaml, Path::new("config.yaml")).unwrap(); // Then - assert_eq!(network, Some(Network::Testnet)); + assert_eq!(ids.starknet.as_deref(), Some("0x534e5f5345504f4c4941")); } #[test] - fn detect_network__should_classify_mainnet_contract_id() { - // Given - let yaml = "indexer:\n mpc_contract_id: v1.signer\n"; + fn detect_expected_identities__should_reject_non_string_value() { + // Given a known chain's identity mistyped as a number (easy to do in TOML) + let toml = "[foreign_chain_health_check.identities]\nstarknet = 1\n"; - // When - let network = detect_network(yaml, Path::new("config.yaml")).unwrap(); + // When / Then — a loud parse error, not a silently dropped entry + detect_expected_identities(toml, Path::new("config.toml")).unwrap_err(); + } - // Then - assert_eq!(network, Some(Network::Mainnet)); + #[test] + fn detect_expected_identities__should_reject_unknown_chain_key() { + // Given a misspelled chain label + let toml = "[foreign_chain_health_check.identities]\nstartknet = \"0x1\"\n"; + + // When / Then — the typo errors instead of silently doing nothing + detect_expected_identities(toml, Path::new("config.toml")).unwrap_err(); } #[test] - fn detect_network__should_return_none_without_signal() { - // Given - // When - let network = detect_network("home_dir = \"/data\"\n", Path::new("config.toml")).unwrap(); + fn detect_expected_identities__should_be_empty_when_absent() { + // Given / When + let ids = + detect_expected_identities("home_dir = \"/data\"\n", Path::new("config.toml")).unwrap(); // Then - assert_eq!(network, None); + assert!(ids.starknet.is_none() && ids.sui.is_none()); } } diff --git a/crates/foreign-chain-config-tester/src/main.rs b/crates/foreign-chain-config-tester/src/main.rs index f3775153d3..7524d86aee 100644 --- a/crates/foreign-chain-config-tester/src/main.rs +++ b/crates/foreign-chain-config-tester/src/main.rs @@ -1,6 +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. +//! Foreign-chain RPC config tester: probe every configured provider so operators can +//! verify their config without running the node. Each provider is checked by chain +//! identity plus a dynamically discovered transaction — see the README. mod config; mod report; @@ -11,22 +11,17 @@ use std::process::ExitCode; use anyhow::Context; use clap::Parser; -use foreign_chain_health_check::{Network, check_all_providers}; +use foreign_chain_health_check::check_all_providers; /// Verify a node's foreign-chain RPC provider configuration. /// -/// Probes every configured provider with a fixed golden request. +/// Probes every configured provider by chain identity and a recent transaction. #[derive(Parser)] #[command(about, long_about = None)] struct Args { /// Path to the config file to check (`.yaml`, `.yml`, or `.toml`). #[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, } #[tokio::main] @@ -35,17 +30,9 @@ async fn main() -> anyhow::Result { let contents = fs::read_to_string(&args.config) .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, - 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); \ - pass --network mainnet|testnet" - ) - })?, - }; + let identities = config::detect_expected_identities(&contents, &args.config)?; - let results = check_all_providers(&foreign_chains, network).await; + let results = check_all_providers(&foreign_chains, &identities).await; print!("{}", report::render(&results)); Ok(if report::any_failed(&results) { diff --git a/crates/foreign-chain-health-check/Cargo.toml b/crates/foreign-chain-health-check/Cargo.toml index 1098570f6e..febc37f963 100644 --- a/crates/foreign-chain-health-check/Cargo.toml +++ b/crates/foreign-chain-health-check/Cargo.toml @@ -4,25 +4,24 @@ 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 } +jsonrpsee = { workspace = true } mpc-node-config = { workspace = true } +serde = { workspace = true } tokio = { workspace = true } [dev-dependencies] assert_matches = { workspace = true } httpmock = { workspace = true } near-mpc-bounded-collections = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } [lints] diff --git a/crates/foreign-chain-health-check/src/checks.rs b/crates/foreign-chain-health-check/src/checks.rs index 6ed3528691..4e4e6b633e 100644 --- a/crates/foreign-chain-health-check/src/checks.rs +++ b/crates/foreign-chain-health-check/src/checks.rs @@ -1,4 +1,6 @@ -//! Per-provider golden checks: run a fixed request and verify the extracted value. +//! Per-provider probes: verify the provider's chain identity against the configured +//! expected value, then run the real inspector over a dynamically discovered recent +//! transaction. use std::time::Duration; @@ -7,17 +9,16 @@ use foreign_chain_inspector::ForeignChainInspectionError; use foreign_chain_inspector::{ BlockConfirmations, EthereumFinality, ForeignChainInspector, aptos::{ - AptosExtractedValue, AptosTransactionHash, + AptosTransactionHash, inspector::{AptosExtractor, AptosFinality, AptosInspector}, }, bitcoin::{ - BitcoinExtractedValue, BitcoinTransactionHash, + BitcoinTransactionHash, inspector::{BitcoinExtractor, BitcoinInspector}, }, - evm::inspector::{EvmChain, EvmExtractedValue, EvmExtractor, EvmInspector}, - http_client::HttpClient, + evm::inspector::{EvmChain, EvmExtractor, EvmInspector}, starknet::{ - StarknetExtractedValue, StarknetTransactionHash, + StarknetTransactionHash, inspector::{StarknetExtractor, StarknetFinality, StarknetInspector}, }, sui::{ @@ -26,10 +27,13 @@ use foreign_chain_inspector::{ }, }; use foreign_chain_rpc_interfaces::aptos::ReqwestAptosClient; +use foreign_chain_rpc_interfaces::evm::{BlockNumberOrTag, FinalityTag, U64}; +use foreign_chain_rpc_interfaces::starknet::{BlockId, BlockTag}; use foreign_chain_rpc_interfaces::sui::SuiRpcClient; use http::{HeaderName, HeaderValue}; +use jsonrpsee::core::client::ClientT; -use crate::golden; +use crate::parse; /// Typed "wrong network / wrong value" failures, so tests can assert on the kind instead of /// matching error-message substrings. Wrapped into `anyhow::Error` on the way out, so the @@ -38,8 +42,6 @@ use crate::golden; pub enum Mismatch { ChainId { expected: String, got: String }, BlockHash { expected: [u8; 32], got: [u8; 32] }, - EventTypeTag { expected: String, got: String }, - EventSequenceNumber { expected: u64, got: u64 }, } impl std::fmt::Display for Mismatch { @@ -55,92 +57,256 @@ impl std::fmt::Display for Mismatch { hex::encode(expected), hex::encode(got), ), - Self::EventTypeTag { expected, got } => write!( - f, - "event type tag mismatch: expected {expected}, got {got} — is this provider on the expected network?" - ), - Self::EventSequenceNumber { expected, got } => write!( - f, - "event sequence number mismatch: expected {expected}, got {got}" - ), } } } impl std::error::Error for Mismatch {} -fn verify_block_hash(expected: [u8; 32], got: [u8; 32]) -> anyhow::Result<()> { - if got != expected { - return Err(Mismatch::BlockHash { expected, got }.into()); +/// How far below a chain's reported head a probe takes its block, so slightly lagging backends +/// behind one provider URL still agree the block is final. +const HEAD_PROBE_OFFSET: u64 = 10; + +/// How many earlier blocks a probe scans when a block carries no transactions (quiet networks +/// still produce blocks on a timer, so empty blocks are normal). +const EMPTY_BLOCK_WALKBACK_LIMIT: u64 = 10; + +/// Treats a probe transaction as healthy when it verifies, or when it fails in a way that +/// reflects the transaction itself (reverted, or no log at the index) rather than the provider — +/// both still prove the provider serves canonical, final data. Any other error is a real failure. +fn accept_probe_outcome( + outcome: Result, ForeignChainInspectionError>, + failure_context: &'static str, +) -> anyhow::Result<()> { + match outcome { + Ok(_) + | Err(ForeignChainInspectionError::TransactionFailed) + | Err(ForeignChainInspectionError::LogIndexOutOfBounds) => Ok(()), + Err(e) => Err(e).context(failure_context), } - Ok(()) } -pub async fn check_evm( - client: HttpClient, - tx: [u8; 32], - expected_block_hash: [u8; 32], -) -> anyhow::Result<()> +/// Verifies the network via `eth_chainId`, then runs the inspector at `Finalized` over a +/// transaction from a recent finalized block — walking back past empty ones. +pub async fn check_evm(client: C, expected_chain_id: &str) -> anyhow::Result<()> where Chain: EvmChain + Send + Sync, + C: ClientT + Send + Sync, { - let inspector = EvmInspector::::new(client); - let values = inspector + let inspector = EvmInspector::::new(client); + + let expected = parse::chain_id_u64(expected_chain_id).context("invalid expected chain id")?; + let got = inspector + .chain_id() + .await + .context("failed to fetch chain id")?; + if got != expected { + return Err(Mismatch::ChainId { + expected: expected.to_string(), + got: got.to_string(), + } + .into()); + } + + let head = inspector + .block_with_txs(BlockNumberOrTag::Tag(FinalityTag::Finalized)) + .await + .context("failed to fetch the finalized block")?; + let probe_number = head + .number + .as_u64() + .checked_sub(HEAD_PROBE_OFFSET) + .with_context(|| { + format!( + "finalized height {} is below the probe offset {HEAD_PROBE_OFFSET}", + head.number + ) + })?; + let mut block = inspector + .block_with_txs(BlockNumberOrTag::Number(U64::from(probe_number))) + .await + .context("failed to fetch the probe block")?; + let mut walked_back = 0; + let tx = loop { + if let Some(tx) = block.transactions.first() { + break Chain::TransactionHash::from(*tx.as_fixed_bytes()); + } + walked_back += 1; + if walked_back > EMPTY_BLOCK_WALKBACK_LIMIT { + bail!( + "no transactions in the probe block or the {EMPTY_BLOCK_WALKBACK_LIMIT} blocks before it" + ); + } + let earlier = block + .number + .as_u64() + .checked_sub(1) + .context("walked back past the genesis block without finding a transaction")?; + block = inspector + .block_with_txs(BlockNumberOrTag::Number(U64::from(earlier))) + .await + .context("failed to fetch an earlier finalized block")?; + }; + + let outcome = inspector .extract( - Chain::TransactionHash::from(tx), + tx, EthereumFinality::Finalized, - vec![EvmExtractor::BlockHash], + vec![EvmExtractor::Log { log_index: 0 }], ) - .await?; - match values.into_iter().next().context("RPC returned no value")? { - EvmExtractedValue::BlockHash(hash) => { - let got: [u8; 32] = hash.into(); - verify_block_hash(expected_block_hash, got) - } - EvmExtractedValue::Log(_) => bail!("expected a block hash, got a log"), - } + .await; + accept_probe_outcome( + outcome, + "failed to inspect a transaction from the finalized block", + ) } -pub async fn check_bitcoin( - client: HttpClient, - tx: [u8; 32], - expected_block_hash: [u8; 32], -) -> anyhow::Result<()> { +/// Identifies the network via the genesis block hash (`getblockhash 0`, never pruned — the +/// provider must actually hold block 0), then inspects a transaction from a recent block. +pub async fn check_bitcoin(client: C, expected_genesis: &str) -> anyhow::Result<()> +where + C: ClientT + Send + Sync, +{ let inspector = BitcoinInspector::new(client); - let values = inspector + + let expected = parse::hex32(expected_genesis).context("invalid expected genesis hash")?; + let genesis = inspector + .block_hash(0) + .await + .context("failed to fetch the genesis block hash")?; + if *genesis != expected { + return Err(Mismatch::BlockHash { + expected, + got: *genesis, + } + .into()); + } + + // Tip height via getbestblockhash + getblock rather than getblockcount: some provider + // edges serve getblockcount as a JSON-RPC 1.0-style response (`"error": null`) that the + // 2.0 transport rejects. + let tip = inspector + .best_block_hash() + .await + .context("failed to fetch the best block hash")?; + let height = inspector + .block(tip) + .await + .context("failed to fetch the best block")? + .height; + let probe = height.checked_sub(HEAD_PROBE_OFFSET).with_context(|| { + format!("chain height {height} is below the probe offset {HEAD_PROBE_OFFSET}") + })?; + let hash = inspector + .block_hash(probe) + .await + .context("failed to fetch a recent block hash")?; + let block = inspector + .block(hash) + .await + .context("failed to fetch a recent block")?; + // The first entry is the block's coinbase, so every well-formed block carries one. + let tx = block + .tx + .first() + .context("recent block carries no transactions")?; + let tx = BitcoinTransactionHash::from(**tx); + + // A canonical, confirmed transaction extracts cleanly; Bitcoin has no failed-tx concept. + inspector .extract( - BitcoinTransactionHash::from(tx), + tx, BlockConfirmations::from(1), vec![BitcoinExtractor::BlockHash], ) - .await?; - match values.into_iter().next().context("RPC returned no value")? { - BitcoinExtractedValue::BlockHash(hash) => { - let got: [u8; 32] = hash.into(); - verify_block_hash(expected_block_hash, got) - } - } + .await + .context("failed to inspect a transaction from a recent block")?; + Ok(()) } -pub async fn check_starknet( - client: HttpClient, - tx: [u8; 32], - expected_block_hash: [u8; 32], -) -> anyhow::Result<()> { +/// Cap on the exponential walk-back (the step doubles each try) before giving up. +const MAX_WALKBACK_BLOCKS: u64 = 1024; + +/// Verifies the network via `starknet_chainId`, then runs the inspector at `AcceptedOnL1` +/// over a transaction from a recent block — walking back past empty or not-yet-final ones. +/// Requires provider JSON-RPC v0.9+ (the `l1_accepted` block tag). +pub async fn check_starknet(client: C, expected_chain_id: &str) -> anyhow::Result<()> +where + C: ClientT + Send + Sync, +{ let inspector = StarknetInspector::new(client); - let values = inspector - .extract( - StarknetTransactionHash::from(tx), - StarknetFinality::AcceptedOnL1, - vec![StarknetExtractor::BlockHash], - ) - .await?; - match values.into_iter().next().context("RPC returned no value")? { - StarknetExtractedValue::BlockHash(hash) => { - let got: [u8; 32] = hash.into(); - verify_block_hash(expected_block_hash, got) + + let chain_id = inspector + .chain_id() + .await + .context("failed to fetch chain id")?; + let expected = parse::felt32(expected_chain_id).context("invalid expected chain id")?; + if *chain_id.as_fixed_bytes() != expected { + return Err(Mismatch::ChainId { + expected: expected_chain_id.to_string(), + got: format!("{chain_id:#x}"), } - StarknetExtractedValue::Log(_) => bail!("expected a block hash, got a log"), + .into()); + } + + let head = inspector + .block_with_tx_hashes(BlockId::Tag(BlockTag::L1Accepted)) + .await + .context("failed to fetch the latest L1-accepted block")?; + let probe_number = head + .block_number + .checked_sub(HEAD_PROBE_OFFSET) + .with_context(|| { + format!( + "chain height {} is below the probe offset {HEAD_PROBE_OFFSET}", + head.block_number + ) + })?; + let mut block = inspector + .block_with_tx_hashes(BlockId::Number { + block_number: probe_number, + }) + .await + .context("failed to fetch the probe block")?; + + // Walk back until a transaction verifies. An empty block or a `NotFinalized` receipt (a + // lagging load-balanced backend) steps earlier; any other error is a real failure. + let mut step = 1; + let mut walked_back = 0; + loop { + if let Some(tx) = block.transactions.first() { + let tx_hash = StarknetTransactionHash::from(*tx.as_fixed_bytes()); + match inspector + .extract( + tx_hash, + StarknetFinality::AcceptedOnL1, + vec![StarknetExtractor::Log { log_index: 0 }], + ) + .await + { + Ok(_) + | Err(ForeignChainInspectionError::TransactionFailed) + | Err(ForeignChainInspectionError::LogIndexOutOfBounds) => return Ok(()), + Err(ForeignChainInspectionError::NotFinalized) => {} + Err(e) => { + return Err(e).context("failed to inspect a transaction from a recent block"); + } + } + } + walked_back += step; + if walked_back > MAX_WALKBACK_BLOCKS { + bail!("no L1-final transaction within {MAX_WALKBACK_BLOCKS} blocks of the probe block"); + } + let earlier = block.block_number.checked_sub(step).context( + "walked back past the genesis block without finding an L1-final transaction", + )?; + block = inspector + .block_with_tx_hashes(BlockId::Number { + block_number: earlier, + }) + .await + .context("failed to fetch an earlier block")?; + step *= 2; } } @@ -189,72 +355,547 @@ pub async fn check_sui(client: impl SuiRpcClient, expected_chain_id: &str) -> an .first() .and_then(|tx| tx.digest.as_deref()) .context("latest checkpoint carries no transaction digest")?; - let tx = golden::base58_32(digest)?; + let tx = parse::base58_32(digest)?; let inspector = SuiInspector::new(client); - // 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 + let outcome = inspector .extract( SuiTransactionDigest::from(tx), SuiFinality::Checkpointed, vec![SuiExtractor::Event { event_index: 0 }], ) - .await - { - Ok(_) - | Err(ForeignChainInspectionError::TransactionFailed) - | Err(ForeignChainInspectionError::LogIndexOutOfBounds) => Ok(()), - Err(e) => Err(e).context("failed to inspect a transaction from the latest checkpoint"), - } + .await; + accept_probe_outcome( + outcome, + "failed to inspect a transaction from the latest checkpoint", + ) } +/// How far behind the ledger version the Aptos probe transaction is taken from. +const APTOS_VERSION_PROBE_OFFSET: u64 = 100; + +/// Identifies the network via the ledger `chain_id` (`GET /v1`), then inspects a recent +/// committed transaction. pub async fn check_aptos( url: String, auth_header: Option<(HeaderName, HeaderValue)>, timeout: Duration, - tx: [u8; 32], - expected_type_tag: &str, - expected_sequence_number: u64, + expected_chain_id: &str, ) -> anyhow::Result<()> { - let inspector = AptosInspector::new(ReqwestAptosClient::new(url, auth_header, timeout)); - let values = inspector + let client = ReqwestAptosClient::new(url, auth_header, timeout); + + let expected = parse::chain_id_u64(expected_chain_id).context("invalid expected chain id")?; + let info = client + .get_ledger_info() + .await + .context("failed to fetch ledger info")?; + if u64::from(info.chain_id) != expected { + return Err(Mismatch::ChainId { + expected: expected.to_string(), + got: info.chain_id.to_string(), + } + .into()); + } + + let ledger_version: u64 = info + .ledger_version + .parse() + .context("provider returned an unparseable ledger version")?; + let probe = ledger_version + .checked_sub(APTOS_VERSION_PROBE_OFFSET) + .with_context(|| { + format!( + "ledger version {ledger_version} is below the probe offset {APTOS_VERSION_PROBE_OFFSET}" + ) + })?; + let tx = client + .get_transaction_by_version(probe) + .await + .context("failed to fetch a recent transaction")?; + let hash = + parse::hex32(&tx.hash).context("provider returned an unparseable transaction hash")?; + + let inspector = AptosInspector::new(client); + let outcome = inspector .extract( - AptosTransactionHash::from(tx), + AptosTransactionHash::from(hash), AptosFinality::Committed, vec![AptosExtractor::Event { event_index: 0 }], ) - .await?; - match values.into_iter().next().context("RPC returned no value")? { - AptosExtractedValue::Event(event) => { - if event.type_tag != expected_type_tag { - return Err(Mismatch::EventTypeTag { - expected: expected_type_tag.to_string(), - got: event.type_tag.clone(), - } - .into()); - } - if event.sequence_number != expected_sequence_number { - return Err(Mismatch::EventSequenceNumber { - expected: expected_sequence_number, - got: event.sequence_number, - } - .into()); - } - Ok(()) - } - } + .await; + accept_probe_outcome(outcome, "failed to inspect a recent transaction") } #[cfg(test)] #[expect(non_snake_case)] mod tests { use super::*; - use crate::golden; - use crate::network::Network; + use crate::parse; use assert_matches::assert_matches; + use foreign_chain_inspector::base::inspector::Base; + use foreign_chain_rpc_interfaces::bitcoin::{ + GetBlockHeaderVerboseResponse, GetBlockResponse, GetRawTransactionVerboseResponse, + TransportBitcoinBlockHash, TransportBitcoinTransactionHash, + }; + use foreign_chain_rpc_interfaces::evm::{ + GetBlockByNumberResponse as EvmBlock, GetBlockByNumberWithTxsResponse as EvmBlockWithTxs, + GetTransactionReceiptResponse as EvmReceipt, U64, + }; + use foreign_chain_rpc_interfaces::starknet::{ + GetBlockWithTxHashesResponse, GetTransactionReceiptResponse, H256, StarknetEvent, + StarknetExecutionStatus, StarknetFinalityStatus, + }; use httpmock::prelude::*; + use jsonrpsee::core::client::{BatchResponse, error::Error as RpcError}; + use jsonrpsee::core::params::BatchRequestBuilder; + use std::sync::atomic::{AtomicUsize, Ordering}; + + /// A [`ClientT`] that returns queued JSON values in call order, ignoring method and params + /// (mirrors the inspector crate's sequential mock). Lets `check_starknet`'s fixed call + /// sequence — chainId, L1-accepted head, probe block, receipt, canonical block — be scripted. + struct SequentialMockClient { + responses: Vec, + calls: AtomicUsize, + } + + impl SequentialMockClient { + fn new(responses: Vec) -> Self { + Self { + responses, + calls: AtomicUsize::new(0), + } + } + } + + impl ClientT for SequentialMockClient { + async fn request(&self, _method: &str, _params: Params) -> Result + where + R: serde::de::DeserializeOwned, + { + let i = self.calls.fetch_add(1, Ordering::SeqCst); + let value = self.responses.get(i).cloned().unwrap_or_else(|| { + panic!( + "mock received call #{} but only {} responses were queued", + i + 1, + self.responses.len() + ) + }); + serde_json::from_value(value).map_err(RpcError::ParseError) + } + + async fn notification( + &self, + _method: &str, + _params: Params, + ) -> Result<(), RpcError> { + unimplemented!("notification() not used in tests") + } + + async fn batch_request<'a, R>( + &self, + _batch: BatchRequestBuilder<'a>, + ) -> Result, RpcError> + where + R: serde::de::DeserializeOwned + std::fmt::Debug + 'a, + { + unimplemented!("batch_request() not used in tests") + } + } + + const STARKNET_MAINNET_CHAIN_ID: &str = "0x534e5f4d41494e"; + + fn starknet_receipt() -> GetTransactionReceiptResponse { + GetTransactionReceiptResponse { + block_hash: H256::from([4; 32]), + block_number: 842_750, + events: vec![StarknetEvent { + data: vec![H256::from([0xab; 32])], + from_address: H256::from([0x11; 32]), + keys: vec![H256::from([0xcc; 32])], + }], + finality_status: StarknetFinalityStatus::AcceptedOnL1, + execution_status: StarknetExecutionStatus::Succeeded, + } + } + + fn json(value: impl serde::Serialize) -> serde_json::Value { + serde_json::to_value(value).unwrap() + } + + #[tokio::test] + async fn check_starknet__should_pass_when_chain_id_matches_and_a_recent_tx_verifies() { + // Given a provider on the expected network whose probe block (10 below the latest + // L1-accepted head) carries a transaction the inspector can verify (final, canonical, succeeded). + let receipt = starknet_receipt(); + let head = GetBlockWithTxHashesResponse { + block_hash: H256::from([9; 32]), + block_number: 900_000, + transactions: vec![], + }; + let probe = GetBlockWithTxHashesResponse { + block_hash: H256::from([8; 32]), + block_number: 899_990, + transactions: vec![H256::from([3; 32])], + }; + let canonical = GetBlockWithTxHashesResponse { + block_hash: receipt.block_hash, + block_number: receipt.block_number, + transactions: vec![], + }; + let client = SequentialMockClient::new(vec![ + json(STARKNET_MAINNET_CHAIN_ID), + json(&head), + json(&probe), + json(&receipt), + json(&canonical), + ]); + + // When + let result = check_starknet(client, STARKNET_MAINNET_CHAIN_ID).await; + + // Then + result.unwrap(); + } + + #[tokio::test] + async fn check_starknet__should_fail_when_chain_id_differs() { + // Given a provider reporting a different network's chain id; the probe must reject it + // before spending any further calls. + let client = SequentialMockClient::new(vec![json("0x534e5f5345504f4c4941")]); + + // When + let result = check_starknet(client, STARKNET_MAINNET_CHAIN_ID).await; + + // Then + assert_matches!( + result.unwrap_err().downcast_ref::(), + Some(Mismatch::ChainId { .. }) + ); + } + + #[tokio::test] + async fn check_starknet__should_walk_back_when_the_probe_block_is_empty() { + // Given the right network, an empty probe block, and a verifiable transaction in the + // block before it. + let receipt = starknet_receipt(); + let head = GetBlockWithTxHashesResponse { + block_hash: H256::from([9; 32]), + block_number: 900_000, + transactions: vec![], + }; + let empty_probe = GetBlockWithTxHashesResponse { + block_hash: H256::from([8; 32]), + block_number: 899_990, + transactions: vec![], + }; + let earlier = GetBlockWithTxHashesResponse { + block_hash: H256::from([7; 32]), + block_number: 899_989, + transactions: vec![H256::from([3; 32])], + }; + let canonical = GetBlockWithTxHashesResponse { + block_hash: receipt.block_hash, + block_number: receipt.block_number, + transactions: vec![], + }; + let client = SequentialMockClient::new(vec![ + json(STARKNET_MAINNET_CHAIN_ID), + json(&head), + json(&empty_probe), + json(&earlier), + json(&receipt), + json(&canonical), + ]); + + // When + let result = check_starknet(client, STARKNET_MAINNET_CHAIN_ID).await; + + // Then + result.unwrap(); + } + + #[tokio::test] + async fn check_starknet__should_walk_back_when_the_probe_tx_is_not_yet_l1_final() { + // Given the right network: the probe block's tx is only L2-accepted (as a lagging + // load-balanced backend would report), but a tx in an earlier block is L1-final. + let l2_receipt = GetTransactionReceiptResponse { + finality_status: StarknetFinalityStatus::AcceptedOnL2, + ..starknet_receipt() + }; + let l1_receipt = starknet_receipt(); + let head = GetBlockWithTxHashesResponse { + block_hash: H256::from([9; 32]), + block_number: 900_000, + transactions: vec![], + }; + let probe = GetBlockWithTxHashesResponse { + block_hash: H256::from([8; 32]), + block_number: 899_990, + transactions: vec![H256::from([3; 32])], + }; + let earlier = GetBlockWithTxHashesResponse { + block_hash: H256::from([7; 32]), + block_number: 899_989, + transactions: vec![H256::from([4; 32])], + }; + let canonical = GetBlockWithTxHashesResponse { + block_hash: l1_receipt.block_hash, + block_number: l1_receipt.block_number, + transactions: vec![], + }; + let client = SequentialMockClient::new(vec![ + json(STARKNET_MAINNET_CHAIN_ID), + json(&head), + json(&probe), + json(&l2_receipt), // probe tx: not yet L1-final -> walk back + json(&earlier), + json(&l1_receipt), // earlier tx: L1-final -> pass + json(&canonical), + ]); + + // When + let result = check_starknet(client, STARKNET_MAINNET_CHAIN_ID).await; + + // Then + result.unwrap(); + } + + #[tokio::test] + async fn check_starknet__should_fail_when_no_l1_final_tx_within_the_walkback_cap() { + // Given the right network but every block within the walk-back cap is empty. + let head = GetBlockWithTxHashesResponse { + block_hash: H256::from([9; 32]), + block_number: 1_000_000, + transactions: vec![], + }; + let empty = GetBlockWithTxHashesResponse { + block_hash: H256::from([8; 32]), + block_number: 1_000_000, + transactions: vec![], + }; + // More empty blocks than the exponential walk-back can consume before hitting the cap. + let responses = [json(STARKNET_MAINNET_CHAIN_ID), json(&head)] + .into_iter() + .chain(std::iter::repeat_with(|| json(&empty)).take(16)) + .collect(); + let client = SequentialMockClient::new(responses); + + // When + let result = check_starknet(client, STARKNET_MAINNET_CHAIN_ID).await; + + // Then + let error = format!("{:#}", result.unwrap_err()); + assert!(error.contains("no L1-final transaction"), "{error}"); + } + + const BASE_MAINNET_CHAIN_ID: &str = "8453"; + + #[tokio::test] + async fn check_evm__should_pass_when_chain_id_matches_and_a_recent_tx_verifies() { + // Given a provider on the expected network whose probe block (10 below the finalized + // head) carries a transaction the inspector can verify (finalized, canonical, succeeded; + // no logs -> the accept list treats an out-of-bounds log index as healthy). + let tx = H256::from([3; 32]); + let block_hash = H256::from([11; 32]); + let receipt = EvmReceipt { + transaction_hash: tx, + block_hash, + block_number: U64::from(50), + status: U64::from(1), + logs: vec![], + }; + let head = EvmBlockWithTxs { + number: U64::from(100), + hash: H256::from([9; 32]), + transactions: vec![], + }; + let probe = EvmBlockWithTxs { + number: U64::from(90), + hash: H256::from([8; 32]), + transactions: vec![tx], + }; + let finality_head = EvmBlock { + number: U64::from(100), + hash: H256::from([9; 32]), + }; + let canonical = EvmBlock { + number: U64::from(50), + hash: block_hash, + }; + let client = SequentialMockClient::new(vec![ + json(U64::from(8453)), + json(&head), + json(&probe), + json(&receipt), + json(&finality_head), + json(&canonical), + ]); + + // When + let result = check_evm::(client, BASE_MAINNET_CHAIN_ID).await; + + // Then + result.unwrap(); + } + + #[tokio::test] + async fn check_evm__should_fail_when_chain_id_differs() { + // Given a provider reporting a different network's chain id. + let client = SequentialMockClient::new(vec![json(U64::from(1))]); + + // When + let result = check_evm::(client, BASE_MAINNET_CHAIN_ID).await; + + // Then + assert_matches!( + result.unwrap_err().downcast_ref::(), + Some(Mismatch::ChainId { .. }) + ); + } + + #[tokio::test] + async fn check_evm__should_walk_back_when_the_probe_block_is_empty() { + // Given the right network, an empty probe block, and a verifiable transaction in the + // block before it. + let tx = H256::from([3; 32]); + let block_hash = H256::from([11; 32]); + let receipt = EvmReceipt { + transaction_hash: tx, + block_hash, + block_number: U64::from(50), + status: U64::from(1), + logs: vec![], + }; + let head = EvmBlockWithTxs { + number: U64::from(100), + hash: H256::from([9; 32]), + transactions: vec![], + }; + let empty_probe = EvmBlockWithTxs { + number: U64::from(90), + hash: H256::from([8; 32]), + transactions: vec![], + }; + let earlier = EvmBlockWithTxs { + number: U64::from(89), + hash: H256::from([7; 32]), + transactions: vec![tx], + }; + let finality_head = EvmBlock { + number: U64::from(100), + hash: H256::from([9; 32]), + }; + let canonical = EvmBlock { + number: U64::from(50), + hash: block_hash, + }; + let client = SequentialMockClient::new(vec![ + json(U64::from(8453)), + json(&head), + json(&empty_probe), + json(&earlier), + json(&receipt), + json(&finality_head), + json(&canonical), + ]); + + // When + let result = check_evm::(client, BASE_MAINNET_CHAIN_ID).await; + + // Then + result.unwrap(); + } + + #[tokio::test] + async fn check_evm__should_fail_when_no_recent_block_carries_transactions() { + // Given the right network but only empty blocks within the walk-back limit. + let head = EvmBlockWithTxs { + number: U64::from(100), + hash: H256::from([9; 32]), + transactions: vec![], + }; + let empty_blocks = (0..=EMPTY_BLOCK_WALKBACK_LIMIT).map(|i| { + json(EvmBlockWithTxs { + number: U64::from(90 - i), + hash: H256::from([8; 32]), + transactions: vec![], + }) + }); + let responses = [json(U64::from(8453)), json(&head)] + .into_iter() + .chain(empty_blocks) + .collect(); + let client = SequentialMockClient::new(responses); + + // When + let result = check_evm::(client, BASE_MAINNET_CHAIN_ID).await; + + // Then + let error = format!("{:#}", result.unwrap_err()); + assert!(error.contains("no transactions"), "{error}"); + } + + const BTC_MAINNET_GENESIS: &str = + "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"; + + #[tokio::test] + async fn check_bitcoin__should_pass_when_genesis_matches_and_a_recent_tx_verifies() { + // Given a provider whose genesis hash matches and whose recent block carries a + // transaction the inspector can verify (confirmed, canonical). + let genesis = TransportBitcoinBlockHash::from(parse::hex32(BTC_MAINNET_GENESIS).unwrap()); + let block_hash = TransportBitcoinBlockHash::from([0x33; 32]); + let tip = TransportBitcoinBlockHash::from([0x44; 32]); + let recent = TransportBitcoinBlockHash::from([0x11; 32]); + let txid = TransportBitcoinTransactionHash::from([0x22; 32]); + let raw_tx = GetRawTransactionVerboseResponse { + blockhash: block_hash, + confirmations: 10, + }; + let header = GetBlockHeaderVerboseResponse { + hash: block_hash, + height: 799_990, + }; + let tip_block = GetBlockResponse { + height: 800_000, + tx: vec![TransportBitcoinTransactionHash::from([0x55; 32])], + }; + let probe_block = GetBlockResponse { + height: 799_990, + tx: vec![txid], + }; + let client = SequentialMockClient::new(vec![ + json(genesis), + json(tip), + json(&tip_block), + json(recent), + json(&probe_block), + json(&raw_tx), + json(&header), + json(block_hash), + ]); + + // When + let result = check_bitcoin(client, BTC_MAINNET_GENESIS).await; + + // Then + result.unwrap(); + } + + #[tokio::test] + async fn check_bitcoin__should_fail_when_the_genesis_hash_differs() { + // Given a provider serving a different chain's genesis hash. + let wrong = TransportBitcoinBlockHash::from([0xee; 32]); + let client = SequentialMockClient::new(vec![json(wrong)]); + + // When + let result = check_bitcoin(client, BTC_MAINNET_GENESIS).await; + + // Then + assert_matches!( + result.unwrap_err().downcast_ref::(), + Some(Mismatch::BlockHash { .. }) + ); + } fn golden_aptos_body(tx: &str, type_tag: &str, sequence_number: u64) -> serde_json::Value { serde_json::json!({ @@ -271,37 +912,40 @@ mod tests { } #[tokio::test] - async fn check_aptos__should_pass_when_provider_returns_golden_event() { - // Given + async fn check_aptos__should_pass_when_chain_id_matches_and_a_recent_tx_verifies() { + // Given a provider on the expected network (chain id 1) whose recent transaction the + // inspector can verify (committed, with an event). let server = MockServer::start_async().await; - let aptos = golden::golden_set(Network::Mainnet).aptos.unwrap(); - let tx = aptos.tx; - let mock = server + let tx = "aa".repeat(32); + server + .mock_async(|when, then| { + when.method(GET).path("/"); + then.status(200) + .json_body(serde_json::json!({ "chain_id": 1, "ledger_version": "1000" })); + }) + .await; + // Ledger version 1000 minus the probe offset (100). + server + .mock_async(|when, then| { + when.method(GET).path("/transactions/by_version/900"); + then.status(200) + .json_body(golden_aptos_body(&tx, "0x1::block::NewBlockEvent", 0)); + }) + .await; + server .mock_async(|when, then| { when.method(GET) .path(format!("/transactions/by_hash/0x{tx}")); - then.status(200).json_body(golden_aptos_body( - tx, - aptos.event_type_tag, - aptos.event_sequence_number, - )); + then.status(200) + .json_body(golden_aptos_body(&tx, "0x1::block::NewBlockEvent", 0)); }) .await; // When - let result = check_aptos( - server.base_url(), - None, - Duration::from_secs(5), - golden::hex32(tx).unwrap(), - aptos.event_type_tag, - aptos.event_sequence_number, - ) - .await; + let result = check_aptos(server.base_url(), None, Duration::from_secs(5), "1").await; // Then result.unwrap(); - mock.assert_async().await; } use foreign_chain_rpc_interfaces::sui::proto::{ @@ -356,16 +1000,18 @@ mod tests { } } + /// Sui mainnet's genesis checkpoint digest (`get_service_info` chain id). + const SUI_MAINNET_CHAIN_ID: &str = "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S"; + #[tokio::test] async fn check_sui__should_pass_when_provider_is_on_the_expected_network() { // Given - let sui = golden::golden_set(Network::Mainnet).sui.unwrap(); let client = MockSuiClient { - chain_id: sui.chain_id.to_string(), + chain_id: SUI_MAINNET_CHAIN_ID.to_string(), }; // When - let result = check_sui(client, sui.chain_id).await; + let result = check_sui(client, SUI_MAINNET_CHAIN_ID).await; // Then result.unwrap(); @@ -373,18 +1019,13 @@ mod tests { #[tokio::test] async fn check_sui__should_fail_when_chain_id_differs() { - // Given — a provider on a different network. + // Given — a provider on a different network (the testnet genesis digest). let client = MockSuiClient { - chain_id: golden::golden_set(Network::Testnet) - .sui - .unwrap() - .chain_id - .to_string(), + chain_id: "69WiPg3DAQiwdxfncX6wYQ2siKwAe6L9BZthQea3JNMD".to_string(), }; - let expected = golden::golden_set(Network::Mainnet).sui.unwrap(); // When - let result = check_sui(client, expected.chain_id).await; + let result = check_sui(client, SUI_MAINNET_CHAIN_ID).await; // Then assert_matches!( @@ -394,38 +1035,24 @@ mod tests { } #[tokio::test] - async fn check_aptos__should_fail_when_event_type_tag_differs() { - // Given + async fn check_aptos__should_fail_when_chain_id_differs() { + // Given a provider reporting a different network's ledger chain id. let server = MockServer::start_async().await; - let aptos = golden::golden_set(Network::Mainnet).aptos.unwrap(); - let tx = aptos.tx; server .mock_async(|when, then| { - when.method(GET) - .path(format!("/transactions/by_hash/0x{tx}")); - then.status(200).json_body(golden_aptos_body( - tx, - "0xdead::wrong::Event", - aptos.event_sequence_number, - )); + when.method(GET).path("/"); + then.status(200) + .json_body(serde_json::json!({ "chain_id": 2, "ledger_version": "1000" })); }) .await; - // When - let result = check_aptos( - server.base_url(), - None, - Duration::from_secs(5), - golden::hex32(tx).unwrap(), - aptos.event_type_tag, - aptos.event_sequence_number, - ) - .await; + // When — expecting mainnet (1) but the provider is on testnet (2). + let result = check_aptos(server.base_url(), None, Duration::from_secs(5), "1").await; // Then assert_matches!( result.unwrap_err().downcast_ref::(), - Some(Mismatch::EventTypeTag { .. }) + Some(Mismatch::ChainId { .. }) ); } } diff --git a/crates/foreign-chain-health-check/src/golden.rs b/crates/foreign-chain-health-check/src/golden.rs deleted file mode 100644 index f87696449a..0000000000 --- a/crates/foreign-chain-health-check/src/golden.rs +++ /dev/null @@ -1,246 +0,0 @@ -//! Per-network reference transactions and the value an inspector must extract -//! from each. A mainnet transaction does not exist on testnet (and vice versa), -//! so the vectors are network-specific; `None` means the chain is skipped. - -use anyhow::Context; - -use crate::network::Network; - -/// Hashes are hex, with or without a `0x` prefix. -#[derive(Clone, Copy)] -pub struct BlockHashVector { - pub tx: &'static str, - pub block_hash: &'static str, -} - -#[derive(Clone, Copy)] -pub struct AptosVector { - pub tx: &'static str, - pub event_type_tag: &'static str, - pub event_sequence_number: u64, -} - -/// 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` - /// returns it (`sui.rpc.v2`: "the digest of the genesis checkpoint"). Its 4-byte - /// prefix is the well-known Sui chain identifier — mainnet `0x35834a8a`, testnet - /// `0x4c78adac` — which is the value to grep against Sui docs to verify these. - pub chain_id: &'static str, -} - -pub struct GoldenSet { - pub base: Option, - pub bnb: Option, - pub arbitrum: Option, - pub polygon: Option, - pub hyper_evm: Option, - pub abstract_chain: Option, - pub bitcoin: Option, - pub starknet: Option, - pub aptos: Option, - pub sui: Option, -} - -pub fn golden_set(network: Network) -> GoldenSet { - match network { - Network::Mainnet => MAINNET, - Network::Testnet => TESTNET, - } -} - -const MAINNET: GoldenSet = GoldenSet { - base: Some(BlockHashVector { - tx: "a11eaa1236e80f26ddc7aca164f2ba4c6c2726405cb12b1aa8f52c520bad99e1", - block_hash: "b8488c9272c547c45e63ea76cc2d1c927c8f888e2721f790b14db996b6cc6aca", - }), - bnb: Some(BlockHashVector { - tx: "90514fff1563dc9876bc9a02a7b1d4dd2ce44b8d11ea0490aa8d427166eba349", - block_hash: "4f125b8e2716df5cbc72719212d5189dae0e49b6b7a44523165cb01888914999", - }), - arbitrum: Some(BlockHashVector { - tx: "8f1f497285dcf54624cba2c3dd46d13e25fc83466033c139e77e4dce12a1e484", - block_hash: "da0e369bfb9688ca4591604104e4f2953329542bfb3bc0d0c94686b5ad798c1c", - }), - polygon: Some(BlockHashVector { - tx: "7b231f0f5bf36782a48db9b1d89e4613bd00618f03c3c0fba922aa59288e4d38", - block_hash: "56d98f80b91c9cf9dcda71c63c01ea441d46ba31149c902adfbee97e55ff82a6", - }), - hyper_evm: Some(BlockHashVector { - tx: "4d94e2c9c33c533f125bd28a788e80ee24c108356e8fa8a7878f642cf94dcf4a", - block_hash: "657b2ee81add87e3f654840425baca06a06d5876f6d2d873197e70f00f6762e6", - }), - abstract_chain: Some(BlockHashVector { - tx: "4572b72d765f07712cf571993fd805888ede9cd05107f65338defee02f7ea755", - block_hash: "3bb255d468a552a75fc3f4916623b207ceb2d3074dfa14442ac03f0f73423708", - }), - bitcoin: Some(BlockHashVector { - tx: "58ee376171bcc4e2cc040c13848d420b5eaf2f634872055b0a08c1fc2ec6453c", - block_hash: "00000000000000000001fadaf3f8591e071c202762193cf78e389ea691f2ecab", - }), - starknet: Some(BlockHashVector { - tx: "0x52a6c2b9d1d1b77dbc322b298fd91f39e3cca9bf1db4a7aa79f14a90efa633e", - block_hash: "0x1b716b05027567f9f4a2fe37f8769dc3b04a2e5a3893f6e0ed45f24c7c0ffa5", - }), - aptos: Some(AptosVector { - tx: "adc6b85a0931fc7f0d7e3839b52d63105e22cec1cb1cdee48aa2065773098c3c", - event_type_tag: "0x1::block::NewBlockEvent", - event_sequence_number: 822_198_006, - }), - sui: Some(SuiVector { - chain_id: "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S", - }), -}; - -const TESTNET: GoldenSet = GoldenSet { - base: None, - bnb: None, - arbitrum: None, - polygon: None, - hyper_evm: None, - abstract_chain: Some(BlockHashVector { - tx: "497fc5f5b5d81d6bc15cccc6d4d8be8ef6ad19376233b944a60dc435593f7234", - block_hash: "4c93dd4a8f347e6480b0a44f8c2b7eecdfb31d711e8d542fd60112ea5d98fb02", - }), - bitcoin: Some(BlockHashVector { - tx: "5acaa0890f8c1f1b2ac114c25b38d376f23beda1b59e9bcba33256d6e11d7e8e", - block_hash: "000000000000021f43445ab447b3fc85e93eca26b56a4f23ef6c017682038ca2", - }), - starknet: Some(BlockHashVector { - tx: "0x115b24c74eade5ee4c01e63cce5aa462fc2d59d040f5b088a31ad44c9aa58dc", - block_hash: "0x1f33823b145e92ca069b90d3cfb012277762d9dd1dc2efb975b10a7c3d92875", - }), - aptos: Some(AptosVector { - tx: "b463d73b3a2e9c684caf9b27eb66a147348130c50fc8fa74a3f56e712c942773", - event_type_tag: "0x1::block::NewBlockEvent", - event_sequence_number: 302_761_912, - }), - sui: Some(SuiVector { - chain_id: "69WiPg3DAQiwdxfncX6wYQ2siKwAe6L9BZthQea3JNMD", - }), -}; - -/// Decode a 32-byte hash from hex, tolerating an optional `0x` prefix. -pub fn hex32(hex: &str) -> anyhow::Result<[u8; 32]> { - let stripped = hex.strip_prefix("0x").unwrap_or(hex); - let bytes = hex::decode(stripped).with_context(|| format!("invalid hex: {hex}"))?; - bytes - .try_into() - .map_err(|b: Vec| anyhow::anyhow!("expected 32 bytes, got {}: {hex}", b.len())) -} - -/// Decode a Starknet felt (`0x`-prefixed, possibly fewer than 64 hex digits) into -/// a left-zero-padded 32-byte array. -pub fn felt32(felt: &str) -> anyhow::Result<[u8; 32]> { - let stripped = felt.strip_prefix("0x").unwrap_or(felt); - anyhow::ensure!(stripped.len() <= 64, "felt too long: {felt}"); - hex32(&format!("{stripped:0>64}")) -} - -/// Decode a base58-encoded 32-byte digest (the form Sui APIs use). -pub fn base58_32(digest: &str) -> anyhow::Result<[u8; 32]> { - // 32 bytes encode to at most 44 base58 characters; rejecting longer inputs up front - // also bounds `bs58`'s superlinear decode. - anyhow::ensure!( - digest.len() <= 44, - "base58 digest too long: {} characters", - digest.len() - ); - let bytes = bs58::decode(digest) - .into_vec() - .with_context(|| format!("invalid base58: {digest}"))?; - bytes - .try_into() - .map_err(|b: Vec| anyhow::anyhow!("expected 32 bytes, got {}: {digest}", b.len())) -} - -#[cfg(test)] -#[expect(non_snake_case)] -mod tests { - use super::*; - - #[test] - fn hex32__should_decode_with_and_without_prefix() { - // Given / When / Then - hex32("00").unwrap_err(); - assert_eq!( - hex32("0x0000000000000000000000000000000000000000000000000000000000000001").unwrap() - [31], - 1 - ); - } - - #[test] - fn felt32__should_left_pad_short_felts() { - // Given - let felt = "0x1"; - - // When - let bytes = felt32(felt).unwrap(); - - // Then - assert_eq!(bytes[31], 1); - assert_eq!(bytes[..31], [0u8; 31]); - } - - #[test] - fn golden_sets__should_all_parse() { - // Given / When / Then - for network in [Network::Mainnet, Network::Testnet] { - let set = golden_set(network); - for v in [ - set.base, - set.bnb, - set.arbitrum, - set.polygon, - set.hyper_evm, - set.abstract_chain, - set.bitcoin, - ] - .into_iter() - .flatten() - { - hex32(v.tx).unwrap(); - hex32(v.block_hash).unwrap(); - } - if let Some(v) = set.starknet { - felt32(v.tx).unwrap(); - felt32(v.block_hash).unwrap(); - } - if let Some(v) = set.aptos { - hex32(v.tx).unwrap(); - } - if let Some(v) = set.sui { - base58_32(v.chain_id).unwrap(); - } - } - } - - #[test] - fn base58_32__should_decode_sui_digest() { - // Given - let digest = "8eBMXpC8Np7RNDwwiGwSmeev1cSoc7w3fPXdikhH7RZo"; - - // When - let bytes = base58_32(digest).unwrap(); - - // Then - assert_eq!( - hex::encode(bytes), - "7188017648e8e95bfa6c0591988f3c7a6ec6caf3967e294f70d906a376d5e4fe" - ); - } - - #[test] - fn base58_32__should_reject_invalid_input() { - // Contains characters outside the base58 alphabet (`0`, `O`, `I`, `l`): decode fails. - base58_32("not-base58-0OIl").unwrap_err(); - // Valid base58, but decodes to fewer than 32 bytes. - base58_32("abc").unwrap_err(); - // Longer than any 32-byte digest's base58 (max 44 chars); rejected on length up front, - // before decoding, since `bs58`'s decode is superlinear in the input length. - base58_32(&"1".repeat(45)).unwrap_err(); - } -} diff --git a/crates/foreign-chain-health-check/src/lib.rs b/crates/foreign-chain-health-check/src/lib.rs index cc63e9d88f..daed02c68c 100644 --- a/crates/foreign-chain-health-check/src/lib.rs +++ b/crates/foreign-chain-health-check/src/lib.rs @@ -1,10 +1,9 @@ -//! 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`. +//! Foreign-chain RPC provider health checks: verify each configured provider's chain +//! identity against the configured expected value, run the real inspector over a recently +//! produced transaction, and report a per-provider result. mod checks; -mod golden; -mod network; +mod parse; mod results; use std::future::Future; @@ -25,70 +24,72 @@ use http::{HeaderName, HeaderValue}; use mpc_node_config::foreign_chains::RpcProviderName; use mpc_node_config::{ForeignChainConfig, ForeignChainProviderConfig, ForeignChainsConfig}; -pub use network::Network; +pub use mpc_node_config::ExpectedIdentities; 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. +/// Probe every configured provider against its configured expected identity, one +/// [`ProviderResult`] per provider, each checked independently. +/// Configured but unsupported chains 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, + identities: &ExpectedIdentities, ) -> 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; + run_evm::("base", cfg, identities.base.as_deref(), &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; + run_evm::("bnb", cfg, identities.bnb.as_deref(), &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; + run_evm::("arbitrum", cfg, identities.arbitrum.as_deref(), &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; + run_evm::("polygon", cfg, identities.polygon.as_deref(), &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; + run_evm::("hyper_evm", cfg, identities.hyper_evm.as_deref(), &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; + run_evm::( + "abstract", + cfg, + identities.abstract_chain.as_deref(), + &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; + run_bitcoin(cfg, identities.bitcoin.as_deref(), &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; + run_starknet(cfg, identities.starknet.as_deref(), &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; + run_aptos(cfg, identities.aptos.as_deref(), &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; + run_sui(cfg, identities.sui.as_deref(), &mut out).await; } else { mark_not_configured("sui", &mut out); } @@ -108,13 +109,6 @@ pub async fn check_all_providers( 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()) } @@ -155,24 +149,18 @@ async fn run_check(timeout: Duration, fut: impl Future( chain: &'static str, cfg: &ForeignChainConfig, - vector: Option, - network: Network, + expected_chain_id: Option<&str>, out: &mut Vec, ) { - let Some(vector) = vector else { - mark_skipped(chain, cfg, &no_reference_reason(network), out); + let Some(expected) = expected_chain_id else { + mark_missing_identity(chain, cfg, 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 - } + let status = match prepare_jsonrpc(provider) { + Err(e) => Status::Failed(format!("{e:#}")), + Ok(client) => run_check(timeout, checks::check_evm::(client, expected)).await, }; out.push(ProviderResult { chain, @@ -184,24 +172,18 @@ async fn run_evm( async fn run_bitcoin( cfg: &ForeignChainConfig, - vector: Option, - network: Network, + expected_genesis: Option<&str>, out: &mut Vec, ) { - let Some(vector) = vector else { - mark_skipped("bitcoin", cfg, &no_reference_reason(network), out); + let Some(expected) = expected_genesis else { + mark_missing_identity("bitcoin", cfg, 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 - } + let status = match prepare_jsonrpc(provider) { + Err(e) => Status::Failed(format!("{e:#}")), + Ok(client) => run_check(timeout, checks::check_bitcoin(client, expected)).await, }; out.push(ProviderResult { chain: "bitcoin", @@ -213,23 +195,19 @@ async fn run_bitcoin( async fn run_starknet( cfg: &ForeignChainConfig, - vector: Option, - network: Network, + expected_chain_id: Option<&str>, out: &mut Vec, ) { - let Some(vector) = vector else { - mark_skipped("starknet", cfg, &no_reference_reason(network), out); + let Some(expected_chain_id) = expected_chain_id else { + mark_missing_identity("starknet", cfg, 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 + let status = match prepare_jsonrpc(provider) { + Err(e) => Status::Failed(format!("{e:#}")), + Ok(client) => { + run_check(timeout, checks::check_starknet(client, expected_chain_id)).await } }; out.push(ProviderResult { @@ -242,33 +220,19 @@ async fn run_starknet( async fn run_aptos( cfg: &ForeignChainConfig, - vector: Option, - network: Network, + expected_chain_id: Option<&str>, out: &mut Vec, ) { - let Some(vector) = vector else { - mark_skipped("aptos", cfg, &no_reference_reason(network), out); + let Some(expected) = expected_chain_id else { + mark_missing_identity("aptos", cfg, 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 + let status = match prepare_aptos(provider) { + Err(e) => Status::Failed(format!("{e:#}")), + Ok((url, header)) => { + run_check(timeout, checks::check_aptos(url, header, timeout, expected)).await } }; out.push(ProviderResult { @@ -279,25 +243,20 @@ 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, - network: Network, + expected_chain_id: Option<&str>, out: &mut Vec, ) { - let Some(vector) = vector else { - mark_skipped("sui", cfg, &no_reference_reason(network), out); + let Some(expected) = expected_chain_id else { + mark_missing_identity("sui", cfg, 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, + Ok(client) => run_check(timeout, checks::check_sui(client, expected)).await, }; out.push(ProviderResult { chain: "sui", @@ -324,6 +283,24 @@ fn prepare_sui( .map_err(|e| anyhow::anyhow!("failed to build the Sui gRPC client: {e}")) } +/// A configured identity-probed chain without an expected identity is a config error: +/// every provider row fails until `foreign_chain_health_check.identities.` is set. +fn mark_missing_identity( + chain: &'static str, + cfg: &ForeignChainConfig, + out: &mut Vec, +) { + for name in cfg.providers.keys() { + out.push(ProviderResult { + chain, + provider: provider_name(name), + status: Status::Failed(format!( + "no expected identity configured: set foreign_chain_health_check.identities.{chain}" + )), + }); + } +} + fn mark_skipped( chain: &'static str, cfg: &ForeignChainConfig, @@ -369,6 +346,29 @@ mod tests { } } + #[tokio::test] + async fn check_all_providers__should_fail_identity_chain_without_configured_identity() { + // Given a configured identity-probed chain but no expected identity in config + let fc = ForeignChainsConfig { + starknet: Some(config_with_provider(AuthConfig::None)), + ..Default::default() + }; + + // When + let results = check_all_providers(&fc, &ExpectedIdentities::default()).await; + + // Then the provider fails with a pointer to the config key, without being probed + let starknet = results + .iter() + .find(|r| r.chain == "starknet") + .expect("starknet row"); + assert_matches!( + &starknet.status, + Status::Failed(reason) + if reason.contains("foreign_chain_health_check.identities.starknet") + ); + } + #[tokio::test] async fn check_all_providers__should_skip_configured_but_unsupported_chains() { // Given a configured but not-yet-supported chain @@ -378,7 +378,7 @@ mod tests { }; // When - let results = check_all_providers(&fc, Network::Mainnet).await; + let results = check_all_providers(&fc, &ExpectedIdentities::default()).await; // Then it is reported skipped as unsupported, not probed let ethereum = results @@ -397,7 +397,7 @@ mod tests { let fc = ForeignChainsConfig::default(); // When - let results = check_all_providers(&fc, Network::Mainnet).await; + let results = check_all_providers(&fc, &ExpectedIdentities::default()).await; // Then every known chain still appears, each with a "not configured" placeholder let expected = [ @@ -441,9 +441,13 @@ mod tests { base: Some(config_with_provider(auth)), ..Default::default() }; + let identities = ExpectedIdentities { + base: Some("8453".to_string()), + ..Default::default() + }; // When - let results = check_all_providers(&fc, Network::Mainnet).await; + let results = check_all_providers(&fc, &identities).await; // Then assert_eq!(results[0].chain, "base"); @@ -476,32 +480,38 @@ mod tests { #[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). + // Given — one Aptos provider on the expected network with a verifiable recent tx + // (pass), another on the wrong network (fail), and a separate unsupported chain (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; + let tx = "aa".repeat(32); + healthy + .mock_async(|when, then| { + when.method(GET).path("/"); + then.status(200) + .json_body(serde_json::json!({ "chain_id": 1, "ledger_version": "1000" })); + }) + .await; + healthy + .mock_async(|when, then| { + when.method(GET).path("/transactions/by_version/900"); + then.status(200) + .json_body(aptos_event_body(&tx, "0x1::block::NewBlockEvent", 0)); + }) + .await; 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, - )); + then.status(200) + .json_body(aptos_event_body(&tx, "0x1::block::NewBlockEvent", 0)); }) .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, - )); + when.method(GET).path("/"); + then.status(200) + .json_body(serde_json::json!({ "chain_id": 2, "ledger_version": "1000" })); }) .await; @@ -523,8 +533,13 @@ mod tests { ..Default::default() }; + let identities = ExpectedIdentities { + aptos: Some("1".to_string()), + ..Default::default() + }; + // When - let results = check_all_providers(&fc, Network::Mainnet).await; + let results = check_all_providers(&fc, &identities).await; // Then — the broken provider does not suppress the healthy one; pass, // fail, and skip all coexist in a single run. diff --git a/crates/foreign-chain-health-check/src/network.rs b/crates/foreign-chain-health-check/src/network.rs deleted file mode 100644 index a91ca6ae7e..0000000000 --- a/crates/foreign-chain-health-check/src/network.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! 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/parse.rs b/crates/foreign-chain-health-check/src/parse.rs new file mode 100644 index 0000000000..65476f0840 --- /dev/null +++ b/crates/foreign-chain-health-check/src/parse.rs @@ -0,0 +1,104 @@ +//! Decoders for the configured expected-identity strings, one per identity format. + +use anyhow::Context; + +/// Decode a 32-byte hash from hex, tolerating an optional `0x` prefix. +pub fn hex32(hex: &str) -> anyhow::Result<[u8; 32]> { + let stripped = hex.strip_prefix("0x").unwrap_or(hex); + let bytes = hex::decode(stripped).with_context(|| format!("invalid hex: {hex}"))?; + bytes + .try_into() + .map_err(|b: Vec| anyhow::anyhow!("expected 32 bytes, got {}: {hex}", b.len())) +} + +/// Parse a numeric chain id, accepting decimal (`8453`) or `0x`-hex (`0x2105`). +pub fn chain_id_u64(s: &str) -> anyhow::Result { + let s = s.trim(); + match s.strip_prefix("0x") { + Some(hex) => { + u64::from_str_radix(hex, 16).with_context(|| format!("invalid hex chain id: {s}")) + } + None => s.parse().with_context(|| format!("invalid chain id: {s}")), + } +} + +/// Decode a Starknet felt (`0x`-prefixed, possibly fewer than 64 hex digits) into +/// a left-zero-padded 32-byte array. +pub fn felt32(felt: &str) -> anyhow::Result<[u8; 32]> { + let stripped = felt.strip_prefix("0x").unwrap_or(felt); + anyhow::ensure!(stripped.len() <= 64, "felt too long: {felt}"); + hex32(&format!("{stripped:0>64}")) +} + +/// Decode a base58-encoded 32-byte digest (the form Sui APIs use). +pub fn base58_32(digest: &str) -> anyhow::Result<[u8; 32]> { + // 32 bytes encode to at most 44 base58 characters; rejecting longer inputs up front + // also bounds `bs58`'s superlinear decode. + anyhow::ensure!( + digest.len() <= 44, + "base58 digest too long: {} characters", + digest.len() + ); + let bytes = bs58::decode(digest) + .into_vec() + .with_context(|| format!("invalid base58: {digest}"))?; + bytes + .try_into() + .map_err(|b: Vec| anyhow::anyhow!("expected 32 bytes, got {}: {digest}", b.len())) +} + +#[cfg(test)] +#[expect(non_snake_case)] +mod tests { + use super::*; + + #[test] + fn hex32__should_decode_with_and_without_prefix() { + // Given / When / Then + hex32("00").unwrap_err(); + assert_eq!( + hex32("0x0000000000000000000000000000000000000000000000000000000000000001").unwrap() + [31], + 1 + ); + } + + #[test] + fn felt32__should_left_pad_short_felts() { + // Given + let felt = "0x1"; + + // When + let bytes = felt32(felt).unwrap(); + + // Then + assert_eq!(bytes[31], 1); + assert_eq!(bytes[..31], [0u8; 31]); + } + + #[test] + fn base58_32__should_decode_sui_digest() { + // Given + let digest = "8eBMXpC8Np7RNDwwiGwSmeev1cSoc7w3fPXdikhH7RZo"; + + // When + let bytes = base58_32(digest).unwrap(); + + // Then + assert_eq!( + hex::encode(bytes), + "7188017648e8e95bfa6c0591988f3c7a6ec6caf3967e294f70d906a376d5e4fe" + ); + } + + #[test] + fn base58_32__should_reject_invalid_input() { + // Contains characters outside the base58 alphabet (`0`, `O`, `I`, `l`): decode fails. + base58_32("not-base58-0OIl").unwrap_err(); + // Valid base58, but decodes to fewer than 32 bytes. + base58_32("abc").unwrap_err(); + // Longer than any 32-byte digest's base58 (max 44 chars); rejected on length up front, + // before decoding, since `bs58`'s decode is superlinear in the input length. + base58_32(&"1".repeat(45)).unwrap_err(); + } +} diff --git a/crates/foreign-chain-inspector/src/bitcoin/inspector.rs b/crates/foreign-chain-inspector/src/bitcoin/inspector.rs index bc8aa47514..35744341a2 100644 --- a/crates/foreign-chain-inspector/src/bitcoin/inspector.rs +++ b/crates/foreign-chain-inspector/src/bitcoin/inspector.rs @@ -3,7 +3,8 @@ use jsonrpsee::core::client::ClientT; use crate::bitcoin::{BitcoinExtractedValue, BitcoinTransactionHash}; use crate::{BlockConfirmations, ForeignChainInspectionError, ForeignChainInspector}; use foreign_chain_rpc_interfaces::bitcoin::{ - GetBlockHashArgs, GetBlockHeaderArgs, GetBlockHeaderVerboseResponse, GetRawTransactionArgs, + GetBestBlockHashArgs, GetBlockArgs, GetBlockHashArgs, GetBlockHeaderArgs, + GetBlockHeaderVerboseResponse, GetBlockResponse, GetRawTransactionArgs, GetRawTransactionVerboseResponse, TransportBitcoinBlockHash, TransportBitcoinTransactionHash, }; @@ -15,6 +16,12 @@ const VERBOSE_RESPONSE: bool = true; const GET_BLOCK_HEADER_METHOD: &str = "getblockheader"; /// https://developer.bitcoin.org/reference/rpc/getblockhash.html const GET_BLOCK_HASH_METHOD: &str = "getblockhash"; +/// https://developer.bitcoin.org/reference/rpc/getbestblockhash.html +const GET_BEST_BLOCK_HASH_METHOD: &str = "getbestblockhash"; +/// https://developer.bitcoin.org/reference/rpc/getblock.html +const GET_BLOCK_METHOD: &str = "getblock"; +/// `getblock` verbosity that returns transaction ids (not full objects). +const GET_BLOCK_VERBOSITY_TX_IDS: u8 = 1; #[derive(Clone)] pub struct BitcoinInspector { @@ -78,6 +85,39 @@ where Self { client } } + /// The canonical block hash at `height` (`getblockhash`). `height` 0 is the genesis block, + /// whose hash is a permanent, never-pruned identifier of which network a provider serves. + pub async fn block_hash( + &self, + height: u64, + ) -> Result { + let args = GetBlockHashArgs { height }; + Ok(self.client.request(GET_BLOCK_HASH_METHOD, &args).await?) + } + + /// The hash of the chain tip (`getbestblockhash`). + pub async fn best_block_hash( + &self, + ) -> Result { + Ok(self + .client + .request(GET_BEST_BLOCK_HASH_METHOD, &GetBestBlockHashArgs) + .await?) + } + + /// A block's transaction ids (`getblock`, verbosity 1). The health probe reads a recent, + /// unpruned transaction from it to exercise the extraction pipeline. + pub async fn block( + &self, + blockhash: TransportBitcoinBlockHash, + ) -> Result { + let args = GetBlockArgs { + blockhash, + verbosity: GET_BLOCK_VERBOSITY_TX_IDS, + }; + Ok(self.client.request(GET_BLOCK_METHOD, &args).await?) + } + /// Checks that the receipt's block is on the canonical chain by resolving its height via /// `getblockheader` and then asking the RPC for the canonical hash at that height via /// `getblockhash`. `getblockhash` only ever returns canonical blocks, so a mismatch means diff --git a/crates/foreign-chain-inspector/src/evm/inspector.rs b/crates/foreign-chain-inspector/src/evm/inspector.rs index f7f8caf219..74e389ffb1 100644 --- a/crates/foreign-chain-inspector/src/evm/inspector.rs +++ b/crates/foreign-chain-inspector/src/evm/inspector.rs @@ -6,13 +6,14 @@ use jsonrpsee::core::client::ClientT; use crate::{EthereumFinality, ForeignChainInspectionError, ForeignChainInspector}; use foreign_chain_rpc_interfaces::evm::{ - BlockNumberOrTag, FinalityTag, GetBlockByNumberArgs, GetBlockByNumberResponse, - GetTransactionReceiptARgs, GetTransactionReceiptResponse, H256, Log, - ReturnFullTransactionObjects, U64, + BlockNumberOrTag, ChainIdArgs, FinalityTag, GetBlockByNumberArgs, GetBlockByNumberResponse, + GetBlockByNumberWithTxsResponse, GetTransactionReceiptARgs, GetTransactionReceiptResponse, + H256, Log, ReturnFullTransactionObjects, U64, }; const GET_TRANSACTION_RECEIPT_METHOD: &str = "eth_getTransactionReceipt"; const GET_BLOCK_BY_NUMBER_METHOD: &str = "eth_getBlockByNumber"; +const CHAIN_ID_METHOD: &str = "eth_chainId"; /// Marker trait for EVM-compatible chain type parameters. /// @@ -106,6 +107,26 @@ where } } + /// The provider's chain id (`eth_chainId`). Identifies which network a provider serves + /// without depending on historical data. + pub async fn chain_id(&self) -> Result { + let id: U64 = self.client.request(CHAIN_ID_METHOD, &ChainIdArgs).await?; + Ok(id.as_u64()) + } + + /// Fetches a block (and its transaction hashes) by number or finality tag. The health + /// probe reads a recent, unpruned transaction from it to exercise the extraction pipeline. + pub async fn block_with_txs( + &self, + block: BlockNumberOrTag, + ) -> Result { + let args = GetBlockByNumberArgs::new(block, ReturnFullTransactionObjects::from(false)); + Ok(self + .client + .request(GET_BLOCK_BY_NUMBER_METHOD, &args) + .await?) + } + /// Checks that the receipt's block has reached the requested finality level — i.e. that the /// head of the chain at `finality` is at or past `receipt_block_number`. async fn verify_finality_level( diff --git a/crates/foreign-chain-inspector/src/starknet/inspector.rs b/crates/foreign-chain-inspector/src/starknet/inspector.rs index 4f33844852..c45e9061e7 100644 --- a/crates/foreign-chain-inspector/src/starknet/inspector.rs +++ b/crates/foreign-chain-inspector/src/starknet/inspector.rs @@ -1,14 +1,16 @@ use crate::starknet::{StarknetExtractedValue, StarknetTransactionHash}; use crate::{ForeignChainInspectionError, ForeignChainInspector}; use foreign_chain_rpc_interfaces::starknet::{ - BlockId, GetBlockWithTxHashesArgs, GetBlockWithTxHashesResponse, GetTransactionReceiptArgs, - GetTransactionReceiptResponse, H256, StarknetExecutionStatus, StarknetFinalityStatus, + BlockId, ChainIdArgs, GetBlockWithTxHashesArgs, GetBlockWithTxHashesResponse, + GetTransactionReceiptArgs, GetTransactionReceiptResponse, H256, StarknetExecutionStatus, + StarknetFinalityStatus, parse_felt, }; use jsonrpsee::core::client::ClientT; use near_mpc_contract_interface::types::{StarknetFelt, StarknetLog}; const GET_TRANSACTION_RECEIPT_METHOD: &str = "starknet_getTransactionReceipt"; const GET_BLOCK_WITH_TX_HASHES_METHOD: &str = "starknet_getBlockWithTxHashes"; +const CHAIN_ID_METHOD: &str = "starknet_chainId"; #[derive(Clone)] pub struct StarknetInspector { @@ -80,6 +82,26 @@ where Self { client } } + /// The network's chain identifier (`starknet_chainId`) as a felt, e.g. `0x534e5f4d41494e` + /// (`SN_MAIN`). The RPC returns a possibly-short felt string; it is padded to a full felt. + pub async fn chain_id(&self) -> Result { + let felt: String = self.client.request(CHAIN_ID_METHOD, &ChainIdArgs).await?; + parse_felt(&felt).map_err(ForeignChainInspectionError::MalformedRpcResponse) + } + + /// Fetches a block and its transaction hashes by id (e.g. `BlockId::Tag(Latest)` for the + /// head). + pub async fn block_with_tx_hashes( + &self, + block_id: BlockId, + ) -> Result { + let args = GetBlockWithTxHashesArgs { block_id }; + Ok(self + .client + .request(GET_BLOCK_WITH_TX_HASHES_METHOD, &args) + .await?) + } + /// Checks that the receipt's block is on the canonical chain by re-fetching the canonical /// block at `receipt_block_number` and comparing hashes. `starknet_getBlockWithTxHashes` /// only ever resolves to a canonical block, so a mismatch means the receipt was indexed @@ -94,14 +116,10 @@ where receipt_block_number: u64, receipt_block_hash: H256, ) -> Result<(), ForeignChainInspectionError> { - let args = GetBlockWithTxHashesArgs { - block_id: BlockId::Number { + let canonical = self + .block_with_tx_hashes(BlockId::Number { block_number: receipt_block_number, - }, - }; - let canonical: GetBlockWithTxHashesResponse = self - .client - .request(GET_BLOCK_WITH_TX_HASHES_METHOD, &args) + }) .await?; let hash_matches = canonical.block_hash == receipt_block_hash; diff --git a/crates/foreign-chain-inspector/tests/starknet_inspector.rs b/crates/foreign-chain-inspector/tests/starknet_inspector.rs index 80caef9552..e93ac13f9c 100644 --- a/crates/foreign-chain-inspector/tests/starknet_inspector.rs +++ b/crates/foreign-chain-inspector/tests/starknet_inspector.rs @@ -46,6 +46,7 @@ fn canonical_block_for(receipt: &GetTransactionReceiptResponse) -> GetBlockWithT GetBlockWithTxHashesResponse { block_hash: receipt.block_hash, block_number: receipt.block_number, + transactions: vec![], } } @@ -413,6 +414,7 @@ async fn extract__should_return_non_canonical_block_when_receipt_block_hash_diff let canonical_block = GetBlockWithTxHashesResponse { block_hash: H256::from(canonical_hash_bytes), block_number, + transactions: vec![], }; let mock_client = SequentialResponseMockClientBuilder::new() diff --git a/crates/foreign-chain-rpc-interfaces/src/aptos.rs b/crates/foreign-chain-rpc-interfaces/src/aptos.rs index 1f29f379ba..c6d319bca7 100644 --- a/crates/foreign-chain-rpc-interfaces/src/aptos.rs +++ b/crates/foreign-chain-rpc-interfaces/src/aptos.rs @@ -40,6 +40,14 @@ pub struct EventGuid { pub account_address: String, } +/// Response from `GET /v1` (the ledger-info index). `chain_id` identifies the network +/// (1 = mainnet, 2 = testnet); `ledger_version` is the current committed version (stringified). +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct LedgerInfoResponse { + pub chain_id: u8, + pub ledger_version: String, +} + /// Error from the Aptos REST API client. #[derive(Debug, thiserror::Error)] pub enum AptosRpcError { @@ -85,6 +93,37 @@ impl ReqwestAptosClient { .expect("Aptos rpc_url is validated as a URL by node-config before reaching here"); Self { base, client } } + + /// The ledger-info index (`GET /v1`): the chain id (network identity) and current version. + /// Inherent (not on [`AptosRpcClient`]) because only the health probe needs it. + pub async fn get_ledger_info(&self) -> Result { + get_json(&self.client, self.base.clone()).await + } + + /// A transaction by ledger version (`GET /v1/transactions/by_version/{version}`). The health + /// probe reads a recent, unpruned transaction to exercise the extraction pipeline. + pub async fn get_transaction_by_version( + &self, + version: u64, + ) -> Result { + get_json(&self.client, build_version_url(&self.base, version)).await + } +} + +async fn get_json( + client: &reqwest::Client, + url: Url, +) -> Result { + let response = client.get(url).send().await?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(AptosRpcError::ApiError { + status: status.as_u16(), + body, + }); + } + Ok(response.json().await?) } /// Appends `transactions/by_hash/{hash}` to `base`, preserving its path and query string (so a @@ -98,6 +137,16 @@ fn build_request_url(base: &Url, tx_hash_hex: &str) -> Url { url } +/// Like [`build_request_url`], but for `transactions/by_version/{version}`. +fn build_version_url(base: &Url, version: u64) -> Url { + let mut url = base.clone(); + url.path_segments_mut() + .expect("an http(s) base URL always supports path segments") + .pop_if_empty() + .extend(["transactions", "by_version", &version.to_string()]); + url +} + impl AptosRpcClient for ReqwestAptosClient { fn get_transaction_by_hash( &self, @@ -105,19 +154,7 @@ impl AptosRpcClient for ReqwestAptosClient { ) -> impl Future> + Send { let url = build_request_url(&self.base, tx_hash_hex); let client = self.client.clone(); - async move { - let response = client.get(url).send().await?; - let status = response.status(); - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(AptosRpcError::ApiError { - status: status.as_u16(), - body, - }); - } - let parsed = response.json::().await?; - Ok(parsed) - } + async move { get_json(&client, url).await } } } diff --git a/crates/foreign-chain-rpc-interfaces/src/bitcoin.rs b/crates/foreign-chain-rpc-interfaces/src/bitcoin.rs index 18cfbc8cf4..cad0bf806b 100644 --- a/crates/foreign-chain-rpc-interfaces/src/bitcoin.rs +++ b/crates/foreign-chain-rpc-interfaces/src/bitcoin.rs @@ -96,3 +96,42 @@ impl Serialize for GetBlockHashArgs { impl ToRpcParams for &GetBlockHashArgs { to_rpc_params_impl!(); } + +/// `getbestblockhash` takes no parameters; it returns the hash of the chain tip. +/// +pub struct GetBestBlockHashArgs; + +impl ToRpcParams for &GetBestBlockHashArgs { + fn to_rpc_params(self) -> Result>, serde_json::Error> { + Ok(None) + } +} + +/// Request args for `getblock` at verbosity 1, whose response lists the block's transaction ids. +/// +pub struct GetBlockArgs { + pub blockhash: TransportBitcoinBlockHash, + pub verbosity: u8, +} + +impl Serialize for GetBlockArgs { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let request_parameters = (&self.blockhash, &self.verbosity); + request_parameters.serialize(serializer) + } +} + +impl ToRpcParams for &GetBlockArgs { + to_rpc_params_impl!(); +} + +/// Partial `getblock` response (verbosity 1). The health probe reads the height of the chain +/// tip and a transaction id from a recent block to exercise the inspector against. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct GetBlockResponse { + pub height: u64, + pub tx: Vec, +} diff --git a/crates/foreign-chain-rpc-interfaces/src/evm.rs b/crates/foreign-chain-rpc-interfaces/src/evm.rs index d7c364d0d7..a007f86750 100644 --- a/crates/foreign-chain-rpc-interfaces/src/evm.rs +++ b/crates/foreign-chain-rpc-interfaces/src/evm.rs @@ -34,6 +34,18 @@ pub struct GetBlockByNumberResponse { pub hash: H256, } +/// Like [`GetBlockByNumberResponse`] but also captures the block's transaction hashes +/// (`eth_getBlockByNumber` with `full = false` returns `transactions` as an array of hashes). +/// Used by the health probe to discover a recent transaction to exercise the inspector against; +/// kept separate so the finality/canonical checks keep deserializing the leaner response. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GetBlockByNumberWithTxsResponse { + pub number: U64, + pub hash: H256, + #[serde(default)] + pub transactions: Vec, +} + /// Partial RPC arguments for `eth_getBlockByNumber`. /// #[derive( @@ -107,3 +119,12 @@ impl ToRpcParams for &GetTransactionReceiptARgs { impl ToRpcParams for &GetBlockByNumberArgs { to_rpc_params_impl!(); } + +/// `eth_chainId` takes no parameters. +pub struct ChainIdArgs; + +impl ToRpcParams for &ChainIdArgs { + fn to_rpc_params(self) -> Result>, serde_json::Error> { + Ok(None) + } +} diff --git a/crates/foreign-chain-rpc-interfaces/src/starknet.rs b/crates/foreign-chain-rpc-interfaces/src/starknet.rs index edb5758c54..f4c1c5b65a 100644 --- a/crates/foreign-chain-rpc-interfaces/src/starknet.rs +++ b/crates/foreign-chain-rpc-interfaces/src/starknet.rs @@ -62,14 +62,22 @@ impl ToRpcParams for &GetTransactionReceiptArgs { to_rpc_params_impl!(); } -/// Block identifier accepted by Starknet block-lookup RPCs. The inspector's canonical-chain -/// check uses `Number`; add more variants only when a caller actually needs them. +/// Block identifier accepted by Starknet block-lookup RPCs: a height or a named tag. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(untagged)] pub enum BlockId { + Tag(BlockTag), Number { block_number: u64 }, } +/// A named Starknet block, serialized as the bare string the JSON-RPC spec expects. +/// `L1Accepted` (the latest block settled on Ethereum L1) requires provider JSON-RPC v0.9+. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BlockTag { + L1Accepted, +} + /// Partial RPC response for `starknet_getBlockWithTxHashes`. /// #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] @@ -77,6 +85,9 @@ pub struct GetBlockWithTxHashesResponse { #[serde(deserialize_with = "deserialize_starknet_felt")] pub block_hash: H256, pub block_number: u64, + /// Transaction hashes in block order; empty if the response omitted them. + #[serde(default, deserialize_with = "deserialize_starknet_felt_vec")] + pub transactions: Vec, } /// Request args for `starknet_getBlockWithTxHashes`. @@ -99,10 +110,19 @@ impl ToRpcParams for &GetBlockWithTxHashesArgs { to_rpc_params_impl!(); } +/// `starknet_chainId` takes no parameters. +pub struct ChainIdArgs; + +impl ToRpcParams for &ChainIdArgs { + fn to_rpc_params(self) -> Result>, serde_json::Error> { + Ok(None) + } +} + /// Starknet felt values use `0x`-prefixed hex like Ethereum, but may omit leading /// zeros (e.g. `"0x5"` instead of `"0x0000…0005"`). This function zero-pads /// short representations so they can be parsed as an [`H256`]. -fn parse_felt(s: &str) -> Result { +pub fn parse_felt(s: &str) -> Result { let stripped = s.strip_prefix("0x").unwrap_or(s); if stripped.len() > 64 { @@ -136,7 +156,7 @@ where #[expect(non_snake_case)] mod tests { use super::{ - BlockId, GetBlockWithTxHashesArgs, GetBlockWithTxHashesResponse, + BlockId, BlockTag, GetBlockWithTxHashesArgs, GetBlockWithTxHashesResponse, GetTransactionReceiptResponse, StarknetExecutionStatus, StarknetFinalityStatus, parse_felt, }; @@ -215,6 +235,20 @@ mod tests { ); } + #[test] + fn serialize_block_id__should_render_l1_accepted_tag_as_bare_string() { + // given + let args = GetBlockWithTxHashesArgs { + block_id: BlockId::Tag(BlockTag::L1Accepted), + }; + + // when + let serialized = serde_json::to_value(&args).unwrap(); + + // then — a named tag is a bare string, not a `{ block_number }` object. + assert_eq!(serialized, serde_json::json!(["l1_accepted"])); + } + #[test] fn deserialize_get_block_with_tx_hashes_response__should_accept_short_hex_block_hash() { let json = serde_json::json!({ @@ -229,6 +263,7 @@ mod tests { GetBlockWithTxHashesResponse { block_hash: parse_felt(SHORT_HEX_BLOCK_HASH).unwrap(), block_number: TEST_BLOCK_NUMBER, + transactions: vec![], } ); } diff --git a/crates/node-config/src/foreign_chains.rs b/crates/node-config/src/foreign_chains.rs index a063be04af..8e40e65eb4 100644 --- a/crates/node-config/src/foreign_chains.rs +++ b/crates/node-config/src/foreign_chains.rs @@ -42,6 +42,42 @@ pub struct ForeignChainsConfig { pub sui: Option, } +/// Startup foreign-chain health-check config: the expected chain identity the node probes +/// each configured provider against. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct ForeignChainHealthCheckConfig { + #[serde(default)] + pub identities: ExpectedIdentities, +} + +/// Expected chain identities from `foreign_chain_health_check.identities`, one field per +/// identity-probed chain. A configured chain with no identity fails its health check; +/// there are no built-in defaults, so any network (including local) is checkable. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default, deny_unknown_fields)] +pub struct ExpectedIdentities { + #[serde(skip_serializing_if = "Option::is_none")] + pub base: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bnb: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub arbitrum: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub polygon: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hyper_evm: Option, + #[serde(rename = "abstract", skip_serializing_if = "Option::is_none")] + pub abstract_chain: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bitcoin: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub starknet: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub aptos: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sui: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct ForeignChainConfig { pub timeout_sec: NonZeroU64, diff --git a/crates/node-config/src/lib.rs b/crates/node-config/src/lib.rs index 3972d695dd..0138f25e3e 100644 --- a/crates/node-config/src/lib.rs +++ b/crates/node-config/src/lib.rs @@ -2,7 +2,8 @@ pub mod foreign_chains; pub mod start; pub use foreign_chains::{ - AuthConfig, ForeignChainConfig, ForeignChainProviderConfig, ForeignChainsConfig, TokenConfig, + AuthConfig, ExpectedIdentities, ForeignChainConfig, ForeignChainHealthCheckConfig, + ForeignChainProviderConfig, ForeignChainsConfig, TokenConfig, }; pub use start::{ ChainId, DownloadConfigType, GcpStartConfig, LogConfig, LogFormat, NearInitConfig, @@ -146,6 +147,9 @@ pub struct ConfigFile { pub keygen: KeygenConfig, #[serde(default)] pub foreign_chains: ForeignChainsConfig, + /// Expected chain identities for the startup foreign-chain RPC provider health check. + #[serde(default)] + pub foreign_chain_health_check: ForeignChainHealthCheckConfig, /// This value is only considered when the node is run in normal node. It defines the number of /// working threads for the runtime. pub cores: Option, diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index 82b242040d..e0a501d5b5 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -23,6 +23,7 @@ clap = { workspace = true } derive_more = { workspace = true } ed25519-dalek = { workspace = true } flume = { workspace = true } +foreign-chain-health-check = { workspace = true } foreign-chain-inspector = { workspace = true } foreign-chain-rpc-auth = { workspace = true } foreign-chain-rpc-interfaces = { workspace = true } diff --git a/crates/node/src/foreign_chain_health.rs b/crates/node/src/foreign_chain_health.rs new file mode 100644 index 0000000000..3225a6968a --- /dev/null +++ b/crates/node/src/foreign_chain_health.rs @@ -0,0 +1,207 @@ +//! Healthcheck of every configured foreign-chain RPC provider, via +//! [`foreign_chain_health_check`]. + +use std::panic::AssertUnwindSafe; + +use foreign_chain_health_check::{ExpectedIdentities, ProviderResult, Status, check_all_providers}; +use futures::FutureExt as _; +use mpc_node_config::ForeignChainsConfig; +use tracing::{debug, error, info, warn}; + +/// Startup healthcheck entrypoint: probes every configured provider against its configured +/// expected identity ([`foreign_chain_health_check`]) and logs a per-provider result plus an +/// `x/y providers healthy` summary. A probe panic is caught and logged, never propagated +/// (the check is diagnostic-only; the node runs regardless). +pub async fn run_startup_health_check( + foreign_chains: ForeignChainsConfig, + identities: ExpectedIdentities, +) { + // Catch a probe panic (e.g. an inspector bug) and log it, rather than letting it vanish + // with the spawned task's dropped join handle. + let probe = async move { + info!("running foreign-chain RPC provider health check"); + let results = check_all_providers(&foreign_chains, &identities).await; + log_results(&results); + }; + + if AssertUnwindSafe(probe).catch_unwind().await.is_err() { + error!( + "foreign-chain RPC provider health check panicked (diagnostic-only; node unaffected)" + ); + } +} + +fn log_results(results: &[ProviderResult]) { + let mut healthy = 0; + let mut failed = 0; + for result in results { + match &result.status { + Status::Passed => { + healthy += 1; + debug!( + chain = result.chain, + provider = %result.provider, + "foreign-chain RPC provider health check passed" + ); + } + Status::Skipped(reason) => { + debug!( + chain = result.chain, + provider = %result.provider, + reason = %reason, + "foreign-chain RPC provider health check skipped" + ); + } + Status::Failed(_) => { + failed += 1; + // TODO(#2350): Also log the failure reason once systematic secret redaction is implemented. + warn!( + chain = result.chain, + provider = %result.provider, + "foreign-chain RPC provider health check failed" + ); + } + } + } + let checked = healthy + failed; + if checked == 0 { + warn!( + skipped = results.len(), + "foreign-chain RPC provider health check probed no providers; \ + foreign_chains is empty or every configured chain was skipped" + ); + return; + } + info!( + "foreign-chain RPC provider health check complete: {healthy}/{checked} providers healthy" + ); +} + +#[cfg(test)] +#[expect(non_snake_case)] +mod tests { + use super::*; + use mpc_node_config::{AuthConfig, ForeignChainConfig, ForeignChainProviderConfig}; + use near_mpc_bounded_collections::NonEmptyBTreeMap; + use std::num::NonZeroU64; + use tracing_test::traced_test; + + fn chain_config(rpc_url: &str) -> ForeignChainConfig { + ForeignChainConfig { + timeout_sec: NonZeroU64::new(5).unwrap(), + max_retries: NonZeroU64::new(1).unwrap(), + providers: NonEmptyBTreeMap::new( + "only".to_string().into(), + ForeignChainProviderConfig { + rpc_url: rpc_url.to_string(), + auth: AuthConfig::None, + }, + ), + } + } + + #[tokio::test] + #[traced_test] + async fn run_startup_health_check__should_probe_configured_providers_against_their_identity() { + // Given a `base` provider with a configured identity but nothing listening on the URL + // (connection refused, no external traffic) + let foreign_chains = ForeignChainsConfig { + base: Some(chain_config("http://127.0.0.1:1")), + ..Default::default() + }; + let identities = ExpectedIdentities { + base: Some("8453".to_string()), + ..Default::default() + }; + + // When + run_startup_health_check(foreign_chains, identities).await; + + // Then the probe runs end to end and summarizes the one probed provider (it fails to + // connect, so 0 of 1 are healthy) + assert!(logs_contain( + "running foreign-chain RPC provider health check" + )); + assert!(logs_contain( + "foreign-chain RPC provider health check complete: 0/1 providers healthy" + )); + } + + #[test] + #[traced_test] + fn log_results__should_not_log_the_failure_reason() { + // Given a failed result carrying a key-bearing reason + let results = vec![ProviderResult { + chain: "base", + provider: "alchemy".to_string(), + status: Status::Failed("boom at https://x/key-bearing-url".to_string()), + }]; + + // When + log_results(&results); + + // Then the failure is announced (chain + provider) but the reason — + // which can carry a secret — is not logged anywhere. + assert!(logs_contain( + "foreign-chain RPC provider health check failed" + )); + logs_assert(|lines: &[&str]| { + if lines.iter().any(|line| line.contains("key-bearing-url")) { + Err("failure reason was logged".to_string()) + } else { + Ok(()) + } + }); + } + + #[test] + #[traced_test] + fn log_results__should_summarize_healthy_ratio_at_info() { + // Given one healthy and one failed provider + let results = vec![ + ProviderResult { + chain: "base", + provider: "healthy".to_string(), + status: Status::Passed, + }, + ProviderResult { + chain: "base", + provider: "broken".to_string(), + status: Status::Failed("boom".to_string()), + }, + ]; + + // When + log_results(&results); + + // Then the detached probe announces completion with a human-readable ratio + assert!(logs_contain( + "foreign-chain RPC provider health check complete: 1/2 providers healthy" + )); + } + + #[test] + #[traced_test] + fn log_results__should_warn_when_no_providers_were_probed() { + // Given only skipped rows — nothing was actually probed + let results = vec![ProviderResult { + chain: "base", + provider: "-".to_string(), + status: Status::Skipped("not configured".to_string()), + }]; + + // When + log_results(&results); + + // Then a check that verified nothing is flagged loudly, not reported as 0/0 + logs_assert(|lines: &[&str]| { + match lines + .iter() + .any(|l| l.contains("WARN") && l.contains("probed no providers")) + { + true => Ok(()), + false => Err("expected a WARN when nothing was probed".to_string()), + } + }); + } +} diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index 890277443f..9e24c7e6ed 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -28,6 +28,7 @@ pub mod cli; pub mod config; mod coordinator; mod db; +mod foreign_chain_health; mod foreign_chain_whitelist_verifier; mod home_paths; mod indexer; diff --git a/crates/node/src/run.rs b/crates/node/src/run.rs index a3f0a8d9c8..0648403b84 100644 --- a/crates/node/src/run.rs +++ b/crates/node/src/run.rs @@ -209,6 +209,12 @@ pub async fn run_mpc_node(config: StartConfig) -> anyhow::Result<()> { let _web_server_join_handle = root_runtime.spawn(web_server); + // Detached, diagnostic-only startup probe of every configured foreign-chain RPC provider. + root_runtime.spawn(crate::foreign_chain_health::run_startup_health_check( + node_config.foreign_chains.clone(), + node_config.foreign_chain_health_check.identities.clone(), + )); + // Create Indexer and wait for indexer to be synced. let (indexer_exit_sender, indexer_exit_receiver) = oneshot::channel(); // Dedicated cancellation token for the indexer thread. Cancelled after diff --git a/crates/node/src/tests.rs b/crates/node/src/tests.rs index b7b56176a6..c31b049fc0 100644 --- a/crates/node/src/tests.rs +++ b/crates/node/src/tests.rs @@ -228,6 +228,7 @@ impl IntegrationTestSetup { signature: SignatureConfig { timeout_sec: 60 }, ckd: CKDConfig { timeout_sec: 60 }, foreign_chains: ForeignChainsConfig::default(), + foreign_chain_health_check: Default::default(), triple: TripleConfig { concurrency: 1, desired_triples_to_buffer: 10, diff --git a/crates/node/src/web.rs b/crates/node/src/web.rs index 1ad580f54d..3131001438 100644 --- a/crates/node/src/web.rs +++ b/crates/node/src/web.rs @@ -532,6 +532,7 @@ mod tests { aptos: Some(test_chain(PROVIDER_PUBLIC, APTOS_RPC_URL, AuthConfig::None)), sui: Some(test_chain(PROVIDER_PUBLIC, SUI_RPC_URL, AuthConfig::None)), }, + foreign_chain_health_check: Default::default(), cores: Some(4), separate_asset_generation_runtime: true, } diff --git a/deployment/cvm-deployment/user-config.toml b/deployment/cvm-deployment/user-config.toml index 46d33f75e9..5df15586b2 100644 --- a/deployment/cvm-deployment/user-config.toml +++ b/deployment/cvm-deployment/user-config.toml @@ -148,3 +148,18 @@ max_retries = 3 [mpc_node_config.node.foreign_chains.starknet.providers.publicnode] rpc_url = "https://starknet-sepolia-rpc.publicnode.com" + +[mpc_node_config.node.foreign_chains.sui] +timeout_sec = 30 +max_retries = 3 + +[mpc_node_config.node.foreign_chains.sui.providers.fullnode] +rpc_url = "https://fullnode.testnet.sui.io:443" + +# Expected identity per chain for the provider health check — see +# crates/foreign-chain-config-tester/README.md for the well-known values. +[mpc_node_config.node.foreign_chain_health_check.identities] +abstract = "11124" # mainnet: "2741" +bitcoin = "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943" # mainnet: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" +starknet = "0x534e5f5345504f4c4941" # mainnet: "0x534e5f4d41494e" +sui = "69WiPg3DAQiwdxfncX6wYQ2siKwAe6L9BZthQea3JNMD" # mainnet: "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S" diff --git a/docs/foreign-chain-transactions.md b/docs/foreign-chain-transactions.md index 219a5c0f65..b462522c72 100644 --- a/docs/foreign-chain-transactions.md +++ b/docs/foreign-chain-transactions.md @@ -647,6 +647,15 @@ Auth variants are explicitly modeled because providers differ in how they expect to be supplied (e.g., bearer tokens, custom headers, query params, or URL path tokens), and some providers require no auth at all. +On startup the node probes every configured provider — verifying its chain identity and then +inspecting a recently produced transaction, the same inspector and auth the real verification +path uses — and logs a per-provider result plus an `x/y providers healthy` summary, so config +typos and un-enabled API keys surface immediately instead of on the first real verification +request. All providers are probed concurrently and the probe runs detached, never blocking +startup. Expected identities come from `foreign_chain_health_check.identities` in config (there +are no built-in values, so any network — including local — is checkable); a configured chain +with no identity fails its check. + ## Risks * **RPC trust and correctness**: Verification relies on centralized RPC providers. A malicious diff --git a/docs/localnet/mpc-config.template.toml b/docs/localnet/mpc-config.template.toml index eb28cbcf38..2d5d1f6580 100644 --- a/docs/localnet/mpc-config.template.toml +++ b/docs/localnet/mpc-config.template.toml @@ -104,3 +104,10 @@ rpc_url = "https://archive.mainnet.sui.io" [node.foreign_chains.sui.providers.public.auth] kind = "none" + +[node.foreign_chain_health_check.identities] +abstract = "11124" +aptos = "1" +bitcoin = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" +starknet = "0x534e5f4d41494e" +sui = "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S" diff --git a/docs/localnet/mpc-configs/config.yaml.template b/docs/localnet/mpc-configs/config.yaml.template index d8594248d6..17274a2b78 100644 --- a/docs/localnet/mpc-configs/config.yaml.template +++ b/docs/localnet/mpc-configs/config.yaml.template @@ -69,3 +69,10 @@ foreign_chains: rpc_url: "https://archive.mainnet.sui.io" auth: kind: none +foreign_chain_health_check: + identities: + abstract: "11124" + aptos: "1" + bitcoin: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" + starknet: "0x534e5f4d41494e" + sui: "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S"