Skip to content

Commit b61880b

Browse files
feat(node): repeat the foreign chain probe every hour
A verdict taken once at boot goes stale: a provider can start serving another network, or go down, while the node is up. The probe now runs on a ticker and the gauges carry the latest round. Tick and its test double move out of remote_attestation into a shared tick module, so both periodic loops use one abstraction.
1 parent 8013554 commit b61880b

7 files changed

Lines changed: 171 additions & 45 deletions

File tree

crates/node/src/foreign_chain_probe.rs

Lines changed: 105 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
//! Startup probe of every configured foreign-chain RPC provider, via
1+
//! Periodic probe of every configured foreign-chain RPC provider, via
22
//! [`foreign_chain_health_check::probe`].
33
44
use std::collections::BTreeSet;
5+
use std::future::Future;
56

67
use foreign_chain_health_check::probe::{
78
ProbeReport, ProviderHealth, ProviderStatus, probe_all_providers,
@@ -11,19 +12,31 @@ use near_mpc_contract_interface::types as dtos;
1112
use tracing::{info, warn};
1213

1314
use crate::metrics;
15+
use crate::tick::Tick;
1416

15-
/// Asks every configured RPC provider which network it serves and reports the verdicts as logs and
16-
/// metrics. Diagnostic only: nothing gates on the result.
17-
pub async fn run_startup_probe(foreign_chains: ForeignChainsConfig) {
17+
/// Asks every configured RPC provider which network it serves, once per tick of `ticker`, and
18+
/// reports the verdicts as logs and metrics. Diagnostic only: nothing gates on the result.
19+
pub async fn run_periodic_probe(foreign_chains: ForeignChainsConfig, ticker: impl Tick) {
1820
if foreign_chains.is_empty() {
1921
warn!("no foreign chain is configured: this node cannot verify foreign-chain transactions");
2022
return;
2123
}
2224

23-
info!("probing foreign-chain RPC providers");
24-
let report = probe_all_providers(&foreign_chains).await;
25-
publish_metrics(&report);
26-
log_report(&report);
25+
probe_periodically(|| probe_all_providers(&foreign_chains), ticker).await;
26+
}
27+
28+
async fn probe_periodically<Probe: Future<Output = ProbeReport>>(
29+
probe: impl Fn() -> Probe,
30+
mut ticker: impl Tick,
31+
) {
32+
loop {
33+
ticker.tick().await;
34+
35+
info!("probing foreign-chain RPC providers");
36+
let report = probe().await;
37+
publish_metrics(&report);
38+
log_report(&report);
39+
}
2740
}
2841

2942
fn is_probed(row: &ProviderHealth) -> bool {
@@ -71,7 +84,7 @@ fn log_report(report: &ProbeReport) {
7184
let chains: BTreeSet<&str> = rows.iter().map(|row| row.chain.label()).collect();
7285
warn!(
7386
?chains,
74-
"no RPC provider was checked at startup: none of the configured foreign chains has a probe"
87+
"no RPC provider was checked: none of the configured foreign chains has a probe"
7588
);
7689
return;
7790
}
@@ -103,8 +116,12 @@ fn publish_metrics(report: &ProbeReport) {
103116
#[expect(non_snake_case)]
104117
mod tests {
105118
use super::*;
119+
use crate::async_testing::{MaybeReady, run_future_once};
120+
use crate::tick::MockTicker;
106121
use foreign_chain_health_check::probe::ProviderCounts;
107122
use prometheus::core::Collector as _;
123+
use std::cell::{Cell, RefCell};
124+
use std::collections::VecDeque;
108125

109126
fn labelled_chains(gauge: &prometheus::IntGaugeVec) -> BTreeSet<String> {
110127
gauge
@@ -243,4 +260,83 @@ mod tests {
243260
assert!(chains.contains("bnb"));
244261
assert!(!chains.contains("solana"));
245262
}
263+
264+
#[test]
265+
fn probe_periodically__should_probe_once_per_tick() {
266+
// Given
267+
let probe_count = Cell::new(0);
268+
let probe = || {
269+
probe_count.set(probe_count.get() + 1);
270+
std::future::ready(ProbeReport::from(vec![]))
271+
};
272+
273+
// When
274+
run_future_once(probe_periodically(probe, MockTicker::new(3)));
275+
276+
// Then
277+
assert_eq!(probe_count.get(), 3);
278+
}
279+
280+
#[test]
281+
fn probe_periodically__should_replace_the_gauges_of_the_previous_round() {
282+
// Given
283+
let rounds = RefCell::new(VecDeque::from([
284+
ProbeReport::from(vec![row(
285+
dtos::ForeignChain::Starknet,
286+
"only",
287+
ProviderStatus::Healthy,
288+
)]),
289+
ProbeReport::from(vec![row(
290+
dtos::ForeignChain::Starknet,
291+
"only",
292+
ProviderStatus::Unreachable,
293+
)]),
294+
]));
295+
let probe_dispatch =
296+
|| std::future::ready(rounds.borrow_mut().pop_front().expect("a report per tick"));
297+
let ticker = MockTicker::new(1);
298+
299+
// When
300+
let MaybeReady::Future(parked_probe_loop) =
301+
run_future_once(probe_periodically(probe_dispatch, ticker.clone()))
302+
else {
303+
panic!("the loop should park once its ticker runs out");
304+
};
305+
let metrics_after_the_first_round = gauges("starknet");
306+
ticker.schedule(1);
307+
run_future_once(parked_probe_loop);
308+
309+
// Then
310+
assert_eq!(
311+
metrics_after_the_first_round,
312+
ProviderCounts {
313+
configured: 1,
314+
healthy: 1
315+
}
316+
);
317+
assert_eq!(
318+
gauges("starknet"),
319+
ProviderCounts {
320+
configured: 1,
321+
healthy: 0
322+
}
323+
);
324+
}
325+
326+
#[test]
327+
fn run_periodic_probe__should_stop_when_no_foreign_chain_is_configured() {
328+
// Given
329+
let foreign_chains = ForeignChainsConfig::default();
330+
let ticker = MockTicker::new(1);
331+
332+
// When
333+
let outcome = run_future_once(run_periodic_probe(foreign_chains, ticker.clone()));
334+
335+
// Then
336+
assert_eq!(ticker.unspent(), 1, "no round should have run");
337+
assert!(
338+
matches!(outcome, MaybeReady::Ready(())),
339+
"the probe should return rather than park on its ticker"
340+
);
341+
}
246342
}

crates/node/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,5 @@ mod storage;
5858
mod tee;
5959
#[cfg(test)]
6060
mod tests;
61+
mod tick;
6162
mod tracking;

crates/node/src/metrics.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,7 @@ pub static FOREIGN_CHAIN_RPC_PROVIDERS_HEALTHY: LazyLock<prometheus::IntGaugeVec
494494
LazyLock::new(|| {
495495
prometheus::register_int_gauge_vec!(
496496
"mpc_foreign_chain_rpc_providers_healthy",
497-
"RPC providers that served the expected network at startup",
497+
"RPC providers that served the expected network at the latest probe",
498498
&["chain"],
499499
)
500500
.unwrap()

crates/node/src/run.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ use crate::tee::{
5050
};
5151

5252
pub const ATTESTATION_RESUBMISSION_INTERVAL: Duration = Duration::from_secs(60 * 60); // 1 hour
53+
pub const FOREIGN_CHAIN_PROBE_INTERVAL: Duration = Duration::from_secs(60 * 60); // 1 hour
5354

5455
pub async fn run_mpc_node(config: StartConfig) -> anyhow::Result<()> {
5556
init_logging(&config.log);
@@ -207,8 +208,9 @@ pub async fn run_mpc_node(config: StartConfig) -> anyhow::Result<()> {
207208
let _web_server_join_handle = root_runtime.spawn(web_server);
208209

209210
// Detached: the report is diagnostic, nothing downstream waits on it.
210-
root_runtime.spawn(crate::foreign_chain_probe::run_startup_probe(
211+
root_runtime.spawn(crate::foreign_chain_probe::run_periodic_probe(
211212
node_config.foreign_chains.clone(),
213+
tokio::time::interval(FOREIGN_CHAIN_PROBE_INTERVAL),
212214
));
213215

214216
// Create Indexer and wait for indexer to be synced.

crates/node/src/tee/remote_attestation.rs

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use crate::{
55
tx_sender::{TransactionSender, TransactionStatus},
66
types::ChainSendTransactionRequest,
77
},
8+
tick::Tick,
89
trait_extensions::convert_to_contract_dto::IntoContractInterfaceType,
910
};
1011
use anyhow::Context;
@@ -362,21 +363,11 @@ pub async fn monitor_attestation_removal<T: TransactionSender + Clone>(
362363
Ok(())
363364
}
364365

365-
/// Allows repeatedly awaiting for something, like a [`tokio::time::Interval`].
366-
pub trait Tick {
367-
async fn tick(&mut self);
368-
}
369-
370-
impl Tick for tokio::time::Interval {
371-
async fn tick(&mut self) {
372-
self.tick().await;
373-
}
374-
}
375-
376366
#[cfg(test)]
377367
mod tests {
378368
use super::*;
379369
use crate::indexer::tx_sender::{TransactionProcessorError, TransactionStatus};
370+
use crate::tick::MockTicker;
380371
use ed25519_dalek::SigningKey;
381372
use rand::SeedableRng;
382373
use std::sync::{Arc, Mutex};
@@ -386,26 +377,6 @@ mod tests {
386377
const TEST_EXPECTED_ATTESTATION_RESUBMISSION_TIMEOUT: Duration = Duration::from_millis(100);
387378
const TEST_VERIFY_NO_ATTESTATION_RESUBMISSION_TIMEOUT: Duration = Duration::from_millis(100);
388379

389-
struct MockTicker {
390-
count: usize,
391-
}
392-
393-
impl MockTicker {
394-
fn new(count: usize) -> Self {
395-
Self { count }
396-
}
397-
}
398-
399-
impl Tick for MockTicker {
400-
async fn tick(&mut self) {
401-
if self.count > 0 {
402-
self.count -= 1;
403-
} else {
404-
std::future::pending::<()>().await;
405-
}
406-
}
407-
}
408-
409380
struct StubAttestationExpiryReader;
410381

411382
impl crate::indexer::ReadAttestationExpiry for StubAttestationExpiryReader {

crates/node/src/tick.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
//! Waiting for the next round of a periodic task.
2+
3+
#[cfg(test)]
4+
use std::sync::Arc;
5+
#[cfg(test)]
6+
use tokio::sync::Semaphore;
7+
8+
/// Allows repeatedly awaiting for something, like a [`tokio::time::Interval`].
9+
pub trait Tick {
10+
async fn tick(&mut self);
11+
}
12+
13+
impl Tick for tokio::time::Interval {
14+
async fn tick(&mut self) {
15+
self.tick().await;
16+
}
17+
}
18+
19+
/// Advances the loop under test by a counted number of rounds rather than by elapsed time.
20+
#[cfg(test)]
21+
#[derive(Clone)]
22+
pub struct MockTicker {
23+
scheduled: Arc<Semaphore>,
24+
}
25+
26+
#[cfg(test)]
27+
impl MockTicker {
28+
pub fn new(count: usize) -> Self {
29+
Self {
30+
scheduled: Arc::new(Semaphore::new(count)),
31+
}
32+
}
33+
34+
/// Lets the loop run `count` more rounds, from its next poll onwards.
35+
pub fn schedule(&self, count: usize) {
36+
self.scheduled.add_permits(count);
37+
}
38+
39+
/// Rounds scheduled but not yet taken by a loop.
40+
pub fn unspent(&self) -> usize {
41+
self.scheduled.available_permits()
42+
}
43+
}
44+
45+
#[cfg(test)]
46+
impl Tick for MockTicker {
47+
async fn tick(&mut self) {
48+
let round = self
49+
.scheduled
50+
.acquire()
51+
.await
52+
.expect("the Semaphore is never closed");
53+
// Spend the round.
54+
round.forget();
55+
}
56+
}

docs/foreign-chain-transactions.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,7 @@ The per-participant registration model above leaves the network with no shared n
409409
| What the operator picks | Full URL, auth scheme, token reference | `provider_id` (label) + token reference |
410410
| Adding a new provider | Every operator updates their yaml; the network effectively supports a chain once enough do | Threshold of participants vote in `(chain, ProviderEntry)`; operators reference it by `provider_id` only |
411411
| Removing a compromised provider | Every operator manually edits their yaml; coordination problem | Threshold of participants vote remove; nodes pick up the change via the indexer and drop the provider on next reconfigure |
412-
| Testnet vs mainnet separation | Implicit — operator decides what URL goes under which chain | Per-`ForeignChain` map slot, plus a startup *network fingerprint probe* that calls the chain's self-identifying RPC and compares the response against the chain's `expected_network_fingerprint` from operator config — catches both lookup-level (wrong bucket) and content-level (wrong URL voted into the right bucket) confusion. |
412+
| Testnet vs mainnet separation | Implicit — operator decides what URL goes under which chain | Per-`ForeignChain` map slot, plus a recurring *network fingerprint probe* that calls the chain's self-identifying RPC and compares the response against the chain's `expected_network_fingerprint` from operator config — catches both lookup-level (wrong bucket) and content-level (wrong URL voted into the right bucket) confusion. |
413413

414414
### Whitelist storage shape
415415

@@ -565,9 +565,9 @@ Voting uses the protocol's existing signing threshold (`self.threshold()?.value(
565565

566566
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.
567567

568-
At startup, every provider of a chain the node can identify 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. A node with no foreign chain configured at all is warned instead, because it cannot verify foreign-chain transactions.
568+
At startup and every hour after that, every provider of a chain the node can identify 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 network blip should not take a chain out of signing. It runs detached, so it never delays startup. Repeating it turns a single snapshot taken at boot into a signal that also catches a provider that starts serving another network, or goes down, while the node is up. A node with no foreign chain configured at all is warned once at startup instead, because it cannot verify foreign-chain transactions.
569569

570-
The result is a line per provider, an `x/y providers healthy` summary counting only the providers a probe covers, and two gauges labelled by chain: `mpc_foreign_chain_rpc_providers_configured` and `mpc_foreign_chain_rpc_providers_healthy`. The gauges are per chain rather than per provider, because a provider name is operator chosen and would put an unbounded label on a time series. A chain whose providers cannot be identified, such as Solana, is left out of both the summary and the gauges: reporting `0` healthy against its configured count would read as every provider failing.
570+
The result of each round is a line per provider, an `x/y providers healthy` summary counting only the providers a probe covers, and two gauges labelled by chain, which carry the verdicts of the round that ran last: `mpc_foreign_chain_rpc_providers_configured` and `mpc_foreign_chain_rpc_providers_healthy`. The gauges are per chain rather than per provider, because a provider name is operator chosen and would put an unbounded label on a time series. A chain whose providers cannot be identified, such as Solana, is left out of both the summary and the gauges: reporting `0` healthy against its configured count would read as every provider failing.
571571

572572
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.
573573

0 commit comments

Comments
 (0)