Skip to content

Commit 7b0bc9f

Browse files
feat(node): probe the foreign chain RPC providers on startup
Spawns the probe detached, so it reports which network each provider serves without delaying startup or gating anything. A status carries no provider text and no auth material, so it is logged whole.
1 parent 052f530 commit 7b0bc9f

6 files changed

Lines changed: 144 additions & 1 deletion

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/node/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ clap = { workspace = true }
2323
derive_more = { workspace = true }
2424
ed25519-dalek = { workspace = true }
2525
flume = { workspace = true }
26+
foreign-chain-health-check = { workspace = true }
2627
foreign-chain-inspector = { workspace = true }
2728
foreign-chain-rpc-auth = { workspace = true }
2829
foreign-chain-rpc-interfaces = { workspace = true }
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
//! Startup probe of every configured foreign-chain RPC provider, via
2+
//! [`foreign_chain_health_check::probe`].
3+
4+
use std::panic::AssertUnwindSafe;
5+
6+
use foreign_chain_health_check::probe::{ProbeReport, ProviderStatus, probe_all_providers};
7+
use futures::FutureExt as _;
8+
use mpc_node_config::ForeignChainsConfig;
9+
use tracing::{debug, error, info, warn};
10+
11+
/// Asks every configured provider which network it serves and logs a line per provider plus an
12+
/// `x/y providers healthy` summary. Diagnostic only: a provider on the wrong network keeps
13+
/// serving, because a boot time blip should not take a chain out of signing.
14+
///
15+
/// A panic is caught and logged rather than vanishing with the spawned task's dropped handle.
16+
pub async fn run_startup_probe(foreign_chains: ForeignChainsConfig) {
17+
let probe = async move {
18+
info!("probing foreign-chain RPC providers");
19+
log_report(&probe_all_providers(&foreign_chains).await);
20+
};
21+
22+
if AssertUnwindSafe(probe).catch_unwind().await.is_err() {
23+
error!("foreign-chain RPC provider probe panicked (diagnostic only; node unaffected)");
24+
}
25+
}
26+
27+
/// A [`ProviderStatus`] carries no provider text and no auth material, so it is logged whole.
28+
fn log_report(report: &ProbeReport) {
29+
for row in report.rows() {
30+
match &row.status {
31+
ProviderStatus::Healthy => debug!(
32+
chain = ?row.chain,
33+
provider = %row.provider,
34+
"foreign-chain RPC provider serves the expected network",
35+
),
36+
ProviderStatus::ProbeNotImplemented => debug!(
37+
chain = ?row.chain,
38+
provider = %row.provider,
39+
"foreign-chain RPC provider cannot be probed",
40+
),
41+
unhealthy => warn!(
42+
chain = ?row.chain,
43+
provider = %row.provider,
44+
status = ?unhealthy,
45+
"foreign-chain RPC provider is unhealthy",
46+
),
47+
}
48+
}
49+
50+
let counts = report.counts_per_chain();
51+
let configured: usize = counts.values().map(|count| count.configured).sum();
52+
if configured == 0 {
53+
warn!("foreign-chain RPC provider probe found nothing to probe: no chain is configured");
54+
return;
55+
}
56+
let healthy: usize = counts.values().map(|count| count.healthy).sum();
57+
info!("foreign-chain RPC provider probe complete: {healthy}/{configured} providers healthy");
58+
}
59+
60+
#[cfg(test)]
61+
#[expect(non_snake_case)]
62+
mod tests {
63+
use super::*;
64+
use mpc_node_config::{AuthConfig, ForeignChainConfig, ForeignChainProviderConfig};
65+
use near_mpc_bounded_collections::NonEmptyBTreeMap;
66+
use std::num::NonZeroU64;
67+
use tracing_test::traced_test;
68+
69+
/// Reserved as "discard", so nothing listens there.
70+
const CLOSED_PORT_URL: &str = "http://127.0.0.1:9";
71+
72+
fn chain_config(expected: &str, rpc_url: &str) -> ForeignChainConfig {
73+
ForeignChainConfig {
74+
timeout_sec: NonZeroU64::new(1).unwrap(),
75+
max_retries: NonZeroU64::new(1).unwrap(),
76+
expected_network_fingerprint: Some(expected.to_string()),
77+
providers: NonEmptyBTreeMap::new(
78+
"only".to_string().into(),
79+
ForeignChainProviderConfig {
80+
rpc_url: rpc_url.to_string(),
81+
auth: AuthConfig::None,
82+
},
83+
),
84+
}
85+
}
86+
87+
#[tokio::test]
88+
#[traced_test]
89+
async fn run_startup_probe__should_probe_every_configured_provider() {
90+
// Given
91+
let foreign_chains = ForeignChainsConfig {
92+
base: Some(chain_config("8453", CLOSED_PORT_URL)),
93+
..Default::default()
94+
};
95+
96+
// When
97+
run_startup_probe(foreign_chains).await;
98+
99+
// Then
100+
assert!(logs_contain("probing foreign-chain RPC providers"));
101+
assert!(logs_contain(
102+
"foreign-chain RPC provider probe complete: 0/1 providers healthy"
103+
));
104+
}
105+
106+
#[tokio::test]
107+
#[traced_test]
108+
async fn run_startup_probe__should_warn_when_no_chain_is_configured() {
109+
// Given
110+
let foreign_chains = ForeignChainsConfig::default();
111+
112+
// When
113+
run_startup_probe(foreign_chains).await;
114+
115+
// Then
116+
assert!(logs_contain("no chain is configured"));
117+
}
118+
119+
#[tokio::test]
120+
#[traced_test]
121+
async fn run_startup_probe__should_name_the_unhealthy_provider_and_its_status() {
122+
// Given
123+
let foreign_chains = ForeignChainsConfig {
124+
base: Some(chain_config("8453", CLOSED_PORT_URL)),
125+
..Default::default()
126+
};
127+
128+
// When
129+
run_startup_probe(foreign_chains).await;
130+
131+
// Then
132+
assert!(logs_contain("foreign-chain RPC provider is unhealthy"));
133+
assert!(logs_contain("Unreachable"));
134+
}
135+
}

