use std::collections::HashSet;
use std::future::Future;
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use alloy::primitives::{Address, Bytes, U256};
use alloy::providers::ProviderBuilder;
use anyhow::Context;
use decdn_common::redact::{sanitize_err_chain, sanitize_error_sources};
use decdn_incentive::capacity_bond::CapacityBond;
use decdn_protocol::{Coverage, Region};
use iroh::PublicKey;
use rand::seq::SliceRandom;
use serde::{Deserialize, Serialize};
use crate::provider::is_permanent_contract_error;
const PAGE_SIZE: u64 = 100;
const REGISTRY_RETRY_BACKOFF: [Duration; 3] = [
Duration::from_secs(1),
Duration::from_secs(5),
Duration::from_secs(30),
];
pub const BOOTSTRAP_UNREACHABLE: &str =
"Cannot reach bootstrap sources. Check network connectivity and RPC endpoint configuration.";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeCandidate {
pub node_id: PublicKey,
pub eth_address: Address,
pub region_hint: Option<Region>,
pub multiaddrs: Bytes,
}
impl NodeCandidate {
#[must_use]
pub fn dial_addrs(&self) -> Vec<std::net::SocketAddr> {
decdn_incentive::node_register::decode_dial_addrs(&self.multiaddrs)
}
}
#[must_use]
pub fn with_dial_addrs(mut target: iroh::EndpointAddr, cand: &NodeCandidate) -> iroh::EndpointAddr {
for sock in cand.dial_addrs() {
target = target.with_ip_addr(sock);
}
target
}
fn candidate_from(info: &CapacityBond::NodeInfo, is_active: bool) -> Option<NodeCandidate> {
if !is_active {
return None;
}
let node_id = PublicKey::from_bytes(&info.nodeId.0).ok()?;
Some(NodeCandidate {
node_id,
eth_address: info.ethAddress,
region_hint: Region::parse(&info.regionHint),
multiaddrs: info.multiaddrs.clone(),
})
}
async fn paginate_with_retry<F, Fut>(fetch_page: F) -> anyhow::Result<Vec<NodeCandidate>>
where
F: Fn(u64) -> Fut,
Fut: Future<Output = Result<(Vec<CapacityBond::NodeInfo>, Vec<bool>), alloy::contract::Error>>,
{
let mut out = Vec::new();
let mut offset = 0u64;
let mut attempt = 0usize;
loop {
let (page, active) = loop {
match fetch_page(offset).await {
Ok(page) => break page,
Err(e) if is_permanent_contract_error(&e) => {
return Err(e).with_context(|| {
format!(
"CapacityBond.getRegisteredNodes(offset={offset}) failed and will not \
succeed on retry — check that blockchain.capacity_bond_address \
holds the registry contract and that rpc_url points at the same \
chain and accepts this key"
)
});
}
Err(e) => {
let Some(backoff) = REGISTRY_RETRY_BACKOFF.get(attempt).copied() else {
return Err(e).with_context(|| {
format!(
"CapacityBond.getRegisteredNodes(offset={offset}) failed after {} \
retries across the whole registry read",
REGISTRY_RETRY_BACKOFF.len()
)
});
};
tracing::warn!(
offset,
attempt,
backoff_ms = backoff.as_millis(),
error = %sanitize_error_sources(&e),
"CapacityBond.getRegisteredNodes page failed; retrying"
);
tokio::time::sleep(backoff).await;
attempt = attempt.saturating_add(1);
}
}
};
anyhow::ensure!(
page.len() == active.len(),
"CapacityBond.getRegisteredNodes(offset={offset}) returned mismatched \
page/active lengths ({} vs {}) — ABI or decoder fault",
page.len(),
active.len()
);
let page_len = page.len() as u64;
out.extend(
page.iter()
.zip(active.iter())
.filter_map(|(info, &is_active)| candidate_from(info, is_active)),
);
if page_len < PAGE_SIZE {
break;
}
offset = offset.saturating_add(PAGE_SIZE);
}
Ok(out)
}
#[doc(hidden)]
pub async fn active_nodes(
rpc_url: &str,
capacity_bond_addr: Address,
) -> anyhow::Result<Vec<NodeCandidate>> {
decdn_common::tls::install_crypto_provider();
let provider = ProviderBuilder::new().connect_http(rpc_url.parse().with_context(|| {
format!(
"rpc_url is not a valid URL (<redacted>, {} chars)",
rpc_url.len()
)
})?);
let registry = CapacityBond::new(capacity_bond_addr, provider);
paginate_with_retry(|offset| {
let registry = ®istry;
async move {
registry
.getRegisteredNodes(U256::from(offset), U256::from(PAGE_SIZE))
.call()
.await
.map(|resp| (resp.page, resp.active))
}
})
.await
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default()
}
fn resolve_bootstrap(
registry: anyhow::Result<Vec<NodeCandidate>>,
data_dir: &Path,
) -> anyhow::Result<Bootstrap> {
let store = crate::PeerStore::open(data_dir);
let cfg = crate::StoreConfig::default();
let now = now_secs();
let err = match registry {
Ok(peers) => {
let mut store_warning = None;
if !peers.is_empty() {
for cand in &peers {
if let Err(e) = store.upsert_identity(cand, now) {
store_warning.get_or_insert_with(|| e.to_string());
}
}
if let Err(e) = store.prune_and_cap(now, &cfg) {
store_warning.get_or_insert_with(|| e.to_string());
}
}
return Ok(Bootstrap::Live {
peers,
store_warning,
});
}
Err(e) => e,
};
let records = store.load_all();
let oldest_identity_secs = records
.iter()
.filter(|r| !r.identity_prunable(now, &cfg))
.map(|r| r.identity_seen_at_secs)
.min();
let cached: Vec<NodeCandidate> = records
.into_iter()
.filter(|r| !r.identity_prunable(now, &cfg))
.map(|r| r.as_candidate())
.collect();
let Some(oldest_identity_secs) = oldest_identity_secs else {
return Err(err.context(BOOTSTRAP_UNREACHABLE));
};
Ok(Bootstrap::Cached {
peers: cached,
registry_error: sanitize_err_chain(&err),
oldest_identity_secs,
})
}
#[derive(Debug)]
#[non_exhaustive]
pub enum Bootstrap {
Live {
peers: Vec<NodeCandidate>,
store_warning: Option<String>,
},
Cached {
peers: Vec<NodeCandidate>,
registry_error: String,
oldest_identity_secs: u64,
},
}
fn format_age_secs(age_secs: u64) -> String {
if age_secs < 60 {
format!("{age_secs}s")
} else if age_secs < 3_600 {
format!("{}m", age_secs / 60)
} else if age_secs < 86_400 {
format!("{}h", age_secs / 3_600)
} else {
format!("{}d", age_secs / 86_400)
}
}
impl Bootstrap {
#[must_use]
pub fn warning(&self) -> Option<String> {
match self {
Self::Live { store_warning, .. } => store_warning.as_ref().map(|e| {
format!("peer store write failed: {e} (selection may be degraded next run)")
}),
Self::Cached {
peers,
registry_error,
oldest_identity_secs,
} => {
let age = format_age_secs(now_secs().saturating_sub(*oldest_identity_secs));
Some(format!(
"could not reach the node registry ({registry_error}); using {} \
previously known node(s) from the local peer store, identity up to {age} \
old. These nodes may have been deactivated or slashed since.",
peers.len()
))
}
}
}
#[must_use]
pub fn into_peers(self) -> Vec<NodeCandidate> {
match self {
Self::Live { peers, .. } | Self::Cached { peers, .. } => peers,
}
}
}
pub async fn bootstrap_nodes(
rpc_url: &str,
capacity_bond_addr: Address,
data_dir: &Path,
registry_cap: Duration,
) -> anyhow::Result<Bootstrap> {
let registry =
match tokio::time::timeout(registry_cap, active_nodes(rpc_url, capacity_bond_addr)).await {
Ok(result) => result,
Err(_) => Err(anyhow::anyhow!(
"the CapacityBond registry read did not finish within {} ms (--timeout-ms); \
the ADR 012 retry schedule alone can take {} s",
registry_cap.as_millis(),
REGISTRY_RETRY_BACKOFF.iter().sum::<Duration>().as_secs(),
)),
};
let data_dir = data_dir.to_path_buf();
tokio::task::spawn_blocking(move || resolve_bootstrap(registry, &data_dir))
.await
.map_err(|e| {
anyhow::Error::new(e).context("the peer-store bootstrap task panicked or was cancelled")
})?
}
#[doc(hidden)]
pub const SELECT_K: usize = 5;
#[must_use]
pub fn select_candidates(
mut candidates: Vec<NodeCandidate>,
client_region: Option<&str>,
k: usize,
) -> Vec<NodeCandidate> {
candidates.shuffle(&mut rand::rng());
if let Some(region) = client_region.and_then(Region::parse) {
candidates.sort_by_key(|c| c.region_hint != Some(region));
}
candidates.truncate(k);
candidates
}
#[must_use]
#[doc(hidden)]
pub fn select_candidates_filtered(
candidates: Vec<NodeCandidate>,
client_region: Option<&str>,
k: usize,
allow: &[Region],
) -> Vec<NodeCandidate> {
let filtered = if allow.is_empty() {
candidates
} else {
candidates
.into_iter()
.filter(|c| match c.region_hint {
Some(r) => allow.contains(&r),
None => true,
})
.collect()
};
select_candidates(filtered, client_region, k)
}
#[must_use]
pub fn admit_sources(ordered: Vec<NodeCandidate>, max_sources: usize) -> Vec<NodeCandidate> {
if max_sources == 0 {
return Vec::new();
}
let mut seen_operators = HashSet::with_capacity(max_sources);
let mut out = Vec::with_capacity(max_sources.min(ordered.len()));
for candidate in ordered {
if out.len() >= max_sources {
break;
}
if seen_operators.insert(candidate.eth_address) {
out.push(candidate);
}
}
out
}
#[derive(Debug, Clone)]
pub struct Probed {
pub candidate: NodeCandidate,
pub rtt_ms: f64,
pub total_bytes: Option<u64>,
pub coverage: Coverage,
}
#[derive(Debug, Clone)]
#[doc(hidden)]
pub struct WarmingCandidate {
pub node_id: PublicKey,
pub eth_address: Address,
pub rtt_ms: f64,
pub multiaddrs: Bytes,
}
#[must_use]
#[doc(hidden)]
pub fn proxy_warming_order(
best_holder_rtt_ms: f64,
rtt_threshold_ms: f64,
margin_ms: f64,
candidates: &[WarmingCandidate],
) -> Vec<&WarmingCandidate> {
if !(best_holder_rtt_ms.is_finite() && best_holder_rtt_ms > rtt_threshold_ms) {
return Vec::new();
}
let mut qualifying: Vec<&WarmingCandidate> = candidates
.iter()
.filter(|c| c.rtt_ms.is_finite() && best_holder_rtt_ms - c.rtt_ms >= margin_ms)
.collect();
qualifying.sort_by(|a, b| a.rtt_ms.total_cmp(&b.rtt_ms));
qualifying
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic
)]
mod tests;