use std::collections::HashMap;
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use alloy::primitives::{Address, Bytes};
use decdn_incentive::slash_judge_domain;
use iroh::{Endpoint, EndpointAddr, PublicKey, RelayUrl};
use tokio_util::task::AbortOnDropHandle;
use crate::UpstreamRateLimited;
use crate::discovery::{self, NodeCandidate};
use crate::probe::probe_once;
use crate::session::{ChainConfig, DiscoveryOptions, FetchMarks, ProxyWarmingParams, PullSite};
const SELECT_PROBE_TIMEOUT_MS: u64 = 5_000;
pub(crate) fn micros_now() -> u64 {
u64::try_from(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_micros()),
)
.unwrap_or(u64::MAX)
}
fn no_serve_target_error(
probe_count: usize,
unreachable: usize,
rate_limited: usize,
unverifiable: usize,
) -> anyhow::Error {
if unverifiable > 0 {
return anyhow::Error::new(ResolveConfigFault(format!(
"{unverifiable} of {probe_count} probed node(s) answered, but their probe \
signatures did not recover to the operator address each is registered \
under. That is usually local configuration rather than missing content: \
check that blockchain.slash_judge_address and blockchain.chain_id match \
the deployment these nodes registered against"
)));
}
let unusable = probe_count
.saturating_sub(unreachable)
.saturating_sub(rate_limited);
let shed = if rate_limited > 0 {
format!(", {rate_limited} rate-limited this client's probes")
} else {
String::new()
};
anyhow::anyhow!(
"none of the {probe_count} probed node(s) could serve the blob \
({unreachable} did not answer{shed}, {unusable} answered but were unusable)"
)
}
const PROBE_SHED_BACKOFFS_MS: [u64; 3] = [250, 500, 1000];
async fn probe_candidate(
endpoint: &Endpoint,
target: EndpointAddr,
hash: [u8; 32],
timestamp_us: u64,
) -> anyhow::Result<(
decdn_protocol::message::ProbeResponse,
decdn_protocol::ProbeResponseExt,
crate::probe::ProbeRtt,
)> {
let mut backoffs = PROBE_SHED_BACKOFFS_MS.iter();
loop {
let res = probe_once(
endpoint,
target.clone(),
hash,
timestamp_us,
Duration::from_millis(SELECT_PROBE_TIMEOUT_MS),
)
.await;
match (&res, backoffs.next()) {
(Err(e), Some(&ms)) if probe_shed(e) => {
tokio::time::sleep(Duration::from_millis(ms)).await;
}
(Err(e), None) if probe_shed(e) => {
tracing::info!(
"{} kept rate-limiting this client's probe: {e:#}",
target.id
);
return res;
}
_ => return res,
}
}
}
fn probe_shed(err: &anyhow::Error) -> bool {
err.downcast_ref::<UpstreamRateLimited>().is_some()
}
const PROBE_SETTLE_AFTER_HOLDER: Duration = Duration::from_millis(250);
async fn settle_probes<T, F>(
probes: impl IntoIterator<Item = F>,
grace: Duration,
cold_grace: Option<Duration>,
is_holder: impl Fn(&T) -> bool,
is_answer: impl Fn(&T) -> bool,
) -> (Vec<T>, futures_util::stream::FuturesUnordered<F>)
where
F: std::future::Future<Output = T>,
{
use futures_util::StreamExt as _;
let mut pending: futures_util::stream::FuturesUnordered<F> = probes.into_iter().collect();
let mut settled = Vec::with_capacity(pending.len());
let mut deadline: Option<tokio::time::Instant> = None;
let mut holder_seen = false;
loop {
let next = match deadline {
None => pending.next().await,
Some(at) => match tokio::time::timeout_at(at, pending.next()).await {
Ok(next) => next,
Err(_) => break,
},
};
let Some(outcome) = next else { break };
let now = tokio::time::Instant::now();
if !holder_seen && is_holder(&outcome) {
holder_seen = true;
let at = now + grace;
deadline = Some(deadline.map_or(at, |cold| cold.min(at)));
} else if deadline.is_none()
&& let Some(cold) = cold_grace
&& is_answer(&outcome)
{
deadline = Some(now + cold);
}
settled.push(outcome);
}
if !pending.is_empty() {
tracing::debug!(
pending = pending.len(),
holder = holder_seen,
"probe round settled; the rest continue as its tail"
);
}
(settled, pending)
}
fn log_probe_result(
cand: &NodeCandidate,
res: &anyhow::Result<(
decdn_protocol::message::ProbeResponse,
decdn_protocol::ProbeResponseExt,
crate::probe::ProbeRtt,
)>,
started: std::time::Instant,
) {
match res {
Ok((resp, _, rtt)) => tracing::debug!(
node = %cand.node_id,
elapsed_ms = started.elapsed().as_millis(),
rtt_ms = rtt.ms,
rtt_direct = rtt.direct,
has_blob = resp.body.has_blob,
"probe answered"
),
Err(error) => tracing::debug!(
node = %cand.node_id,
elapsed_ms = started.elapsed().as_millis(),
error = %format_args!("{error:#}"),
"probe failed"
),
}
}
pub(crate) enum ProbeOutcome {
Holder(discovery::Probed, (PublicKey, f64, u64)),
NonHolder(discovery::WarmingCandidate, (PublicKey, f64, u64)),
Unreachable,
RateLimited,
Unverifiable,
Unusable,
}
impl ProbeOutcome {
const fn is_holder(&self) -> bool {
matches!(self, Self::Holder(..))
}
const fn is_answer(&self) -> bool {
matches!(self, Self::Holder(..) | Self::NonHolder(..))
}
pub(crate) const fn sample(&self) -> Option<(PublicKey, f64, u64)> {
match self {
Self::Holder(_, sample) | Self::NonHolder(_, sample) => Some(*sample),
Self::Unreachable | Self::RateLimited | Self::Unverifiable | Self::Unusable => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProbeRound {
Stream,
Settle,
}
impl ProbeRound {
const fn grace(self) -> Duration {
match self {
Self::Stream => Duration::ZERO,
Self::Settle => PROBE_SETTLE_AFTER_HOLDER,
}
}
const fn cold_grace(self) -> Option<Duration> {
match self {
Self::Stream => Some(PROBE_SETTLE_AFTER_HOLDER),
Self::Settle => None,
}
}
}
pub(crate) struct LateProbes {
pub(crate) tail: std::pin::Pin<Box<dyn futures_util::Stream<Item = ProbeOutcome> + Send>>,
pub(crate) best_holder_rtt_ms: f64,
pub(crate) warming: ProxyWarmingParams,
}
#[derive(Clone, Copy)]
pub struct ProbeOpts<'a> {
pub marks: Option<&'a dyn FetchMarks>,
pub round: ProbeRound,
}
impl std::fmt::Debug for ProbeOpts<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProbeOpts")
.field("marks", &self.marks.is_some())
.field("round", &self.round)
.finish()
}
}
#[derive(Default)]
pub(crate) struct LateSlot(std::sync::Mutex<Option<LateProbes>>);
impl LateSlot {
pub(crate) const fn new(late: LateProbes) -> Self {
Self(std::sync::Mutex::new(Some(late)))
}
pub(crate) fn take(&self) -> Option<LateProbes> {
self.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
}
}
struct ProbeTally {
holders: Vec<discovery::Probed>,
non_holders: Vec<discovery::WarmingCandidate>,
probed_samples: Vec<(PublicKey, f64, u64)>,
unreachable: usize,
rate_limited: usize,
unverifiable: usize,
}
impl ProbeTally {
fn of(outcomes: Vec<ProbeOutcome>) -> Self {
let mut tally = Self {
holders: Vec::new(),
non_holders: Vec::new(),
probed_samples: Vec::new(),
unreachable: 0,
rate_limited: 0,
unverifiable: 0,
};
for outcome in outcomes {
match outcome {
ProbeOutcome::Holder(holder, sample) => {
tally.probed_samples.push(sample);
tally.holders.push(holder);
}
ProbeOutcome::NonHolder(candidate, sample) => {
tally.probed_samples.push(sample);
tally.non_holders.push(candidate);
}
ProbeOutcome::Unreachable => tally.unreachable += 1,
ProbeOutcome::RateLimited => tally.rate_limited += 1,
ProbeOutcome::Unverifiable => tally.unverifiable += 1,
ProbeOutcome::Unusable => {}
}
}
tally
}
}
fn classify_probe(
cand: &NodeCandidate,
res: anyhow::Result<(
decdn_protocol::message::ProbeResponse,
decdn_protocol::ProbeResponseExt,
crate::probe::ProbeRtt,
)>,
hash: [u8; 32],
timestamp_us: u64,
slash_domain: &alloy::sol_types::Eip712Domain,
) -> ProbeOutcome {
let (resp, resp_ext, rtt) = match res {
Ok(answer) => answer,
Err(e) if probe_shed(&e) => return ProbeOutcome::RateLimited,
Err(_) => return ProbeOutcome::Unreachable,
};
if let Err(e) = crate::probe::verify_probe_response(
&resp,
cand.eth_address,
slash_domain,
hash,
timestamp_us,
) {
tracing::warn!(
"dropping an unverifiable probe response from {}: {e}",
cand.node_id
);
return ProbeOutcome::Unverifiable;
}
if !resp_ext.consistent_with(resp.body.has_blob) {
tracing::warn!(
"dropping a probe response from {} with has_blob/coverage mismatch",
cand.node_id
);
return ProbeOutcome::Unusable;
}
let rtt_ms = rtt.ms;
let sample = (cand.node_id, rtt_ms, resp.body.rate_per_mb);
if resp.body.has_blob {
ProbeOutcome::Holder(
discovery::Probed {
candidate: cand.clone(),
rtt_ms,
total_bytes: resp_ext.total_bytes,
coverage: resp_ext.coverage,
},
sample,
)
} else {
ProbeOutcome::NonHolder(
discovery::WarmingCandidate {
node_id: cand.node_id,
eth_address: cand.eth_address,
rtt_ms,
multiaddrs: cand.multiaddrs.clone(),
},
sample,
)
}
}
pub async fn probe_and_order(
endpoint: &Endpoint,
candidates: &[NodeCandidate],
relay_hint: Option<&RelayUrl>,
hash: [u8; 32],
warming: ProxyWarmingParams,
slash_domain: &alloy::sol_types::Eip712Domain,
round: ProbeRound,
) -> anyhow::Result<ResolvedTargets> {
let timestamp_us = micros_now();
let started = std::time::Instant::now();
let probes = candidates.iter().cloned().map(|cand| {
let target = probe_target(&cand, relay_hint.cloned());
let endpoint = endpoint.clone();
let slash_domain = slash_domain.clone();
async move {
let res = probe_candidate(&endpoint, target, hash, timestamp_us).await;
log_probe_result(&cand, &res, started);
classify_probe(&cand, res, hash, timestamp_us, &slash_domain)
}
});
let (outcomes, tail) = settle_probes(
probes,
round.grace(),
round.cold_grace(),
ProbeOutcome::is_holder,
ProbeOutcome::is_answer,
)
.await;
let ProbeTally {
holders,
non_holders,
probed_samples,
unreachable,
rate_limited,
unverifiable,
} = ProbeTally::of(outcomes);
if holders.is_empty() && non_holders.is_empty() {
return Err(no_serve_target_error(
candidates.len(),
unreachable,
rate_limited,
unverifiable,
));
}
if holders.is_empty() {
tracing::info!(
"no node that has answered holds the blob in cache; starting on {} reachable \
bonded non-holder(s) as pull-through serve targets — a node serves an authorized \
miss from its own origin (#1911), and a holder that answers later joins the fetch",
non_holders.len()
);
}
let size_hint = nearest_size_hint(&holders);
let late = late_slot(round, tail, &holders, warming);
let ordered = failover_order(holders, &non_holders, warming);
if let Some((node_id, proxy_rtt, best_holder_rtt)) = ordered.warming_lead {
tracing::info!(
"proxy-warming: routing through nearer non-holder {node_id} ({proxy_rtt:.1}ms) \
instead of the best holder ({best_holder_rtt:.1}ms) to seed a regional copy, \
falling back to the holder if it declines (ADR 037)",
);
}
Ok(ResolvedTargets {
candidates: ordered.order,
coverage_by_node: ordered.coverage_by_node,
probed_samples,
pinned: false,
size_hint,
late,
})
}
fn late_slot<F>(
round: ProbeRound,
tail: futures_util::stream::FuturesUnordered<F>,
holders: &[discovery::Probed],
warming: ProxyWarmingParams,
) -> LateSlot
where
F: std::future::Future<Output = ProbeOutcome> + Send + 'static,
{
use futures_util::StreamExt as _;
if round != ProbeRound::Stream || tail.is_empty() {
return LateSlot::default();
}
let best_holder_rtt_ms = holders
.iter()
.map(|h| h.rtt_ms)
.fold(f64::INFINITY, f64::min);
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let task = AbortOnDropHandle::new(tokio::spawn(tail.for_each(move |outcome| {
let _ = tx.send(outcome);
std::future::ready(())
})));
let answers = futures_util::stream::unfold((rx, task), |(mut rx, task)| async move {
rx.recv().await.map(|outcome| (outcome, (rx, task)))
});
LateSlot::new(LateProbes {
tail: Box::pin(answers),
best_holder_rtt_ms,
warming,
})
}
fn nearest_size_hint(holders: &[discovery::Probed]) -> Option<u64> {
holders
.iter()
.filter(|h| h.total_bytes.is_some())
.min_by(|a, b| a.rtt_ms.total_cmp(&b.rtt_ms))
.and_then(|h| h.total_bytes)
}
struct FailoverOrder {
order: Vec<NodeCandidate>,
warming_lead: Option<(PublicKey, f64, f64)>,
coverage_by_node: HashMap<PublicKey, decdn_protocol::Coverage>,
}
fn failover_order(
mut holders: Vec<discovery::Probed>,
non_holders: &[discovery::WarmingCandidate],
warming: ProxyWarmingParams,
) -> FailoverOrder {
if holders.is_empty() {
let mut cold = non_holders.iter().collect::<Vec<_>>();
cold.sort_by(|a, b| a.rtt_ms.total_cmp(&b.rtt_ms));
let order = cold
.iter()
.map(|c| NodeCandidate {
node_id: c.node_id,
eth_address: c.eth_address,
region_hint: None,
multiaddrs: c.multiaddrs.clone(),
})
.collect();
return FailoverOrder {
order,
warming_lead: None,
coverage_by_node: HashMap::new(),
};
}
holders.sort_by(|a, b| a.rtt_ms.total_cmp(&b.rtt_ms));
let coverage_by_node: HashMap<PublicKey, decdn_protocol::Coverage> = holders
.iter()
.map(|h| (h.candidate.node_id, h.coverage.clone()))
.collect();
let best_holder_rtt = holders
.iter()
.map(|h| h.rtt_ms)
.fold(f64::INFINITY, f64::min);
let proxy_order = if warming.enabled {
discovery::proxy_warming_order(
best_holder_rtt,
warming.rtt_threshold_ms,
warming.margin_ms,
non_holders,
)
} else {
Vec::new()
};
let warming_lead = proxy_order
.first()
.map(|nearest| (nearest.node_id, nearest.rtt_ms, best_holder_rtt));
let proxies = proxy_order.iter().map(|proxy| NodeCandidate {
node_id: proxy.node_id,
eth_address: proxy.eth_address,
region_hint: None,
multiaddrs: proxy.multiaddrs.clone(),
});
let order = proxies
.chain(holders.iter().map(|h| h.candidate.clone()))
.collect();
FailoverOrder {
order,
warming_lead,
coverage_by_node,
}
}
async fn discover_provider(
endpoint: &Endpoint,
(chain, site): (&ChainConfig, &PullSite),
capacity_bond: Address,
relay_hint: Option<&RelayUrl>,
hash: [u8; 32],
opts: &DiscoveryOptions,
probe: ProbeOpts<'_>,
) -> anyhow::Result<ResolvedTargets> {
let mark_probe_start = || {
if let Some(marks) = probe.marks {
marks.probe_started();
}
};
let peer_store = crate::PeerStore::open(&site.data_dir);
let store_cfg = crate::StoreConfig::default();
if !opts.rediscover
&& let Some(targets) =
store_fast_path(&peer_store, &store_cfg, opts.max_sources, now_secs())
{
return Ok(targets);
}
if !opts.rediscover {
let cached = identity_fresh_candidates(&peer_store, &store_cfg, now_secs());
let selected = select_with_widening(
cached,
site.region.as_deref(),
site.region_allowlist.as_slice(),
&store_cfg,
);
if selected.len() >= store_cfg.min_fresh_candidates {
let warming = opts.proxy_warming;
let slash_dom = slash_judge_domain(chain.chain_id, chain.slash_judge);
mark_probe_start();
let targets = probe_and_order(
endpoint,
&selected,
relay_hint,
hash,
warming,
&slash_dom,
probe.round,
)
.await?;
drop(spawn_harvest(
&site.data_dir,
Vec::new(),
targets.probed_samples.clone(),
));
return Ok(targets);
}
}
let bootstrap = discovery::bootstrap_nodes(
&chain.rpc_url,
capacity_bond,
&site.data_dir,
opts.registry_timeout,
)
.await?;
let is_live_registry = matches!(bootstrap, discovery::Bootstrap::Live { .. });
if let Some(warning) = bootstrap.warning() {
tracing::warn!("{warning}");
}
let all = bootstrap.into_peers();
if all.is_empty() {
anyhow::bail!("no active nodes in the CapacityBond registry at {capacity_bond}");
}
let allow: &[decdn_protocol::Region] = if is_live_registry {
site.region_allowlist.as_slice()
} else {
&[]
};
let selected = select_with_widening(all, site.region.as_deref(), allow, &store_cfg);
let warming = opts.proxy_warming;
let slash_dom = slash_judge_domain(chain.chain_id, chain.slash_judge);
mark_probe_start();
let targets = probe_and_order(
endpoint,
&selected,
relay_hint,
hash,
warming,
&slash_dom,
probe.round,
)
.await?;
drop(spawn_harvest(
&site.data_dir,
Vec::new(),
targets.probed_samples.clone(),
));
Ok(targets)
}
fn store_fast_path(
store: &crate::PeerStore,
cfg: &crate::StoreConfig,
max_sources: usize,
now_secs: u64,
) -> Option<ResolvedTargets> {
let mut fresh: Vec<_> = store
.load_all()
.into_iter()
.filter(|r| r.selectable(now_secs, cfg) && !r.identity_prunable(now_secs, cfg))
.collect();
if fresh.len() < cfg.min_fresh_candidates {
return None;
}
fresh.sort_by(|a, b| {
a.latency_ms
.unwrap_or(f64::MAX)
.total_cmp(&b.latency_ms.unwrap_or(f64::MAX))
});
let ordered: Vec<NodeCandidate> = fresh.iter().map(crate::PeerRecord::as_candidate).collect();
let candidates = discovery::admit_sources(ordered, max_sources);
if candidates.len() < cfg.min_fresh_candidates {
return None;
}
Some(ResolvedTargets {
candidates,
coverage_by_node: HashMap::new(),
probed_samples: Vec::new(),
pinned: false,
size_hint: None,
late: LateSlot::default(),
})
}
pub fn identity_fresh_candidates(
store: &crate::PeerStore,
cfg: &crate::StoreConfig,
now_secs: u64,
) -> Vec<NodeCandidate> {
store
.load_all()
.into_iter()
.filter(|r| {
r.identity_fresh(now_secs, cfg)
&& !r.identity_prunable(now_secs, cfg)
&& !r.failure_suppressed(now_secs, cfg)
})
.map(|r| r.as_candidate())
.collect()
}
fn select_with_widening(
candidates: Vec<NodeCandidate>,
region: Option<&str>,
allow: &[decdn_protocol::Region],
cfg: &crate::StoreConfig,
) -> Vec<NodeCandidate> {
let widen_fallback = (!allow.is_empty()).then(|| candidates.clone());
let selected =
discovery::select_candidates_filtered(candidates, region, discovery::SELECT_K, allow);
if let Some(widened) = widen_fallback
&& selected.len() < cfg.min_fresh_candidates
{
return discovery::select_candidates_filtered(widened, region, discovery::SELECT_K, &[]);
}
selected
}
pub(crate) fn spawn_harvest(
data_dir: &Path,
registry: Vec<NodeCandidate>,
probed: Vec<(PublicKey, f64, u64)>,
) -> tokio::task::JoinHandle<()> {
let dir = data_dir.to_path_buf();
tokio::spawn(async move {
let store = crate::PeerStore::open(&dir);
let cfg = crate::StoreConfig::default();
harvest(&store, ®istry, probed, now_secs(), &cfg);
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct HarvestTally {
writes: usize,
failed: usize,
}
fn harvest(
store: &crate::PeerStore,
registry: &[NodeCandidate],
probed: Vec<(PublicKey, f64, u64)>,
now: u64,
cfg: &crate::StoreConfig,
) -> HarvestTally {
let mut tally = HarvestTally {
writes: 0,
failed: 0,
};
let mut file = |node_id: &PublicKey, what: &str, written: anyhow::Result<()>| {
tally.writes = tally.writes.saturating_add(1);
if let Err(e) = written {
tally.failed = tally.failed.saturating_add(1);
tracing::debug!(%node_id, "could not record {what} in the peer store: {e:#}");
}
};
for cand in registry {
file(
&cand.node_id,
"an identity",
store.upsert_identity(cand, now),
);
}
for (node_id, rtt_ms, rate) in probed {
let written = store.record_sample(&node_id, rtt_ms, rate, now, cfg);
file(&node_id, "a probe sample", written);
}
if let Err(e) = store.prune_and_cap(now, cfg) {
tracing::debug!("could not prune the peer store: {e:#}");
}
if tally.writes > 0 && tally.failed == tally.writes {
tracing::warn!(
writes = tally.writes,
"the peer store refused every write of this probe round; \
its latency ranking stays at its last state"
);
}
tally
}
pub fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
#[derive(Debug)]
pub struct ResolveConfigFault(pub String);
impl std::fmt::Display for ResolveConfigFault {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ResolveConfigFault {}
pub fn holders_or_none(
resolved: anyhow::Result<ResolvedTargets>,
) -> anyhow::Result<ResolvedTargets> {
match resolved {
Ok(targets) => Ok(targets),
Err(err) if err.downcast_ref::<ResolveConfigFault>().is_some() => Err(err),
Err(err) => {
tracing::warn!(
error = %decdn_common::redact::sanitize_err_chain(&err),
"no holder resolved yet; the fetch keeps looking"
);
Ok(ResolvedTargets {
candidates: Vec::new(),
coverage_by_node: HashMap::new(),
probed_samples: Vec::new(),
pinned: false,
size_hint: None,
late: LateSlot::default(),
})
}
}
}
pub struct ResolvedTargets {
pub(crate) candidates: Vec<NodeCandidate>,
pub(crate) coverage_by_node: HashMap<PublicKey, decdn_protocol::Coverage>,
pub(crate) probed_samples: Vec<(PublicKey, f64, u64)>,
pub(crate) pinned: bool,
pub(crate) size_hint: Option<u64>,
pub(crate) late: LateSlot,
}
impl std::fmt::Debug for ResolvedTargets {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ResolvedTargets")
.field("candidates", &self.candidates.len())
.field("pinned", &self.pinned)
.field("size_hint", &self.size_hint)
.finish_non_exhaustive()
}
}
fn pinned_targets(node_id: PublicKey, provider: Address) -> ResolvedTargets {
ResolvedTargets {
candidates: vec![NodeCandidate {
node_id,
eth_address: provider,
region_hint: None,
multiaddrs: Bytes::new(),
}],
coverage_by_node: HashMap::new(),
probed_samples: Vec::new(),
pinned: true,
size_hint: None,
late: LateSlot::default(),
}
}
pub async fn resolve_target_node(
opts: &DiscoveryOptions,
(chain, site): (&ChainConfig, &PullSite),
endpoint: &Endpoint,
relays: &[RelayUrl],
hash: [u8; 32],
probe: ProbeOpts<'_>,
) -> anyhow::Result<ResolvedTargets> {
if let Some(pinned) = &opts.pinned {
return Ok(pinned_targets(pinned.node_id, pinned.provider));
}
let capacity_bond = chain.capacity_bond.ok_or_else(|| {
anyhow::Error::new(ResolveConfigFault(
"auto-discovery needs capacity_bond_address (--capacity-bond-address or \
blockchain.capacity_bond_address), or pass --node-id to dial directly"
.to_string(),
))
})?;
let order = discover_provider(
endpoint,
(chain, site),
capacity_bond,
relays.first(),
hash,
opts,
probe,
)
.await?;
if !order.candidates.is_empty() {
tracing::info!("discovered {} node(s):", order.candidates.len());
for c in &order.candidates {
tracing::info!(
" * {} / {} / {}",
c.region_hint.as_ref().map_or("??", |r| r.as_str()),
node_id_tail(&c.node_id),
short_address(&c.eth_address),
);
}
}
Ok(order)
}
fn node_id_tail(node_id: &PublicKey) -> String {
let s = node_id.to_string();
s.get(s.len().saturating_sub(4)..).unwrap_or(&s).to_owned()
}
fn short_address(addr: &Address) -> String {
let s = addr.to_string();
match (s.get(..6), s.get(s.len().saturating_sub(4)..)) {
(Some(head), Some(tail)) if s.len() > 10 => format!("{head}...{tail}"),
_ => s,
}
}
fn probe_target(cand: &NodeCandidate, relay: Option<RelayUrl>) -> EndpointAddr {
let mut target = EndpointAddr::new(cand.node_id);
if let Some(url) = relay {
target = target.with_relay_url(url);
}
discovery::with_dial_addrs(target, cand)
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic
)]
mod tests;
#[cfg(test)]
pub(crate) mod tests_support;