crates/node/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ mod async_testing;
4141
mod background;
4242
mod coordinator;
4343
mod db;
44+
mod foreign_chain_probe;
4445
mod foreign_chain_whitelist_verifier;
4546
mod home_paths;
4647
mod indexer;

crates/node/src/run.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,11 @@ pub async fn run_mpc_node(config: StartConfig) -> anyhow::Result<()> {
209209

210210
let _web_server_join_handle = root_runtime.spawn(web_server);
211211

212+
// Detached and diagnostic only: reports which network each configured provider serves.
213+
root_runtime.spawn(crate::foreign_chain_probe::run_startup_probe(
214+
node_config.foreign_chains.clone(),
215+
));
216+
212217
// Create Indexer and wait for indexer to be synced.
213218
let (indexer_exit_sender, indexer_exit_receiver) = oneshot::channel();
214219
// Dedicated cancellation token for the indexer thread. Cancelled after

docs/foreign-chain-transactions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -525,7 +525,7 @@ Voting uses the protocol's existing signing threshold (`self.threshold()?.value(
525525

526526
The per-chain map key prevents *lookup* confusion: when the node resolves the operator's `ethereum:` section, only `entries[Ethereum]` is consulted, never `entries[Sepolia]`. What it doesn't prevent is a `ChainVote { chain: Ethereum, providers: [ProviderEntry { provider_id: "ankr", chain_routing: PathSegment { segment: "eth_sepolia" }, … }, …], threshold: _ }` getting voted in — the contract just stores what threshold consensus produces; it can't tell whether `"eth_sepolia"` actually corresponds to Ethereum mainnet. Threshold voter review is the first line of defense; the fan-out across a chain's providers is the structural one. The network fingerprint probe is a per-node diagnostic on top of both.
527527

528-
Once wired into node startup, each resolved provider gets its self-identifying RPC called and the response is compared against that chain's `expected_network_fingerprint` from the operator's config. The probe is report-only: a provider serving the wrong network is logged, but is not dropped, because a boot-time network blip should not take a chain out of signing.
528+
At startup, each configured provider gets its self-identifying RPC called and the response is compared against that chain's `expected_network_fingerprint` from the operator's config. The probe is report-only: a provider serving the wrong network is logged, but is not dropped, because a boot-time network blip should not take a chain out of signing. It runs detached, so it never delays startup.
529529

530530
Taking the expected value from operator config rather than a constant in the attested binary is a deliberate trade. It makes mixed-network and local deployments checkable at all, since a config may pair one chain's mainnet with another's testnet and no binary can ship a value for a devnet. The cost is that the check no longer binds an operator: they can set the wrong value, or omit the field and get no check at all, and either way they fool only their own node's diagnostics. The network-level defenses against a wrong URL are unchanged: threshold voter review of the whitelist, and the provider fan-out, which fails the individual request when a provider disagrees with its siblings.
531531

0 commit comments

Comments
 (0)