decdn_node/node_origin/mod.rs
1//! `NodeOrigin` — the node-to-node cache-miss pull-through origin (#831, ADR
2//! 001/022).
3//!
4//! A whole-blob cache miss reaches the network through the engine's origin
5//! chain, so the production shape of "on a miss, discover a provider, open a
6//! paid channel, pull, and populate the cache" is an [`Origin`] implementation
7//! injected into that chain (appended last, so configured HTTP/FS/S3 origins
8//! are tried first and the paid network pull is the final fallback). On
9//! [`Origin::fetch`] this:
10//!
11//! 1. discovers providers for the hash (DHT [`crate::dht::find_providers`],
12//! with the origin-directory fallback),
13//! 2. probes each candidate for rate + RTT and ranks them by the combined
14//! local+network reputation score ([`crate::selection::rank_candidates`]),
15//! 3. opens (or reuses) a buyer payment channel to the best candidate and streams
16//! the whole blob straight into the local cache via the gap-driven
17//! [`decdn_client::drive`] loop (#1682 — resumable, so a channel that
18//! runs dry mid-blob is topped up and the pull continues at the paid frontier;
19//! no whole-blob buffer is ever held in RAM), falling back through up to
20//! [`crate::selection::MAX_PROVIDER_ATTEMPTS`] providers,
21//! 4. records the per-provider [`Outcome`] into the local reputation score
22//! (ADR 008 §Local Score Calculation).
23//!
24//! Every chunk group of the bao verified-stream is verified against the content
25//! root as it lands and admitted straight into the cache (ADR 038, via
26//! `admit_bao_stream`), so a dishonest provider is detected (and scored
27//! [`Outcome::Corruption`]) rather than surfaced to the caller. On the window
28//! path the corruption detector is the cache TEE's verifying decoder; its
29//! verdict reaches the scorer via the pull leg's post-drive `record_outcome`
30//! (#915).
31//!
32//! # Deferred initialisation
33//!
34//! The engine is constructed early in runtime bring-up — before the endpoint,
35//! DHT, buyer-channel service, and reputation handles `NodeOrigin` depends on
36//! exist — and takes its origin chain only at construction (there is no
37//! `add_origin`). So `NodeOrigin` is built empty, placed in the chain, and its
38//! dependencies are injected later via a write-once [`OnceLock`] (see
39//! [`NodeOrigin::provision`]). Until that set lands — the feature is off, or the
40//! buyer bootstrap failed — `fetch` returns [`OriginFetch::NotFound`], a clean
41//! miss that leaves the handler behaving exactly as it did before pull-through.
42
43mod admit_store;
44mod backend_source;
45mod funder;
46mod pull_leg;
47mod ranged_pull;
48mod timed_source;
49
50pub(crate) use admit_store::NodeAdmitStore;
51pub(crate) use backend_source::BackendSource;
52pub(crate) use funder::NodeFunder;
53use funder::{SETTLE_POLL_STEP, settle_window};
54pub(crate) use pull_leg::{PrimeLeg, PullLegTarget, run_local_pull_leg, run_pull_leg};
55
56use std::collections::HashMap;
57use std::future::Future;
58use std::pin::Pin;
59use std::sync::atomic::{AtomicBool, Ordering};
60use std::sync::{Arc, Mutex, OnceLock};
61use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
62
63use alloy::dyn_abi::Eip712Domain;
64use alloy::primitives::{Address, B256, U256};
65use decdn_bao_range::align_range;
66use decdn_cache::origin::{Origin, OriginFetch};
67use decdn_cache::{Hash, OriginKind, OriginPullError};
68use decdn_client::{
69 BudgetPacer, PeerSource, PrimedSource, RecoveryGate, Stepped, drive, first_leg,
70};
71use decdn_common::redact::sanitize_err_chain;
72use decdn_protocol::client::{StreamError, VoucherRejectReason};
73use iroh::{Endpoint, EndpointAddr, PublicKey};
74use timed_source::{TimedSource, timed_open};
75use tokio_util::sync::CancellationToken;
76use tracing::{Instrument as _, debug, info, warn};
77
78use decdn_reputation::{LocalReputation, Outcome};
79
80use decdn_incentive::PoolOpenFailureReason;
81use decdn_protocol::client::NO_NAMESPACE;
82
83use crate::buyer_channel::{OpenReported, PoolOpenPending, PoolOpener};
84use crate::buyer_ledgers::BuyerLedgers;
85use crate::dht::negative_cache::Hash as DhtHash;
86use crate::dht::routing::{NodeId as DhtNodeId, RoutingTable};
87use crate::dht::{
88 LookupConfig, NegativeProbeCache, NodeAddressResolver, OriginDirectory, PositiveProbeCache,
89 ProbedProvider, StakerSet,
90};
91use crate::metrics::Metrics;
92use crate::selection::{
93 Candidate, MAX_PROVIDER_ATTEMPTS, PROBE_EARLY_EXIT_CANDIDATES, PROBE_TIMEOUT, rank_candidates,
94};
95use decdn_client::buyer_pool::ProgressWrite;
96use decdn_client::probe::probe_once;
97use decdn_client::{
98 BlobTooLarge, Cumulative, HashMismatch, LegNoProgress, LocalPullFault, PoolContext, PoolLedger,
99 PullDeadlines, PullStalled, PullTimeout, RateAboveCeiling, ResumeOffsetPastEnd,
100 UpstreamRateLimited, UpstreamRefused, UpstreamVoucherRejected, VoucherProgress,
101 effective_rate_ceiling, sign_client_binding,
102};
103
104/// How a probe round decides it has collected enough holders (#1506).
105#[derive(Clone, Copy)]
106pub(crate) enum ProbeGather {
107 /// Single-source failover: stop once [`PROBE_EARLY_EXIT_CANDIDATES`] holders
108 /// answer, because the single-source pull loop tries at most that many.
109 EarlyExit,
110 /// Ranged assembly: stop once the admitted holders' coverage UNION spans the
111 /// blob, so a set of partial holders whose fastest answers all cover the same
112 /// discovery block is not mistaken for enough. A holder that reports its blob
113 /// size (`ProbeResponseExt.total_bytes`) pins the block count the union must
114 /// span; absent any size the round drains to the probe-fanout ceiling.
115 ///
116 /// A round whose holders cannot span the blob is followed by a second round
117 /// over the namespace's origin-directory candidates it did not probe (#2195).
118 /// An origin advertises every block, so its answer normally closes the union.
119 /// Each round takes its own `probe_fanout`, so a gather sends at most twice
120 /// that many probes and stays bounded.
121 CoverageUnion,
122}
123
124/// The coverage union of a set of probed holders, and whether it spans the blob
125/// (#1506).
126///
127/// A holder's own reported size pins how many blocks the blob has. Its coverage
128/// bits past that count are ignored, because an untrusted wire bitmap may set
129/// spurious high bits (see `Coverage`). A holder that reports no size adds
130/// nothing, and the union spans nothing until some holder reports one.
131#[derive(Default)]
132struct CoverageUnionTracker {
133 /// Covered discovery blocks seen so far.
134 blocks: std::collections::BTreeSet<u32>,
135 /// The largest block count any holder has reported for this blob.
136 target: u32,
137}
138
139impl CoverageUnionTracker {
140 /// The union of `candidates`' coverage.
141 fn from_candidates(candidates: &[Candidate]) -> Self {
142 let mut union = Self::default();
143 for candidate in candidates {
144 union.add(candidate);
145 }
146 union
147 }
148
149 /// Fold `candidate`'s coverage into the union.
150 fn add(&mut self, candidate: &Candidate) {
151 let Some(bytes) = candidate.total_bytes_hint else {
152 return;
153 };
154 let holder_blocks = decdn_protocol::num_blocks(bytes);
155 self.target = self.target.max(holder_blocks);
156 self.blocks.extend(
157 candidate
158 .coverage
159 .covered_blocks()
160 .filter(|&block| block < holder_blocks),
161 );
162 }
163
164 /// Every block `0..target` is in the union. False while no holder has
165 /// reported a size.
166 fn spans(&self) -> bool {
167 self.target > 0 && (0..self.target).all(|block| self.blocks.contains(&block))
168 }
169}
170
171/// Whether `candidates`' coverage union spans the blob — see
172/// [`CoverageUnionTracker`].
173pub(super) fn coverage_spans(candidates: &[Candidate]) -> bool {
174 CoverageUnionTracker::from_candidates(candidates).spans()
175}
176
177/// Which arm of the pool-open ladder an error falls in, decided from its typed
178/// markers alone.
179///
180/// Separate from [`record_pool_open_failure`] so the metering invariant — one
181/// failure moves `node_pull_pool_open_failures_total` exactly once — is testable
182/// without standing up a whole [`NodeOriginDeps`]. That ladder is the only
183/// caller; it reads this and then does the metrics and logging the arm calls for.
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185enum PoolOpenArm {
186 /// [`PoolOpenPending`]: the open outlived the caller's budget and continues
187 /// in the background. Not a failure.
188 Pending,
189 /// `PoolOpenFailureReason::InsufficientDeposit`: this node's wallet cannot
190 /// fund a deposit, so it can pay no provider and the fill ends "funding
191 /// needed".
192 FundingNeeded,
193 /// [`LocalPullFault`]: node-wide — this node can pay no provider.
194 LocalFault,
195 /// [`OpenReported`] without [`LocalPullFault`]: the open task already logged
196 /// and metered it, and it leaves this node able to pay someone else.
197 Reported,
198 /// Marked by nobody, so raised outside the open task and counted here.
199 Residual,
200}
201
202/// Read the ladder arm for `err` from its typed markers.
203///
204/// Order is load-bearing: [`LocalPullFault`] is checked before [`OpenReported`]
205/// because the node-wide faults carry both, and letting `OpenReported` win would
206/// answer a client `NotFound` from a node that cannot pay anyone (#1560). An
207/// unfundable wallet carries [`LocalPullFault`] too, and is checked first: its
208/// fill ends "funding needed", which the client hears as `NotFound` (ADR 005
209/// §Open-time refusal classes).
210fn classify_pool_open_arm(err: &anyhow::Error) -> PoolOpenArm {
211 if err.downcast_ref::<PoolOpenPending>().is_some() {
212 PoolOpenArm::Pending
213 } else if err.downcast_ref::<PoolOpenFailureReason>()
214 == Some(&PoolOpenFailureReason::InsufficientDeposit)
215 {
216 PoolOpenArm::FundingNeeded
217 } else if err.downcast_ref::<LocalPullFault>().is_some() {
218 PoolOpenArm::LocalFault
219 } else if err.downcast_ref::<OpenReported>().is_some() {
220 PoolOpenArm::Reported
221 } else {
222 PoolOpenArm::Residual
223 }
224}
225
226/// Record a buyer pool open/reuse failure on `err` to the metrics in `deps`,
227/// emitting a structured-log line with the failure-class `reason` (#966).
228///
229/// Bumps the unlabeled `node_pull_pool_open_failures` total and, when the
230/// error chain carries a [`PoolOpenFailureReason`] (attached by the
231/// `open_pool` kernel for the three `openPool`-tx failure classes), the
232/// matching `decdn_pool_open_failures_{reason}_total` sibling counter.
233///
234/// **One failure moves the total once.** The metering invariant across the buyer
235/// path is: a site meters the total if and only if it marks the error
236/// [`OpenReported`]. Every leg of the detached open task does both, so a leg that
237/// reports itself returns at the `OpenReported` arm below without being restated —
238/// and the arm that meters here is reached only by a failure nobody else counted.
239/// A leg that breaks the pairing doubles `node_pull_pool_open_failures_total`
240/// against `node_pull_attempts_total` (#2072).
241///
242/// Two outcomes are NOT failures and return before that: [`PoolOpenPending`] (the
243/// open outlived our budget and continues in the background), and anything the
244/// detached open task has already reported ([`OpenReported`]) — which includes every one of `run_open`'s legs: the
245/// under-slot store re-read, the `openPool` tx, and the `store.record` that persists
246/// it, plus a join fault if the task itself does not run to completion. So the
247/// unlabeled arm below is genuinely a *residual*: an open/reuse failure raised
248/// outside the open task itself.
249///
250/// Returns the [`PullMiss`] this failure is (#1560), so a pool open that failed because
251/// of a fault in THIS node is not answered to the client as an absent blob. That matters
252/// more than the buyer-key case #1560 was filed for: the loudest node-wide buyer faults in
253/// this crate all land here, each of which would otherwise sign every client a
254/// clean `NotFound` — a poisoned `opens_in_flight` mutex ("this node can no longer
255/// open a buyer pool to ANY provider and must be restarted"), an unreadable buyer pool
256/// store ("can neither open nor reuse a pool until the store recovers"), a store WRITE
257/// that leaves a deposit untracked, an open task that does not run to completion, a lane
258/// whose already-paid watermark cannot be established, and a wallet that cannot fund a
259/// deposit.
260///
261/// **Attribution is decided at the raising site, not here.** Every one of those legs is
262/// typed [`LocalPullFault`] where it is raised, and this function only reads the marker.
263/// That split is not stylistic: [`OpenReported`] means "already logged and metered, do not
264/// restate" — it says nothing about whose fault the failure is — and every leg of the open
265/// task attaches it. So the by-reason classification below runs only for a failure
266/// raised outside the task; the `openPool` legs are classified where they are raised,
267/// which is also where the full error chain is still intact to log.
268///
269/// Consequently the `LocalPullFault`-marked legs are the node-wide ones: the store and
270/// lock faults, and an `InsufficientDeposit`. The store and lock faults answer the
271/// client `Declined`. A wallet that cannot fund a deposit cannot pay any provider
272/// either, but its fill ends "funding needed", a funding problem of this node and
273/// not of the requester, so it answers `NotFound`. The unmarked legs are the deliberate `Clean` ones: a
274/// pending open and a `ContractRevert` — deterministic on-chain state that says
275/// nothing about this node's ability to pay, so another candidate may still deliver.
276/// Refusing `Declined` for that would steer clients off a node that is fine.
277/// An `RpcError` is NOT among them: `openPool` names no provider, so a chain lane
278/// that cannot carry the transaction cannot carry it for any candidate.
279// The arms are a flat sentinel ladder; splitting it would scatter one decision.
280#[allow(clippy::cognitive_complexity)]
281fn record_pool_open_failure(
282 deps: &NodeOriginDeps,
283 provider_addr: Address,
284 err: &anyhow::Error,
285) -> PullMiss {
286 // Not a failure at all: the open ran past our per-candidate budget and is still
287 // going in the background (#1143). Meter it apart from real failures — a sustained rate
288 // means this node's chain lane is too slow for `CHANNEL_OPEN_CALLER_BUDGET`, which is a
289 // very different diagnosis from a reverting or under-funded open.
290 //
291 // NOT `node_pull_timeout_sec`. The pool open has its OWN budget;
292 // `DEFAULT_NODE_PULL_TIMEOUT_SEC`'s own doc says outright that "raising this to
293 // give a slow L2 more room does nothing: that is the pool open." That knob is
294 // inert for this symptom.
295 let arm = classify_pool_open_arm(err);
296 if arm == PoolOpenArm::Pending {
297 deps.metrics.node_pull_pool_open_pending();
298 debug!(%provider_addr, error = %err, "node-origin: pool open still in flight; trying the next candidate");
299 return PullMiss::Clean;
300 }
301 // Ahead of `OpenReported`, deliberately, and this ordering is load-bearing (#1560). The
302 // node-wide buyer faults are reported by the open path AND typed `LocalPullFault` there,
303 // so they carry BOTH markers; `OpenReported` alone would end the walk one arm below with
304 // a `debug!` and a clean miss, which is how a node that "must be restarted" kept telling
305 // clients the content does not exist. `local_fault_outranks_open_reported` pins the
306 // ordering so swapping these two arms fails.
307 //
308 // Metered here rather than at the raising site's counter alone, so the same emergency
309 // reads on `node_pull_local_fault_total` whichever leg of the buyer path raised it. Note
310 // this fires ONCE PER CANDIDATE, so one node-wide fault moves the counter by up to
311 // `MAX_PROVIDER_ATTEMPTS` per request — the rate is the signal, not the absolute.
312 //
313 // It also logs despite `OpenReported`, which normally means "already reported, stay
314 // quiet". Deliberate: the raising site's line says what broke, and this one says what it
315 // COST — that a client was refused rather than told the blob is missing.
316 if arm == PoolOpenArm::FundingNeeded {
317 deps.metrics.node_pull_local_fault();
318 warn!(
319 %provider_addr, error = %sanitize_err_chain(err),
320 "node-origin: this node's wallet cannot fund a buyer pool — it cannot pay \
321 any provider; exonerating the upstream and answering a miss"
322 );
323 return PullMiss::FundingNeeded;
324 }
325 if arm == PoolOpenArm::LocalFault {
326 deps.metrics.node_pull_local_fault();
327 warn!(
328 %provider_addr, error = %sanitize_err_chain(err),
329 "node-origin: LOCAL buyer-side fault opening a pool — this node cannot pay \
330 any provider; exonerating the upstream and refusing rather than reporting a miss"
331 );
332 return PullMiss::LocalFault;
333 }
334 // The detached open task already logged and metered this one (#1143). It has to
335 // be the reporter, because when an open fails, every caller may already have
336 // timed out and left — so if the caller were the reporter, the failure would go
337 // unobserved exactly when it is least affordable. This arm keeps the caller that
338 // DID happen to still be waiting from double-counting it.
339 if arm == PoolOpenArm::Reported {
340 debug!(%provider_addr, error = %err, "node-origin: buyer pool open failed (reported by the open task)");
341 // Reported by the open path and NOT typed as ours there, so it is one of the legs
342 // that leaves this node able to pay somebody else: a `ContractRevert`, an `RpcError`,
343 // or an expired pool that could not be reclaimed. Another candidate may still
344 // deliver, and if none does, `NotFound` is a true statement about what we could
345 // obtain. Everything the open path knows to be node-wide arrives marked and returned
346 // one arm above — the marker is the contract, not this arm's guesswork.
347 return PullMiss::Clean;
348 }
349 deps.metrics.node_pull_pool_open_failure();
350 let reason = err.downcast_ref::<PoolOpenFailureReason>().copied();
351 if let Some(reason) = reason {
352 deps.metrics.pool_open_failure_by_reason(reason);
353 }
354 // `warn!`, not `debug!`. Everything the open task raises is `OpenReported` and
355 // returned above, so what reaches here is raised OUTSIDE the task — which makes this
356 // arm node-local faults, not peer behaviour: a store read fault on the reuse fast
357 // path, a poisoned `opens_in_flight` mutex (which wedges every open for the life of
358 // the process), or a pool that opened on-chain and is somehow not live in the
359 // store. None of those are things an operator should have to scrape debug logs to
360 // see; at the default `RUST_LOG=info` a `debug!` here meant watching
361 // `node_pull_pool_open_failures_total` climb with no line explaining any of it.
362 //
363 // The line names the store and lock state only when nothing classified the failure.
364 // A reason that reaches here describes an on-chain or RPC cause, and pointing an
365 // operator at local state for it sends them to the wrong host (#2072).
366 if let Some(reason) = reason {
367 warn!(
368 %provider_addr,
369 reason = reason.as_label(),
370 error = %sanitize_err_chain(err),
371 "node-origin: buyer pool open/reuse failed on a classified chain fault \
372 raised outside the open task"
373 );
374 } else {
375 warn!(
376 %provider_addr,
377 reason = "unclassified",
378 error = %sanitize_err_chain(err),
379 "node-origin: buyer pool open/reuse failed (raised outside the open task — \
380 suspect this node's store or lock state, not the peer)"
381 );
382 }
383 // The residual is node-local by elimination: every leg of the open task returns above
384 // (marked or `OpenReported`), so what reaches here was raised outside it and points at
385 // this node's own state. In practice that is a capability-signing fault in `pin_ctx`
386 // or a poisoned lock.
387 //
388 // This is a catch-all that defaults to the LOUDER verdict, which is the opposite
389 // discipline from `PullMiss::for_verdict` (deliberately catch-all-free so no future
390 // variant inherits an answer). The inversion is intended and is the policy for this
391 // ladder: attribution here comes from a marker the raising site attaches, so an unmarked
392 // arrival is by definition a leg nobody classified — and an unclassified failure of our
393 // own buyer machinery should surface, not be signed away as absent content. A future leg
394 // that is genuinely per-provider must say so by returning `OpenReported` without the
395 // marker, exactly as the existing ones do.
396 deps.metrics.node_pull_local_fault();
397 PullMiss::LocalFault
398}
399
400/// Tuning knobs for the node-to-node pull, resolved from `[cache]` config.
401#[derive(Debug, Clone)]
402pub struct NodeOriginConfig {
403 /// How many discovered providers to probe before ranking.
404 pub probe_fanout: usize,
405 /// Wall-clock bound on the STREAM-OPEN stage of a single upstream pull: connect,
406 /// handshake, and the signed `StreamResponse`. Bounded work, so a slow one is a stall.
407 ///
408 /// It does NOT bound the buyer-channel open, which is a separate, earlier stage on its
409 /// own budget (`selection::CHANNEL_OPEN_CALLER_BUDGET`, 5 s) — raising this to give a
410 /// slow L2 more room does nothing. That the two are sequential stages is exactly why
411 /// `outer_pull_deadline` budgets both, plus the stall window, for every candidate.
412 pub pull_timeout: Duration,
413 /// THROUGHPUT-FLOOR window on the STREAMING stage (#1797). Bytes are counted off the
414 /// QUIC stream sub-frame, so a pull aborts only when throughput over this window falls
415 /// below [`Self::min_throughput_bps`] — never on a large blob or a big frame.
416 /// Deliberately not a wall clock: bounding the bytes by wall clock caps the blob size
417 /// this node can pull through at `pull_timeout × link speed`, which is the bug this
418 /// avoids.
419 pub stall_window: Duration,
420 /// Minimum sustained upstream throughput (bytes/sec) over [`Self::stall_window`]; `0`
421 /// disables the throughput test and leaves idle detection (#1797).
422 pub min_throughput_bps: u64,
423 /// Buyer-side blob-size ceiling (`cache.max_blob_size_mb` × MB), `0` = unlimited.
424 /// Enforced on the bytes that ACTUALLY arrive on the miss-pull leg, never on the
425 /// peer's unverified `total_bytes` claim: the pull aborts with `BlobTooLarge`
426 /// once cumulative received bytes cross it (#1895).
427 pub max_blob_size_bytes: u64,
428 /// Buyer-side ABSOLUTE per-MB rate ceiling (`cache.max_rate_per_mb`), `0` =
429 /// unlimited (#1375). Combined via [`effective_rate_ceiling`] with the
430 /// probe-relative bound (the rate the chosen candidate advertised) so the node
431 /// refuses a stream quote that exceeds the lower of the two before paying — and
432 /// retains the signed over-quote as rate-manipulation evidence.
433 pub max_rate_per_mb: u64,
434 /// How often this node's chain watcher polls for events
435 /// (`blockchain.event_poll_interval_ms`), used to size the settle window
436 /// after a funding recovery step's top-up (#1530).
437 ///
438 /// The wait is for the UPSTREAM's watcher, not ours, but every node in the
439 /// network runs the same default cadence and this is the only local reading of
440 /// it we have. See `funder::settle_wait_budget`.
441 pub event_poll_interval: Duration,
442 /// DHT lookup tuning.
443 pub lookup: LookupConfig,
444 /// This node's self-attested region (`identity.region`), or `None` when
445 /// unset. Used to apply the ADR 030 latency-vs-claim penalty: a probed peer
446 /// that claims *this* region yet answers slower than the latency ceiling
447 /// (`REGION_LATENCY_MAX_MS`) is a region-spoofing signal. `None` disables the
448 /// penalty (nothing to compare against).
449 pub own_region: Option<String>,
450 /// ADR 041 buy-side profitability gate: the most this node pays upstream,
451 /// per MB, on a cache-miss relay leg. `select_policy(&cfg.cache.serve_economics)`
452 /// picks `OffPolicy` (no ceiling) or `MarginPolicy` at construction.
453 pub serve_economics: Arc<dyn crate::serve_economics::ServeEconomicsPolicy>,
454 /// Live operator fee-share (basis points) from `FeeRouter.getShares()[0]`,
455 /// the `(1 - f)` numerator the `margin` serve-economics policy amortizes
456 /// over. Seeded from chain at startup and kept current by the fee-shares
457 /// watcher (`crate::fee_shares_watcher::route`).
458 pub operator_shares: crate::fee_shares::OperatorShares,
459 /// ADR 040 shared frequency estimator, when built (`cache.eviction_policy`
460 /// / `cache.admission_policy` == `"tinylfu"`, or `cache.serve_economics.policy`
461 /// == `"margin"`). Feeds the `margin` policy's heat-estimate ceiling input;
462 /// `None` when no consumer needs it.
463 pub frequency_estimator: Option<Arc<dyn decdn_cache::FrequencyEstimator>>,
464 /// This node's configured served rate per MB (`payment.rate_per_mb`) — the
465 /// sell rate `P_sell`, the serve-economics policy's other input. The same
466 /// base value the probe handler is constructed with.
467 pub sell_rate_base: u64,
468 /// ADR 041 per-source warming allowance: bounds the loss from speculative
469 /// above-floor buys per upstream source node. The buy loop reads
470 /// `available(source)` to pick the warm-at-market vs amortized-floor regime,
471 /// and debits the full buy cost on a successful speculative pull. The SAME
472 /// `Arc` is shared with the eviction path (which forgets a dropped hash's
473 /// tag) and with the warming-credit aggregator, which applies the realized
474 /// margin the serve path enqueues on each re-serve.
475 pub warming: Arc<crate::warming_allowance::WarmingAllowance>,
476}
477
478/// ADR 030 default heuristic (RTT > 150ms to a same-claimed-region node). The
479/// value compared against it is `probe_once`'s RTT: the request/response
480/// exchange on the open connection, or the direct path's RTT when hole punching
481/// selects one in time and it is lower. It never spans the connect. An RTT with
482/// no direct path behind it ([`ProbeRtt::direct`] is `false`) is compared too:
483/// the peer controls whether hole punching succeeds, so exempting a relay RTT
484/// would let a region spoofer escape the penalty by refusing direct paths. The
485/// cost is that an honest in-region peer stuck on a distant relay can read past
486/// 150ms. The threshold is a documented constant, deliberately not yet a
487/// governance knob per ADR 030 (which defers threshold/sample/decay tuning to a
488/// later ADR 008 revision).
489///
490/// [`ProbeRtt::direct`]: decdn_client::probe::ProbeRtt::direct
491const REGION_LATENCY_MAX_MS: u32 = 150;
492
493/// Whether the ADR 030 latency-vs-claim penalty applies to a probed peer.
494///
495/// It fires only when the peer self-attests the observer's *own* region
496/// (`own_region`) yet the observed probe latency exceeds [`REGION_LATENCY_MAX_MS`]
497/// — the canonical region-spoofing signal. An unset own region (`None`) or an
498/// unknown peer region (empty) disables it: there is nothing to contradict.
499fn region_latency_penalty_applies(own_region: Option<&str>, claimed: &str, rtt_ms: u32) -> bool {
500 !claimed.is_empty() && own_region == Some(claimed) && rtt_ms > REGION_LATENCY_MAX_MS
501}
502
503impl NodeOriginConfig {
504 /// The stage bounds for one upstream pull: a wall clock on the open, a throughput floor
505 /// on the stream, no overall cap (#1797).
506 ///
507 /// # Errors
508 ///
509 /// `DeadlineError::ZeroBudget` if either duration is zero. `config`'s own validator
510 /// already rejects that at startup, so this is the second lock on a door that must not
511 /// open: a zero window makes the throughput floor unsatisfiable and abandons every
512 /// upstream on its first read. Both call sites route the error to [`LocalPullFault`],
513 /// which meters it as OUR emergency and scores no peer (#1145 review).
514 fn deadlines(&self) -> anyhow::Result<PullDeadlines> {
515 PullDeadlines::new(
516 self.pull_timeout,
517 self.stall_window,
518 self.min_throughput_bps,
519 )
520 .map_err(|err| {
521 anyhow::anyhow!(
522 "node pull deadlines are unusable ({err}); \
523 check cache.node_pull_timeout_sec and cache.node_pull_stall_window_sec"
524 )
525 .context(LocalPullFault)
526 })
527 }
528}
529
530/// Dependencies the orchestration needs, injected once after runtime bring-up
531/// completes (see the module docs). Built and handed to [`NodeOrigin::provision`]
532/// by the runtime.
533pub struct NodeOriginDeps {
534 /// The node's shared iroh endpoint (dials probes + pulls).
535 pub endpoint: Endpoint,
536 /// The runtime every upstream dial of a pull leg runs on: the node's main
537 /// runtime, which outlives `endpoint`'s close.
538 ///
539 /// A pull leg runs on its own current-thread runtime, which drops when the leg
540 /// returns. A connection dialled there would lose its QUIC driver with that
541 /// runtime, never reach QUIC's draining state, and hang `Endpoint::close` at
542 /// shutdown. Every upstream dial this node makes, on a pull thread or not, goes
543 /// through
544 /// [`PeerSource::with_dial_runtime`](decdn_client::source::PeerSource::with_dial_runtime)
545 /// with this handle, so every driver lives here.
546 pub dial_runtime: tokio::runtime::Handle,
547 /// DHT routing table handle for `find_providers`.
548 pub routing_table: Arc<Mutex<RoutingTable>>,
549 /// Active-staker set (lookup integrity filter).
550 pub staker_set: Arc<dyn StakerSet>,
551 /// On-chain origin directory: the fallback when the DHT returns no providers,
552 /// and the supplement when a ranged pull's probed holders do not span the blob.
553 pub origin_directory: Arc<dyn OriginDirectory>,
554 /// Resolves a provider `NodeId` to its bonded operator Ethereum address.
555 pub addr_resolver: Arc<dyn NodeAddressResolver>,
556 /// Buyer-side payment-channel service (opens/reuses the upstream channel).
557 pub buyer: Arc<dyn PoolOpener>,
558 /// This node's DHT id, the lookup requester.
559 pub self_id: DhtNodeId,
560 /// EIP-712 domain verifying the delivery `slash_sig` (ADR 014 §1).
561 pub slash_domain: Eip712Domain,
562 /// `CapacityBond` EIP-712 bind domain (ADR 005). Used to sign the client
563 /// identity binding this node attaches — over its OWN iroh `NodeId` with
564 /// the buyer key — to every node→node pull, so an upstream node can prove
565 /// this node owns the named channel and, on its own cache miss, chain a
566 /// further reactive origin pull (`pull_authorized`, #1117). Built from
567 /// `chain_id` + the `CapacityBond` address, identical to the domain the
568 /// serving side verifies against.
569 pub bind_domain: Eip712Domain,
570 /// Local per-peer reputation score store (folded on each pull outcome).
571 pub local_rep: Arc<LocalReputation>,
572 /// Requester-side negative-probe cache (drops known-absent providers).
573 pub negative_cache: NegativeProbeCache,
574 /// Requester-side positive probe cache (ADR 001 §Probe cache): lets a repeat
575 /// miss for a hash inside the TTL skip the DHT lookup and probe fanout
576 /// (#1165). Beside the negative cache, and by value for the same reason:
577 /// `NodeOrigin` holds its deps behind `Arc<OnceLock<NodeOriginDeps>>`, so
578 /// every concurrent pull already shares one instance through that `Arc`, and
579 /// the cache's own `Mutex` gives it interior mutability behind `&self`. A
580 /// second `Arc` here would buy nothing but a pointer chase.
581 pub probe_cache: PositiveProbeCache,
582 /// Node metrics for the paid-pull observability counters (#831).
583 pub metrics: Arc<Metrics>,
584 /// The `CapacityBond` registry's `NodeId → regionHint` projection (ADR 030),
585 /// read on the selection/pull path to apply the region-latency penalty to a
586 /// same-region peer that answers slower than the ceiling.
587 pub registry_regions: Arc<std::sync::RwLock<HashMap<DhtNodeId, String>>>,
588 /// Resolved pull tuning.
589 pub config: NodeOriginConfig,
590 /// The live voucher ledger of each provider's current channel, shared by every
591 /// concurrent pull on it (#1145 review). Not a cache — see [`BuyerLedgers`] for
592 /// why a per-pull ledger collides on the same cumulative amount and what that
593 /// costs.
594 pub ledgers: Arc<BuyerLedgers>,
595 /// Providers suppressed from ranking because one rejected our voucher on a reason this
596 /// lane cannot recover from — mapped to the first Unix second at which the provider is
597 /// rankable again (#1145 review). The horizon is a fixed
598 /// `WEDGED_PROVIDER_SUPPRESSION_SECS` window, not a channel deadline: the buyer pool is
599 /// shared across every provider, so it carries no per-provider expiry to key one on.
600 /// Keyed by the peer's [`DhtNodeId`], so `probe_and_rank` can skip a wedged provider for
601 /// ALL hashes, not just the one that wedged it. In-memory: on restart the first miss
602 /// re-wedges and re-suppresses within one pull.
603 pub wedged_providers: Arc<Mutex<HashMap<DhtNodeId, u64>>>,
604 /// The cache engine this origin admits into. Set at `provision`, after the
605 /// engine exists. The node-to-node pull streams straight into it via
606 /// `admit_bao_stream`, so a whole blob never lands in RAM (#1682).
607 pub engine: decdn_cache::CacheEngine,
608}
609
610impl std::fmt::Debug for NodeOriginDeps {
611 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
612 f.debug_struct("NodeOriginDeps")
613 .field("self_id", &self.self_id)
614 .field("config", &self.config)
615 .finish_non_exhaustive()
616 }
617}
618
619impl NodeOriginDeps {
620 /// Suppress `pk` from ranking for [`WEDGED_PROVIDER_SUPPRESSION_SECS`], so
621 /// `probe_and_rank` and `cached_candidates` skip the provider for ALL hashes until that
622 /// window elapses (#1145 review). The window is owned here rather than passed in because
623 /// it is the same for every lane-terminal reason: the pool the lane draws on has no
624 /// per-provider deadline to key one on, so there is nothing for a caller to vary it by.
625 ///
626 /// A re-wedge RESTARTS the window rather than extending the original — the horizon a
627 /// provider earns is measured from its most recent rejection, not its first.
628 fn record_wedged(&self, pk: &PublicKey) {
629 let node = DhtNodeId::from_bytes(*pk.as_bytes());
630 record_wedged_at(
631 &mut self
632 .wedged_providers
633 .lock()
634 .unwrap_or_else(std::sync::PoisonError::into_inner),
635 node,
636 crate::payment_settlement::unix_now(),
637 );
638 }
639
640 /// True if `peer` is wedged and still inside its suppression horizon. Prunes horizons that
641 /// have passed on read: the window is a cooling-off period, not a verdict, so once it
642 /// elapses the provider is worth ranking again and paying for a sweep task to say so would
643 /// buy nothing a read cannot.
644 fn provider_is_wedged(&self, peer: &DhtNodeId, now_secs: u64) -> bool {
645 let mut wedged = self
646 .wedged_providers
647 .lock()
648 .unwrap_or_else(std::sync::PoisonError::into_inner);
649 prune_and_check_wedged(&mut wedged, peer, now_secs)
650 }
651}
652
653/// Suppress `node` for [`WEDGED_PROVIDER_SUPPRESSION_SECS`] measured from `now_secs`.
654///
655/// `insert` overwrites, so re-wedging a provider already in the map restarts its window from
656/// `now_secs` instead of leaving it on the older horizon. That is the intended reading of a
657/// second rejection: the provider earned a fresh window, not the remainder of its first.
658///
659/// Free-standing for the same reason as [`prune_and_check_wedged`] — it is the write half of
660/// the same policy, and pairing them is what lets one test pin the horizon end to end.
661fn record_wedged_at(wedged: &mut HashMap<DhtNodeId, u64>, node: DhtNodeId, now_secs: u64) {
662 wedged.insert(
663 node,
664 now_secs.saturating_add(WEDGED_PROVIDER_SUPPRESSION_SECS),
665 );
666}
667
668/// Drop every suppression horizon `now_secs` has passed, then report whether `peer` still has
669/// one. The horizon is exclusive: a provider is rankable again ON the second its window ends.
670///
671/// Free-standing over the map rather than a method, so the window semantics are testable
672/// without a live [`NodeOriginDeps`] — which needs an iroh endpoint, and would be a fixture far
673/// larger than the assertion.
674fn prune_and_check_wedged(
675 wedged: &mut HashMap<DhtNodeId, u64>,
676 peer: &DhtNodeId,
677 now_secs: u64,
678) -> bool {
679 wedged.retain(|_, expires_at| *expires_at > now_secs);
680 wedged.contains_key(peer)
681}
682
683/// The hashes this node has a pull-leg open ([`NodeOrigin::open_pull_leg`]) in
684/// progress for, each with the number of opens in flight. The buffered
685/// [`Origin::fetch`] path does not mark its pulls here. A whole-blob request
686/// from an active staker for one of these hashes would close a pull-through
687/// loop (#2224), so the serve path refuses it (see
688/// [`NodeOrigin::refuses_whole_blob`]).
689#[derive(Debug, Default)]
690struct PendingOpens(std::sync::Mutex<HashMap<[u8; 32], usize>>);
691
692impl PendingOpens {
693 fn contains(&self, hash: &[u8; 32]) -> bool {
694 self.0
695 .lock()
696 .unwrap_or_else(std::sync::PoisonError::into_inner)
697 .contains_key(hash)
698 }
699}
700
701/// Marks one upstream open for a hash as in progress until it drops. Bind it
702/// to a named local for the whole open: dropping it at once marks nothing.
703#[derive(Debug)]
704#[must_use = "an open is marked in progress only while its guard lives"]
705struct PendingOpen {
706 pending: Arc<PendingOpens>,
707 hash: [u8; 32],
708}
709
710impl PendingOpen {
711 fn enter(pending: &Arc<PendingOpens>, hash: [u8; 32]) -> Self {
712 let mut opens = pending
713 .0
714 .lock()
715 .unwrap_or_else(std::sync::PoisonError::into_inner);
716 let count = opens.entry(hash).or_insert(0);
717 *count = count.saturating_add(1);
718 Self {
719 pending: Arc::clone(pending),
720 hash,
721 }
722 }
723}
724
725impl Drop for PendingOpen {
726 fn drop(&mut self) {
727 let mut opens = self
728 .pending
729 .0
730 .lock()
731 .unwrap_or_else(std::sync::PoisonError::into_inner);
732 if let Some(count) = opens.get_mut(&self.hash) {
733 *count = count.saturating_sub(1);
734 if *count == 0 {
735 opens.remove(&self.hash);
736 }
737 }
738 }
739}
740
741/// Node-to-node pull-through [`Origin`]. Cheap to clone via the shared inner
742/// [`Arc`]; the runtime holds one and injects its dependencies once.
743#[derive(Debug, Clone)]
744pub struct NodeOrigin {
745 deps: Arc<OnceLock<NodeOriginDeps>>,
746 pending: Arc<PendingOpens>,
747}
748
749impl NodeOrigin {
750 /// Build an unprovisioned origin. `fetch` is a clean miss until
751 /// [`Self::provision`] supplies the dependencies.
752 #[must_use]
753 pub fn new() -> Self {
754 Self {
755 deps: Arc::new(OnceLock::new()),
756 pending: Arc::new(PendingOpens::default()),
757 }
758 }
759
760 /// Supply the dependencies, enabling pull-through. Idempotent-safe: a second
761 /// call is ignored with a warning (the write-once `OnceLock` keeps the first
762 /// set), so a misconfigured double-provision can't silently swap deps under
763 /// an in-flight fetch.
764 pub fn provision(&self, deps: NodeOriginDeps) {
765 if self.deps.set(deps).is_err() {
766 warn!("NodeOrigin provisioned more than once; keeping the first dependency set");
767 }
768 }
769
770 /// A clone of the write-once dependency handle, for the off-task serve-miss pull
771 /// leg (#1621): the pull runs on its OWN current-thread runtime (its
772 /// `drive` is non-`Send`, which the iroh `ProtocolHandler::accept` bound forbids
773 /// on the serve task), so it cannot borrow `&self`. It captures this `Arc` and
774 /// reads the deps via `get()` on the pull thread — the same handle
775 /// [`SettleOnDrop`] already carries across a drop.
776 pub(crate) fn deps_arc(&self) -> Arc<OnceLock<NodeOriginDeps>> {
777 Arc::clone(&self.deps)
778 }
779
780 /// Mark an upstream open for `hash` as in progress until the guard drops.
781 fn enter_open(&self, hash: Hash) -> PendingOpen {
782 PendingOpen::enter(&self.pending, *hash.as_bytes())
783 }
784
785 /// Whether the serve path refuses a whole-blob request for `hash` from
786 /// `requester` (#2224). It does while this node's own upstream open for
787 /// `hash` is in progress and `requester` is an active staker. A partial
788 /// holder that answers such a request opens its own whole-blob pull, and
789 /// three or more partial holders then pull the blob from each other in a
790 /// loop. The fill is claimed only after the open, so the open is the window
791 /// a loop closes through. A client is never an active staker, so a client's
792 /// whole-blob request is never refused for this reason, nor is a ranged
793 /// request: a loop of ranged handshakes asks each holder only for blocks it
794 /// advertises, so it starts no recursive whole-blob pull. `false` on an
795 /// unprovisioned origin.
796 pub(crate) fn refuses_whole_blob(&self, hash: Hash, requester: [u8; 32]) -> bool {
797 self.deps.get().is_some_and(|deps| {
798 self.pending.contains(hash.as_bytes())
799 && deps.staker_set.is_active(&DhtNodeId::from_bytes(requester))
800 })
801 }
802
803 /// The buyer-side blob-size ceiling in bytes
804 /// ([`NodeOriginConfig::max_blob_size_bytes`]). `0` means unlimited, and an
805 /// unprovisioned origin reads as unlimited.
806 pub(crate) fn max_blob_size_bytes(&self) -> u64 {
807 self.deps
808 .get()
809 .map_or(0, |deps| deps.config.max_blob_size_bytes)
810 }
811
812 /// Whether the negative cache currently suppresses `peer` for `hash`. `false`
813 /// on an unprovisioned origin. Test-only: it lets a loopback test read the
814 /// suppression a pull left behind.
815 #[cfg(any(test, feature = "test-support"))]
816 #[must_use]
817 pub fn is_suppressed(&self, peer: PublicKey, hash: Hash) -> bool {
818 self.deps.get().is_some_and(|deps| {
819 deps.negative_cache.contains_active(
820 &DhtNodeId::from_bytes(*peer.as_bytes()),
821 &DhtHash::from_bytes(*hash.as_bytes()),
822 )
823 })
824 }
825}
826
827impl Default for NodeOrigin {
828 fn default() -> Self {
829 Self::new()
830 }
831}
832
833impl Origin for NodeOrigin {
834 fn fetch(
835 &self,
836 hash: Hash,
837 _max_bytes: u64,
838 ) -> Pin<Box<dyn Future<Output = Result<OriginFetch, OriginPullError>> + Send + '_>> {
839 let deps_lock = Arc::clone(&self.deps);
840 let span = tracing::info_span!(
841 "node_pull",
842 %hash,
843 outcome = tracing::field::Empty,
844 );
845 Box::pin(async move {
846 let result = async move {
847 let Some(deps) = deps_lock.get() else {
848 // Pull-through not provisioned (feature disabled or the buyer
849 // bootstrap failed). A clean miss — the engine surfaces NotFound
850 // to the handler, which returns it to the client. The engine
851 // enforces `max_bytes` on whatever any provisioned pull returns,
852 // so it is not consulted here.
853 return Ok(OriginFetch::NotFound);
854 };
855 let hash_bytes = *hash.as_bytes();
856 let target = DhtHash::from_bytes(hash_bytes);
857 // ONE budget for the whole fetch, spent across both phases — see
858 // `PullOutcome`.
859 let mut budget = MAX_PROVIDER_ATTEMPTS;
860 // `node_pull_attempts` counts pull ORCHESTRATIONS ("found ≥1 candidate
861 // to try"), and is the denominator for the success / corruption /
862 // unreachable rates. One `fetch` is one orchestration however many
863 // candidate lists it walks, so a probe-cache hit that exhausts its
864 // providers and falls through to the cold path must still meter
865 // exactly once — otherwise every such fetch inflates the denominator
866 // and quietly deflates every rate built on it.
867 let mut attempt_metered = false;
868 // Latched across BOTH walks, like `attempt_metered` — see
869 // `miss_answer` for what it buys.
870 let mut miss = PullMiss::Clean;
871 // The fill's funding recovery state (ADR 003 § Funding recovery),
872 // and the candidates of both walks that refused this node's
873 // funding: the ones the step asks again.
874 let gate = Arc::new(RecoveryGate::with_settle(settle_window(
875 deps.config.event_poll_interval,
876 )));
877 // The deposit this fill starts at. A sibling fill's step that
878 // lands above it serves this fill too.
879 let seen = deps.buyer.pool_deposit().unwrap_or(U256::ZERO);
880 let mut unfunded: Vec<Candidate> = Vec::new();
881
882 // ADR 001 §Probe cache: "On a cache miss the requester checks the probe
883 // cache first; if a valid entry exists, it skips DHT lookup and goes
884 // straight to selection."
885 if let Some(cached) = cached_candidates(deps, target).await {
886 deps.metrics.probe_cache_hit();
887 deps.metrics.node_pull_attempt();
888 attempt_metered = true;
889 let outcome =
890 try_pull(&deps_lock, deps, &cached, hash_bytes, budget, &gate).await;
891 match outcome.payload {
892 Ok(()) => return Ok(OriginFetch::AlreadyAdmitted),
893 Err(failed) => miss = miss.or(failed),
894 }
895 unfunded.extend(outcome.unfunded);
896 budget = budget.saturating_sub(outcome.attempts);
897 // Every cached provider we had budget to try failed to deliver.
898 // The entry has been disproved by the only evidence that outranks
899 // a probe — actual pulls — so drop it rather than let it keep
900 // hitting for the rest of its TTL. Any untried tail beyond
901 // `budget` is forfeited with it: a fresh probe is cheaper than
902 // trusting a list whose top-ranked members just failed.
903 deps.probe_cache.invalidate(&target);
904 if budget == 0 {
905 // The cached candidates ate the whole fetch-wide budget.
906 // Running a fresh lookup + probe now would either exceed the
907 // worst case `outer_pull_deadline` is sized for, or discover
908 // providers it has no attempts left to try. The entry is gone,
909 // so the next fetch goes cold.
910 //
911 // Answered through `miss_answer` like the cold-path arm below,
912 // so an exhausted budget spent on OUR faults is not signed to a
913 // client as an absent blob (#1560).
914 debug!(%hash, "node-origin: probe-cache candidates exhausted the attempt budget");
915 return recover_unfunded(
916 &deps_lock, deps, hash_bytes, unfunded, miss, &gate, seen,
917 )
918 .await;
919 }
920 } else {
921 deps.metrics.probe_cache_miss();
922 }
923
924 // ADR 001 §Probe cache: "if all fail, run a fresh DHT lookup + probe."
925 // This is the generic `Origin::fetch` path (the buffered `populate` fill),
926 // a hash-only pull with no client namespace, so it takes no on-chain
927 // origin-directory fallback (`NO_NAMESPACE`). The namespace-aware
928 // client-serve serve-miss path is `open_pull_leg`.
929 let providers = discover(deps, hash_bytes, U256::ZERO).await;
930 if providers.is_empty() {
931 // `node_pull_no_providers` means "the blob is unavailable on the
932 // network, NOT a pull failure" — mutually exclusive with
933 // `node_pull_attempts` per fetch. A hit-then-fallthrough fetch
934 // already TRIED cached providers (and metered the attempt), so an
935 // empty re-discovery here is a pull story, not an availability
936 // one; metering both would break that exclusivity.
937 if !attempt_metered {
938 deps.metrics.node_pull_no_providers();
939 }
940 debug!(%hash, "node-origin: no providers discovered for cache-miss pull");
941 return recover_unfunded(
942 &deps_lock, deps, hash_bytes, unfunded, miss, &gate, seen,
943 )
944 .await;
945 }
946 if !attempt_metered {
947 deps.metrics.node_pull_attempt();
948 }
949 // Writes the probe cache at its tail. The buffered fill is a
950 // single-source pull (`try_pull`), so one working holder is enough.
951 let ranked = probe_and_rank(
952 deps,
953 providers,
954 hash_bytes,
955 ProbeGather::EarlyExit,
956 U256::ZERO,
957 )
958 .await;
959 let outcome = try_pull(&deps_lock, deps, &ranked, hash_bytes, budget, &gate).await;
960 match outcome.payload {
961 Ok(()) => Ok(OriginFetch::AlreadyAdmitted),
962 Err(failed) => {
963 // The exhausted candidate set: both walks and a fresh
964 // discovery delivered nothing. Funding recovery for the
965 // candidates that refused this node's funding.
966 unfunded.extend(outcome.unfunded);
967 recover_unfunded(
968 &deps_lock,
969 deps,
970 hash_bytes,
971 unfunded,
972 miss.or(failed),
973 &gate,
974 seen,
975 )
976 .await
977 }
978 }
979 }
980 .instrument(span.clone())
981 .await;
982 span.record(
983 "outcome",
984 match &result {
985 Ok(OriginFetch::AlreadyAdmitted | OriginFetch::Found { .. }) => "filled",
986 Ok(OriginFetch::NotFound) => "not_found",
987 Err(_) => "failed",
988 },
989 );
990 result
991 })
992 }
993
994 fn kind(&self) -> OriginKind {
995 OriginKind::Peer
996 }
997}
998
999/// The buffered pull's answer for a walk that delivered nothing (#1560).
1000///
1001/// A [`PullMiss::Clean`] is a `NotFound`: no provider had it, or every one of them
1002/// refused, stalled, or timed out. That is a true statement about what this node
1003/// could obtain, and the engine surfaces it as `CacheError::NotFound`.
1004///
1005/// A [`PullMiss::LocalFault`] is not. The blob may exist on every candidate we
1006/// asked; what failed is US — a buyer key that cannot sign, a deadline config that
1007/// cannot run, a signature the upstream cannot verify, a cache store that cannot
1008/// take the bytes. Reported as a miss it
1009/// becomes a signed wire `NotFound`, a false claim about content that a broken
1010/// node must not make. So it surfaces as an origin error instead, and the serve
1011/// path's `CacheError::OriginError` → `FillOutcome::HardFault` →
1012/// `ServeRejectReason::InternalError` chain answers the client `Declined`.
1013///
1014/// [`OriginPullError::Permanent`] rather than `Transient`, on three counts: the
1015/// retry loop must not re-run a pull whose signer or store is broken; `Permanent` records
1016/// `OriginOutcome::Available` on the per-origin circuit breaker, so our own defect
1017/// does not trip a breaker that describes the PEER origin's health; and the engine's
1018/// chain walk lets any error outrank a `NotFound` from another origin, which is the
1019/// precedence this fix wants.
1020///
1021/// A [`PullMiss::BelowMargin`] answers the same `NotFound` as [`PullMiss::Clean`]:
1022/// this node's serve-economics buy ceiling is a local policy decision, not a fact
1023/// about the content, and it must never reach the wire as a distinct code — that
1024/// would let a client fingerprint this node's pricing floor by probing for it. A
1025/// [`PullMiss::FundingNeeded`] and [`PullMiss::Unfunded`] answer it too: this
1026/// node's own funding gap is not the requester's to recover.
1027fn miss_answer(miss: PullMiss) -> Result<OriginFetch, OriginPullError> {
1028 match miss {
1029 PullMiss::Clean | PullMiss::BelowMargin | PullMiss::FundingNeeded | PullMiss::Unfunded => {
1030 Ok(OriginFetch::NotFound)
1031 }
1032 PullMiss::LocalFault => Err(OriginPullError::Permanent(anyhow::anyhow!(
1033 "node-origin: a LOCAL fault hit at least one attempted candidate and none \
1034 delivered; this node cannot complete the pull, so it refuses rather than \
1035 signing a NotFound for content that may well exist (#1560)"
1036 ))),
1037 }
1038}
1039
1040/// Discover candidate providers for `hash`: the DHT iterative lookup first,
1041/// falling back to the on-chain origin directory keyed on `namespace_id` when the
1042/// lookup converges empty (ADR 022 §`FIND_VALUE` Flow). On the ranged pull leg
1043/// ([`ProbeGather::CoverageUnion`]), a non-empty DHT answer whose holders cannot
1044/// span the blob gets the directory's origins in [`probe_and_rank`] instead,
1045/// because only a probe reports the blob size that decides it.
1046///
1047/// `namespace_id` is the namespace the serving node received on the client
1048/// `StreamRequest`. Within the pull it is consumed at the two directory reads:
1049/// the fallback here and the supplement in [`probe_and_rank`]. A DHT-discovered
1050/// *holder* already has the bytes and needs no namespace, but a
1051/// directory-discovered *cold origin* is then reached with this same namespace so
1052/// its own pull-through gate resolves (#1401, threaded by the progressive
1053/// client-serve path). Origin backends (S3/HTTP/FS) are hash-keyed and never see
1054/// it. `NO_NAMESPACE` (0) resolves to no authorized origins, so a hash-only pull
1055/// (the buffered fill) simply gets no directory fallback (ADR 002 §Namespace 0).
1056async fn discover(
1057 deps: &NodeOriginDeps,
1058 hash_bytes: [u8; 32],
1059 namespace_id: U256,
1060) -> Vec<DhtNodeId> {
1061 let target = DhtHash::from_bytes(hash_bytes);
1062 // `find_providers` carries each holder's range-keyed `Coverage` alongside
1063 // its `NodeId` (ADR 039-adjacent partial-holder discovery) — a STALE
1064 // DHT-lookup hint, not a live observation. It is dropped here rather than
1065 // used to prune candidates before probing: the authoritative coverage a
1066 // ranked candidate carries onward comes from its fresh `ProbeResponseExt`
1067 // instead (`probe_candidate`, #1506). A later task may use this hint to
1068 // skip probing a candidate whose stale coverage already misses the whole
1069 // pull range; today every discovered candidate is still probed.
1070 let providers: Vec<DhtNodeId> = crate::dht::find_providers(
1071 &deps.endpoint,
1072 &deps.routing_table,
1073 &deps.staker_set,
1074 &deps.negative_cache,
1075 deps.self_id,
1076 target,
1077 deps.config.lookup,
1078 Some(&deps.metrics),
1079 )
1080 .await
1081 .into_iter()
1082 .map(|(node, _coverage)| node)
1083 .collect();
1084 if providers.is_empty() {
1085 directory_origins(deps, namespace_id).await
1086 } else {
1087 providers
1088 }
1089}
1090
1091/// The origin directory's candidates for `namespace_id`, without this node. The
1092/// DHT lookup drops this node by `self_id`; the directory lists every active
1093/// origin operator, this node included, and a node never pulls from itself.
1094async fn directory_origins(deps: &NodeOriginDeps, namespace_id: U256) -> Vec<DhtNodeId> {
1095 let mut origins = deps.origin_directory.lookup_origins(namespace_id).await;
1096 origins.retain(|origin| *origin != deps.self_id);
1097 origins
1098}
1099
1100/// Probe up to `probe_fanout` providers for rate + RTT, build a [`Candidate`]
1101/// for each that reports holding the blob, and rank them by the combined
1102/// reputation-weighted selection score.
1103///
1104/// Under [`ProbeGather::CoverageUnion`], a round whose holders cannot span the
1105/// blob also probes the origin directory's candidates for `namespace_id` that the
1106/// first round did not probe (ADR 022 §`FIND_VALUE` Flow, #2195). A DHT answer can
1107/// hold only partial holders, and the ranged assembly needs a candidate set that
1108/// covers every block. An origin past the first round's `probe_fanout` cut is
1109/// probed here even when `providers` named it.
1110async fn probe_and_rank(
1111 deps: &NodeOriginDeps,
1112 providers: Vec<DhtNodeId>,
1113 hash_bytes: [u8; 32],
1114 gather: ProbeGather,
1115 namespace_id: U256,
1116) -> Vec<Candidate> {
1117 let target = DhtHash::from_bytes(hash_bytes);
1118 let mut candidates: Vec<Candidate> = Vec::new();
1119 let probed = probe_round(deps, &providers, hash_bytes, gather, &mut candidates).await;
1120 if matches!(gather, ProbeGather::CoverageUnion) && !coverage_spans(&candidates) {
1121 let directory = directory_origins(deps, namespace_id).await;
1122 let listed = directory.len();
1123 let origins: Vec<DhtNodeId> = directory
1124 .into_iter()
1125 .filter(|origin| !probed.contains(origin))
1126 .collect();
1127 let hash = Hash::from_bytes(hash_bytes);
1128 let holders = candidates.len();
1129 if origins.is_empty() {
1130 debug!(
1131 %hash,
1132 %namespace_id,
1133 holders,
1134 listed,
1135 "node-origin: probed holders do not span the blob and the origin directory \
1136 adds no unprobed candidate"
1137 );
1138 } else {
1139 probe_round(deps, &origins, hash_bytes, gather, &mut candidates).await;
1140 debug!(
1141 %hash,
1142 %namespace_id,
1143 holders,
1144 origins = origins.len(),
1145 admitted = candidates.len().saturating_sub(holders),
1146 spans = coverage_spans(&candidates),
1147 "node-origin: probed holders did not span the blob; probed origin-directory \
1148 candidates"
1149 );
1150 }
1151 }
1152 let ranked = rank(candidates);
1153 // ADR 001 §Probe cache: retain the top 10 by selection score, so a repeat
1154 // miss for this hash inside the TTL skips the lookup and the probe fanout.
1155 // `insert` does the truncation; `ranked` is already in selection order,
1156 // which is the ordering that claim depends on.
1157 //
1158 // The triple plus the probe's UNSIGNED coverage is stored (#1506) — never
1159 // the signed `ProbeResponse` (its `slash_sig` is another node's slashable
1160 // statement, and #1165's "no evidence retention" is that this cache must not
1161 // become an evidence locker; coverage carries no such author), and never
1162 // `reputation`, which `cached_candidates` recomputes.
1163 deps.probe_cache.insert(
1164 target,
1165 ranked
1166 .iter()
1167 .map(|c| ProbedProvider {
1168 node_id: DhtNodeId::from_bytes(c.node_id),
1169 rate_per_mb: c.rate_per_mb,
1170 rtt_ms: c.rtt_ms,
1171 coverage: c.coverage.clone(),
1172 total_bytes_hint: c.total_bytes_hint,
1173 })
1174 .collect(),
1175 );
1176 ranked
1177}
1178
1179/// One concurrent probe round over `peers`, pushing each holder into
1180/// `candidates` until `gather`'s stop condition holds or the round drains.
1181/// Returns the peers it probed: those left after the negative-cache, wedged, and
1182/// `probe_fanout` filters.
1183///
1184/// Under [`ProbeGather::CoverageUnion`] the stop condition counts the holders
1185/// already in `candidates`, so a second round stops as soon as its answers close
1186/// the union the first round left open.
1187async fn probe_round(
1188 deps: &NodeOriginDeps,
1189 peers: &[DhtNodeId],
1190 hash_bytes: [u8; 32],
1191 gather: ProbeGather,
1192 candidates: &mut Vec<Candidate>,
1193) -> Vec<DhtNodeId> {
1194 use futures_util::stream::StreamExt;
1195 let mut union = CoverageUnionTracker::from_candidates(candidates);
1196 let now_secs = crate::payment_settlement::unix_now();
1197 let target = DhtHash::from_bytes(hash_bytes);
1198 // Probe candidates CONCURRENTLY so the probe phase is bounded by a single
1199 // `PROBE_TIMEOUT` rather than `fanout × PROBE_TIMEOUT`: a few slow or
1200 // unreachable peers must not burn the whole pull budget before a healthy
1201 // provider is even tried. `probe_candidate`'s side effects (reputation
1202 // record, negative-cache insert) are all behind locks, so concurrent runs
1203 // are safe; ranking afterwards makes result order irrelevant.
1204 //
1205 // The collection window opens here: every probe, dial included, starts at once on
1206 // the first poll.
1207 let collection_started = Instant::now();
1208 let selected: Vec<DhtNodeId> = peers
1209 .iter()
1210 .copied()
1211 // Drop peers already known to answer "no" for THIS hash within the TTL.
1212 // `find_providers` applies the same filter, but only to what the DHT lookup
1213 // returns — it cannot cover the origin-directory candidates (the fallback for an
1214 // empty lookup, and the supplement for holders that cannot span the blob), nor
1215 // an entry recorded AFTER discovery, which is exactly what a pull-time refusal
1216 // is (#1145 review). This is the one chokepoint every candidate passes through
1217 // regardless of how it was found.
1218 //
1219 // Filtered BEFORE `take`, so a suppressed peer does not consume a probe-fanout
1220 // slot that a viable provider could have used.
1221 .filter(|peer| !deps.negative_cache.contains_active(peer, &target))
1222 // Also drop WEDGED providers, for ALL hashes until the suppression window elapses
1223 // (#1145 review): a provider whose voucher we just failed to pay on lane-terminal terms
1224 // is one we cannot pay for any blob right now. Nothing else SUPPRESSES it — the
1225 // negative-cache entry the wedge writes alongside this one is keyed on (peer, hash), so
1226 // it lapses for any other blob, and the arm deliberately scores no reputation. Without
1227 // this the peer is re-selected on the next miss for a different hash and re-wedged —
1228 // burning a candidate slot each time.
1229 .filter(|peer| !deps.provider_is_wedged(peer, now_secs))
1230 .take(deps.config.probe_fanout)
1231 .collect();
1232 let mut probes: futures_util::stream::FuturesUnordered<_> = selected
1233 .iter()
1234 .map(|&peer| probe_candidate(deps, peer, hash_bytes))
1235 .collect();
1236 // A round that sends no probe has no collection window to record.
1237 let sent_any = !probes.is_empty();
1238 // Collect answers as they arrive and stop early once enough good candidates are in
1239 // hand: waiting on `join_all` would pace the whole round to the SLOWEST probe — a dead
1240 // peer that only resolves at its `PROBE_TIMEOUT` — even when the fastest few already
1241 // answered. `PROBE_TIMEOUT` still bounds each probe, so a sparse round that never reaches
1242 // the stop condition simply drains to the ceiling; a healthy round selects at the speed
1243 // of its fastest good answers.
1244 //
1245 // The stop condition depends on `gather`: [`ProbeGather::EarlyExit`] stops at a fixed
1246 // count of holders (single-source failover), while [`ProbeGather::CoverageUnion`] stops
1247 // once the admitted holders' coverage union spans the blob (ranged assembly, #1506) — a
1248 // set of partial holders whose fastest answers all cover the same block must not stop
1249 // short of the holders that cover the rest.
1250 while let Some(result) = probes.next().await {
1251 if let Some(candidate) = result {
1252 let done = match gather {
1253 ProbeGather::EarlyExit => {
1254 candidates.push(candidate);
1255 candidates.len() >= PROBE_EARLY_EXIT_CANDIDATES
1256 }
1257 ProbeGather::CoverageUnion => {
1258 // `union` also carries every holder an earlier round admitted.
1259 union.add(&candidate);
1260 candidates.push(candidate);
1261 // With no holder-reported size this stays false and the round
1262 // drains to the probe-fanout ceiling.
1263 union.spans()
1264 }
1265 };
1266 if done {
1267 break;
1268 }
1269 }
1270 }
1271 // Cancel any probes still pending: the loop has enough (or the set is drained). Their
1272 // reputation / negative-cache side effects simply do not run for peers we never needed.
1273 drop(probes);
1274 if sent_any {
1275 deps.metrics
1276 .probe_collection_latency(collection_started.elapsed());
1277 }
1278 selected
1279}
1280
1281/// Read one probe's RTT as evidence about the peer's region claim.
1282///
1283/// ADR 030 canonical latency-vs-claim penalty: a peer that self-attests THIS
1284/// node's own region yet answers slower than the latency ceiling is spoofing
1285/// its region. A local-only signal — folded straight into the local EWMA — so
1286/// it self-corrects as fast as we probe and needs no new protocol surface. An
1287/// RTT with no direct path behind it (`direct == false`) can be the relay
1288/// detour. It still counts as evidence, because the peer controls whether a
1289/// direct path forms; the logs carry `direct` so an operator can tell a relay
1290/// reading from a direct one.
1291fn score_probe_latency(deps: &NodeOriginDeps, pk: PublicKey, region: &str, rtt: u32, direct: bool) {
1292 if !direct {
1293 debug!(
1294 peer = %pk,
1295 rtt_ms = rtt,
1296 "node-origin: probe RTT has no direct path behind it; ranking on the exchange RTT"
1297 );
1298 }
1299 if region_latency_penalty_applies(deps.config.own_region.as_deref(), region, rtt) {
1300 // Log with the disambiguating context (the metric alone cannot tell a
1301 // spoofer from a mis-set local `identity.region`): peer, both regions,
1302 // and the observed latency vs ceiling.
1303 debug!(
1304 peer = %pk,
1305 own_region = deps.config.own_region.as_deref().unwrap_or(""),
1306 claimed_region = %region,
1307 rtt_ms = rtt,
1308 direct,
1309 ceiling_ms = REGION_LATENCY_MAX_MS,
1310 "node-origin: same-region claim contradicted by probe latency; \
1311 applying ADR 030 latency penalty"
1312 );
1313 deps.metrics.node_region_latency_penalty();
1314 record_outcome(deps, pk, &Outcome::RegionLatencyMismatch);
1315 }
1316}
1317
1318/// `rank_candidates` reduced to the best-first `Candidate` list both pull paths
1319/// consume. Shared by the cold path and the probe-cache-hit path so a change to
1320/// what "ranked" means cannot apply to one and not the other.
1321fn rank(candidates: Vec<Candidate>) -> Vec<Candidate> {
1322 rank_candidates(candidates)
1323 .into_iter()
1324 .map(|r| r.candidate)
1325 .collect()
1326}
1327
1328/// Probe a single provider, returning a ranked-ready [`Candidate`], carrying
1329/// the holder's reported blob size (`ProbeResponseExt.total_bytes`) as its
1330/// [`Candidate::total_bytes_hint`], iff it responds, validates, and reports
1331/// holding the blob. Side effects: a failed probe scores the provider [`Outcome::Unreachable`],
1332/// except a probe the provider shed with `APP_ERR_RATE_LIMITED`, which suppresses
1333/// the pair for [`REFUSAL_SUPPRESSION_TTL`] and scores nothing (#1986); a
1334/// same-region claim answered slower than [`REGION_LATENCY_MAX_MS`] scores
1335/// [`Outcome::RegionLatencyMismatch`] (ADR 030); a reachable-but-absent provider
1336/// is recorded in the negative-probe cache.
1337// Straight-line probe → classify → build; the tracing macros and the three
1338// sequential drop-conditions inflate the cognitive-complexity metric past the
1339// threshold (same inflation noted in `chain_staker_set`), and splitting the
1340// validation further would obscure the flow rather than clarify it.
1341#[allow(clippy::cognitive_complexity)]
1342async fn probe_candidate(
1343 deps: &NodeOriginDeps,
1344 peer: DhtNodeId,
1345 hash_bytes: [u8; 32],
1346) -> Option<Candidate> {
1347 let Ok(pk) = PublicKey::from_bytes(peer.as_bytes()) else {
1348 // A staker-filtered routing entry should always decode; a failure
1349 // implies upstream state corruption — skip rather than panic.
1350 return None;
1351 };
1352 let probe_ts = now_micros();
1353 let (resp, resp_ext, probe_rtt) = match probe_once(
1354 &deps.endpoint,
1355 EndpointAddr::new(pk),
1356 hash_bytes,
1357 probe_ts,
1358 PROBE_TIMEOUT,
1359 )
1360 .await
1361 {
1362 Ok(ok) => ok,
1363 Err(err) => {
1364 // A `0x10` shed is the peer ANSWERING — "not now" — not the peer being
1365 // unreachable (#1986). It gets what a handler-level `NotFound` refusal
1366 // gets in `classify_refusal`: the pair is suppressed for the short TTL so
1367 // a retry burst stops re-spending a probe slot on it, and no reputation
1368 // outcome is recorded. A `global-full` shed skips the node's close ack-wait
1369 // and can still arrive as a bare drop; that residue scores below.
1370 if err.downcast_ref::<UpstreamRateLimited>().is_some() {
1371 debug!(error = %err, "node-origin: probe shed by the upstream's rate limiter; suppressing briefly, not scoring");
1372 deps.metrics.node_upstream_rate_limited();
1373 deps.negative_cache.record_failure_with_ttl(
1374 peer,
1375 DhtHash::from_bytes(hash_bytes),
1376 REFUSAL_SUPPRESSION_TTL,
1377 );
1378 return None;
1379 }
1380 // A failed probe is a reachability signal: the upstream could not be
1381 // reached for this interaction (ADR 008 §Local Score).
1382 debug!(
1383 peer = %pk,
1384 provider_addr = ?deps.addr_resolver.address_of(&peer),
1385 error = %err,
1386 "node-origin: probe failed; scoring provider unreachable"
1387 );
1388 record_outcome(deps, pk, &Outcome::Unreachable);
1389 return None;
1390 }
1391 };
1392 // Shape, echoed-field correlation, and `slash_sig` recovery to the peer's
1393 // registered operator address (ADR 005 §Signer binding, ADR 014 §1). Selection
1394 // reads `has_blob` and `rate_per_mb` off this response and then PAYS the winner,
1395 // so an unrecovered signature would let a node win on a rate it never committed
1396 // to — and leave nothing to slash when it declines to honour it.
1397 //
1398 // A failure is requester-local policy, never an attributable fault: dropped
1399 // without a reputation event, exactly as for a timeout, because a signature that
1400 // does not recover attributes nothing to anyone (ADR 008).
1401 let Some(provider_addr) = deps.addr_resolver.address_of(&peer) else {
1402 debug!("node-origin: probed peer has no resolvable operator address; skipping");
1403 return None;
1404 };
1405 if let Err(err) = decdn_client::probe::verify_probe_response(
1406 &resp,
1407 provider_addr,
1408 &deps.slash_domain,
1409 hash_bytes,
1410 probe_ts,
1411 ) {
1412 debug!(error = %err, "node-origin: dropping an unverifiable probe response");
1413 return None;
1414 }
1415 // #1506: `has_blob` and `coverage.is_empty()` are a biconditional by
1416 // construction on an honest responder. Neither field is in the signed
1417 // set (`coverage` is unsigned, and this mismatch has no attributable
1418 // author to slash — same reasoning as an unrecovered `slash_sig` above),
1419 // so a violation is dropped rather than scored to reputation.
1420 if !resp_ext.consistent_with(resp.body.has_blob) {
1421 debug!(
1422 has_blob = resp.body.has_blob,
1423 "node-origin: dropping a probe response with has_blob/coverage mismatch"
1424 );
1425 return None;
1426 }
1427 if !resp.body.has_blob {
1428 // Reachable but does not hold the blob — not a reputation event (no
1429 // delivery attempted); cache the negative so the next lookup for this
1430 // hash skips it within the TTL.
1431 deps.negative_cache
1432 .record_failure(peer, DhtHash::from_bytes(hash_bytes));
1433 return None;
1434 }
1435 let rtt = ms_to_u32(probe_rtt.ms);
1436 // Peer's self-attested region (ADR 030), resolved from the on-chain
1437 // `CapacityBond` registry projection; empty when the registry has no
1438 // region hint for this peer.
1439 let region = crate::dht::capacity_bond_registry::region_of(
1440 &deps.registry_regions,
1441 DhtNodeId::from_bytes(*peer.as_bytes()),
1442 )
1443 .unwrap_or_default();
1444 score_probe_latency(deps, pk, ®ion, rtt, probe_rtt.direct);
1445 Some(Candidate {
1446 node_id: *peer.as_bytes(),
1447 rate_per_mb: resp.body.rate_per_mb,
1448 rtt_ms: rtt,
1449 reputation: peer_reputation(deps, pk),
1450 // Drives the geo-diversity tie-break tier (selection.rs) and the
1451 // latency-vs-claim penalty in `score_probe_latency`.
1452 region,
1453 // No on-chain stake lookup is wired yet (#1470 / ADR 019). `0` is a
1454 // placeholder here, not an observation — which is exactly why tier 2 is
1455 // a uniform no-op until the lookup lands. When it does, a failed read
1456 // must be resolved here (retry, or drop the candidate) rather than
1457 // passed through as `0`; see `Candidate::stake`.
1458 stake: 0,
1459 // The fresh, probe-confirmed coverage (#1506) — never the stale DHT
1460 // hint `discover` drops. The `consistent_with` check above already
1461 // guarantees this is non-empty whenever `has_blob` is true, which is
1462 // the only way execution reaches here.
1463 coverage: resp_ext.coverage,
1464 // The holder's reported blob size. The coverage-union gather (#1506)
1465 // reads it to know how many discovery blocks the union must span,
1466 // and the pull leg cuts its first upstream open from it.
1467 total_bytes_hint: resp_ext.total_bytes,
1468 })
1469}
1470
1471/// Rebuild ranked [`Candidate`]s from a probe-cache hit (ADR 001 §Probe cache).
1472///
1473/// `None` means "no usable cached candidate", covering three cases the caller has
1474/// no reason to distinguish: no entry, an expired entry, and an entry whose every
1475/// provider is currently suppressed. All three cost a fresh lookup + probe and all
1476/// three count as `probe_cache_miss` — a hit that saves no network work is not a
1477/// hit in any sense a dashboard cares about. The ranged pull leg treats a
1478/// returned list whose coverage does not span the blob the same way (#2195): it
1479/// cannot complete the ranged assembly, so it costs the same fresh lookup.
1480///
1481/// The cache stores the ADR triple plus the probe's unsigned `Coverage`. `reputation`
1482/// and `region` are rebuilt here, FRESH, and the result re-ranked. That is what ADR
1483/// 001's "goes straight to selection" means: skip discovery and probing — not skip
1484/// the selection algorithm. Caching a `Candidate` whole would have been less code and would have
1485/// frozen `reputation` for the TTL, letting a node that failed three pulls in
1486/// the meantime keep the rank it earned before them; the fields we decline to
1487/// cache are the ones that MOVE.
1488async fn cached_candidates(deps: &NodeOriginDeps, target: DhtHash) -> Option<Vec<Candidate>> {
1489 let providers = deps.probe_cache.get(&target)?;
1490 let now_secs = crate::payment_settlement::unix_now();
1491 let mut candidates = Vec::with_capacity(providers.len());
1492 for provider in providers {
1493 // An entry can be stale INSIDE its own TTL, so the same two filters
1494 // `probe_and_rank` applies are applied here — this is the other
1495 // chokepoint every candidate passes through. A pull-time refusal recorded
1496 // a negative for this exact (peer, hash) seconds ago
1497 // (`classify_pull_failure`), and a wedged provider is out of ranking for ANY hash.
1498 if deps
1499 .negative_cache
1500 .contains_active(&provider.node_id, &target)
1501 || deps.provider_is_wedged(&provider.node_id, now_secs)
1502 {
1503 continue;
1504 }
1505 // ADR 001 §Probe cache: "verify the selected node_id is still active in
1506 // the local registry cache" before opening `cdn/client/v1`. The cold path
1507 // applies this inside `find_providers` (`filter_active_stakers`); the hit
1508 // path bypasses `find_providers`, so re-apply it here. `addr_resolver` is
1509 // NOT this guard — the address binding survives ejection/unbonding (only
1510 // deregistration clears it), which would otherwise let a node ejected inside the
1511 // TTL win a paid pull the cold path denies (#1223).
1512 //
1513 // NOTE: an origin from a populated `StaticOriginDirectory` (tests) that is
1514 // not a staker is skipped here and falls through to the cold path, where
1515 // that unfiltered fallback re-serves it — such directories get correctness,
1516 // not hit acceleration. Production directories are chain-backed and
1517 // staker-filtered, so their origins are stakers and this check is complete.
1518 if !deps.staker_set.is_active(&provider.node_id) {
1519 continue;
1520 }
1521 let Ok(pk) = PublicKey::from_bytes(provider.node_id.as_bytes()) else {
1522 // Matches `probe_candidate`: a key that does not decode implies
1523 // upstream state corruption — skip rather than panic.
1524 continue;
1525 };
1526 let region =
1527 crate::dht::capacity_bond_registry::region_of(&deps.registry_regions, provider.node_id)
1528 .unwrap_or_default();
1529 // Deliberately NOT re-running the ADR 030 region-latency penalty here,
1530 // unlike `probe_candidate`. That penalty reads a probe's `rtt` as EVIDENCE
1531 // against a self-attested region claim, and we already scored this rtt
1532 // once — at probe time, when it was evidence. A cache hit performs no
1533 // probe and produces no new evidence, so re-recording would charge one
1534 // probe's latency to the peer again on every hit inside the TTL: an EWMA
1535 // beaten down by a single stale sample replayed as many times as the
1536 // blob happens to be requested. Popularity is not guilt.
1537 candidates.push(Candidate {
1538 node_id: *provider.node_id.as_bytes(),
1539 rate_per_mb: provider.rate_per_mb,
1540 rtt_ms: provider.rtt_ms,
1541 reputation: peer_reputation(deps, pk),
1542 region,
1543 // See `probe_candidate` above: `0` is a placeholder, not a lookup.
1544 stake: 0,
1545 // The probe's UNSIGNED coverage, stored in the cache entry alongside
1546 // the ADR 001 triple (#1506). ≤15s fresh (the entry's TTL), so a
1547 // cache-hit candidate is range-planned against a real holder's
1548 // blocks rather than reading as covering nothing. This is not the
1549 // evidence-retention the cache's module doc forbids — coverage is
1550 // outside the `slash_sig` set and has no author to slash.
1551 coverage: provider.coverage.clone(),
1552 total_bytes_hint: provider.total_bytes_hint,
1553 });
1554 }
1555 if candidates.is_empty() {
1556 return None;
1557 }
1558 Some(rank(candidates))
1559}
1560
1561/// What one walk of a ranked candidate list consumed and produced.
1562///
1563/// The `attempts` count is the load-bearing half (#1165). [`MAX_PROVIDER_ATTEMPTS`]
1564/// is a budget for the whole FETCH, not for each list, and a fetch that hits the
1565/// probe cache walks two lists: the cached providers, then — if they all fail — a
1566/// freshly discovered one. Handing each list its own [`MAX_PROVIDER_ATTEMPTS`]
1567/// would double the worst case to six sequential pulls, silently blowing through
1568/// [`crate::selection::outer_pull_deadline`], which budgets for exactly three
1569/// (`(open + pull + stall) × MAX_PROVIDER_ATTEMPTS + slack`, 172s at defaults).
1570/// The deadline is enforced OUTSIDE this path, so nothing would fail loudly: the
1571/// fetch would just be killed mid-pull by a timeout sized for a world it no
1572/// longer lived in — the #859 starvation that formula exists to prevent.
1573///
1574/// So the caller carries a remaining-attempts budget across both phases and this
1575/// reports what was spent.
1576///
1577/// Generic over the payload so both walkers share it: [`try_pull`] returns
1578/// `PullOutcome<Bytes>` (the buffered blob) and the pull leg's candidate walk
1579/// returns the opened leg. A plain type parameter — no future is generic here,
1580/// only the value a successful walk hands back.
1581struct PullOutcome<T> {
1582 /// The delivered payload, or why the walk produced none.
1583 payload: Result<T, PullMiss>,
1584 /// Candidates actually TRIED — i.e. per-candidate attempts, whether or
1585 /// not they delivered. Never exceeds the `budget` passed in.
1586 attempts: usize,
1587 /// The tried candidates that refused this node's funding
1588 /// ([`PullMiss::Unfunded`]): the ones a funding recovery step asks again.
1589 unfunded: Vec<Candidate>,
1590}
1591
1592/// Why an attempt — one candidate, or a whole walk of them — produced no payload
1593/// (#1560).
1594///
1595/// The only distinction the callers need is whether the failure was OURS, because
1596/// that is what decides the answer this node signs to its own client. Collapsing
1597/// every non-hit onto a clean `NotFound` — no providers, all refused, all stalled,
1598/// and a LOCAL fault alike — makes a false statement about the content in the last
1599/// case: the blob may well exist and be perfectly reachable; it is
1600/// this node that cannot sign a voucher for it. A broken node answers `Declined`
1601/// rather than launder its own defect into a signed claim about content.
1602///
1603/// Deliberately NOT a per-verdict taxonomy. The crate-private `PullVerdict`
1604/// already carries the full one, and it answers a different question ("what does
1605/// this failure say about the PEER?"); this answers "what may we tell the
1606/// client?". Only its `OurLocalFault` arm changes that answer: a wedged lane to
1607/// ONE provider is not node-wide degradation, and it already has its
1608/// own remedy (keep the row, suppress the provider).
1609///
1610/// `#[must_use]`: a classifier's verdict dropped rather than propagated silently
1611/// reintroduces the #1560 mis-attribution at that call site — this node's own
1612/// fault signed to a client as an absent blob. Dropping a `PullVerdict` is
1613/// legitimate at the two
1614/// mid-stream sites (the answer is already on the wire) and they say so with `let _ =`,
1615/// which satisfies this attribute; dropping a `PullMiss` never is.
1616#[must_use]
1617#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1618pub enum PullMiss {
1619 /// Nothing about the failure blames this node — the honest empty answer, and
1620 /// the one a wire `NotFound` is true of.
1621 Clean,
1622 /// The attempt failed on a fault in THIS node: a broken signer, a bad
1623 /// encode/range, an unusable deadline config, an upstream that could not
1624 /// VERIFY a signature we produced, or a cache store that could not take the
1625 /// bytes. Says nothing about whether the content exists.
1626 LocalFault,
1627 /// Candidates existed but every one quoted above the serve-economics buy
1628 /// ceiling; the node declined an unprofitable relay. Wire-identical to
1629 /// [`Self::Clean`].
1630 BelowMargin,
1631 /// This node's own wallet cannot fund a buyer pool, so it can pay no
1632 /// provider and the fill ends "funding needed". The funding problem is this
1633 /// node's, not the requester's, so it is wire-identical to [`Self::Clean`]
1634 /// (ADR 005 §Open-time refusal classes).
1635 FundingNeeded,
1636 /// The candidate refused this node's funding: an `Unfunded` refusal, a
1637 /// funding voucher rejection, or this node's pool short of the next
1638 /// voucher. The refusal scopes to that candidate; a walk that delivers
1639 /// nothing runs its funding recovery step for such candidates (ADR 003
1640 /// § Funding recovery). Wire-identical to [`Self::Clean`].
1641 Unfunded,
1642}
1643
1644impl PullMiss {
1645 /// The `outcome` value an `upstream_stream` span records for this miss, and
1646 /// the `cause` a serve-miss refusal's log line records.
1647 pub(crate) const fn as_str(self) -> &'static str {
1648 match self {
1649 Self::Clean => "clean_miss",
1650 Self::LocalFault => "local_fault",
1651 Self::BelowMargin => "below_margin",
1652 Self::FundingNeeded => "funding_needed",
1653 Self::Unfunded => "unfunded",
1654 }
1655 }
1656
1657 /// Which miss a classified failure is.
1658 ///
1659 /// Exhaustive on purpose, like [`classify_refusal`] and [`voucher_verdict`]: a
1660 /// new [`PullVerdict`] must DECIDE whether it is honest enough to sign a
1661 /// `NotFound` over, rather than inheriting [`Self::Clean`] by falling into a
1662 /// catch-all — which is exactly how the local-fault case went unnoticed.
1663 // `match_same_arms`: the `Refused(OurFault)` arm reaches `Clean` like the block above it,
1664 // but for an unrelated reason — node-wide-but-not-broken, rather than not-about-this-node.
1665 // Merging them would erase the only rationale that makes a future variant's arm
1666 // decidable, which is the same trade `classify_refusal` makes for the same reason.
1667 #[allow(clippy::match_same_arms)]
1668 const fn for_verdict(verdict: PullVerdict) -> Self {
1669 match verdict {
1670 PullVerdict::OurLocalFault => Self::LocalFault,
1671 // Every other verdict is either about the peer (`Refused` of the first three
1672 // kinds, `Stalled`, `RateLimited`, `Corruption`, `Unreachable`), about OUR
1673 // configuration of what we will accept from it (`Oversize`, `RateCeiling`,
1674 // `OurDeadline`), or about one lane to one provider (the two voucher arms).
1675 // None of them is evidence that THIS node is broken for every client and
1676 // every blob, so none earns a `Declined`: a node with one wedged lane
1677 // is still a healthy node that simply cannot serve this blob right now.
1678 PullVerdict::Oversize
1679 | PullVerdict::RateCeiling
1680 | PullVerdict::OurDeadline
1681 | PullVerdict::Stalled
1682 | PullVerdict::RateLimited
1683 | PullVerdict::OurDeadLane(_)
1684 | PullVerdict::OurVoucherRetryable(_)
1685 | PullVerdict::Refused(RefusalVerdict::DurableMiss | RefusalVerdict::Transient)
1686 | PullVerdict::Corruption
1687 | PullVerdict::LegNoProgress
1688 | PullVerdict::Unreachable => Self::Clean,
1689 // A defensive backstop: `pull_verdict` unwraps a `VoucherRejected` to
1690 // `voucher_verdict` first, so this arm is not reached in practice. A
1691 // rejected voucher is about one lane, not this node.
1692 PullVerdict::Refused(RefusalVerdict::OurFault) => Self::Clean,
1693 }
1694 }
1695
1696 /// Fold another attempt's miss into this one: a local fault LATCHES, and an
1697 /// economics refusal outranks a clean miss.
1698 ///
1699 /// A walk reports a local fault if ANY of its attempts hit one, mirroring the
1700 /// serve path's `fault_seen` latch — a later candidate's clean miss must not
1701 /// overwrite an earlier fault of ours, or the walk reports a degraded node as a
1702 /// merely-empty one. The fold runs on every failure, but its result only ever
1703 /// DECIDES anything when nothing delivered: a candidate that faults locally
1704 /// followed by one that succeeds is a plain success, and the walk returns the
1705 /// payload without consulting the latch at all.
1706 ///
1707 /// Below a local fault, a [`Self::BelowMargin`] outranks a [`Self::Clean`] the
1708 /// same way, for the same reason at one rung down: it preserves the "an
1709 /// economics refusal happened somewhere in this walk" signal so the buy loop
1710 /// can tell a below-margin miss apart from a genuinely empty one, rather than
1711 /// letting a later candidate's honest miss erase the fact that an earlier one
1712 /// quoted above this node's buy ceiling. A [`Self::FundingNeeded`] sits
1713 /// between the two: every attempt hits the same wallet, so it names the walk
1714 /// whatever an attempt's quote was. An [`Self::Unfunded`] candidate ranks
1715 /// just below it: the walk's funding recovery step decides whether it
1716 /// becomes funding needed.
1717 const fn or(self, other: Self) -> Self {
1718 match (self, other) {
1719 (Self::LocalFault, _) | (_, Self::LocalFault) => Self::LocalFault,
1720 (Self::FundingNeeded, _) | (_, Self::FundingNeeded) => Self::FundingNeeded,
1721 (Self::Unfunded, _) | (_, Self::Unfunded) => Self::Unfunded,
1722 (Self::BelowMargin, _) | (_, Self::BelowMargin) => Self::BelowMargin,
1723 (Self::Clean, Self::Clean) => Self::Clean,
1724 }
1725 }
1726
1727 /// Whether this miss is a fault in this node.
1728 pub const fn is_local_fault(self) -> bool {
1729 matches!(self, Self::LocalFault)
1730 }
1731}
1732
1733/// The ADR 041 buy-side economic decision for one ranked candidate.
1734enum EconGate {
1735 /// The candidate's quote is above this node's buy ceiling: skip it. The walk
1736 /// records the skip as a [`PullMiss::BelowMargin`], so an exhausted walk whose
1737 /// only failures were economic answers `NotFound` — distinct from a clean
1738 /// no-provider miss for observability, identical on the wire.
1739 Skip,
1740 /// The candidate is buyable. `rate_ceiling` is the effective per-MB ceiling to
1741 /// enforce on the stream (the lower of the candidate's probe rate, the static
1742 /// `max_rate_per_mb`, and — under the `margin` policy — the economic buy cap, so
1743 /// a bait-and-switch above the cap aborts mid-pull). `speculative` is true when
1744 /// the quote sits above the amortized profit-guaranteed floor: a successful pull
1745 /// then debits the source's warming allowance by the full buy cost.
1746 Allow {
1747 rate_ceiling: u64,
1748 speculative: bool,
1749 },
1750}
1751
1752/// The ADR 041 buy ceiling for one candidate: derive this node's sell rate and the
1753/// policy's two-regime buy cap, fold the cap into the effective stream ceiling, and
1754/// decide whether to buy at all.
1755///
1756/// `source` is the candidate's node id — the warming-allowance key. `heat` is the
1757/// ADR 040 frequency estimate for the hash, computed once per miss. `candidate_rate`
1758/// is the probe-advertised per-MB rate.
1759///
1760/// With `OffPolicy` (no economic gate) the only bound is the static
1761/// `max_rate_per_mb`, and no allowance is touched (never speculative). Under
1762/// `MarginPolicy` the cap is `max(sell, amortized)` while the source has warming
1763/// allowance, else the amortized floor; a quote above the cap is refused, and a
1764/// quote merely above the amortized floor is allowed but flagged speculative.
1765fn economic_ceiling(
1766 deps: &NodeOriginDeps,
1767 source: crate::warming_allowance::SourceId,
1768 heat: u32,
1769 candidate_rate: u64,
1770) -> EconGate {
1771 let sell = deps.config.sell_rate_base;
1772 let operator_bps = deps.config.operator_shares.bps();
1773 let warm = deps.config.warming.available(source);
1774 // A short, stable identifier for the upstream source, computed only in the
1775 // cold telemetry branches below (not on every candidate).
1776 let source_hex = || -> String {
1777 use std::fmt::Write as _;
1778 source
1779 .as_bytes()
1780 .iter()
1781 .take(8)
1782 .fold(String::with_capacity(16), |mut acc, b| {
1783 let _ = write!(acc, "{b:02x}");
1784 acc
1785 })
1786 };
1787 // A spent per-source allowance downgrades this source from the market-price
1788 // (warming) ceiling to the amortized floor. Count it only when warming is
1789 // configured, so a node that runs warming off does not report every source
1790 // as griefed down.
1791 if !warm && deps.config.warming.enabled() {
1792 deps.metrics.warming_speculative_blocked();
1793 debug!(
1794 source = %source_hex(),
1795 "node-origin: warming allowance spent for source; buying at amortized floor only"
1796 );
1797 }
1798 let mk = |warming_available| crate::serve_economics::ServeEconomicsCtx {
1799 sell_rate_per_mb: sell,
1800 operator_bps,
1801 heat_estimate: heat,
1802 warming_available,
1803 };
1804 // `ceiling` is the actual buy cap under the live warming regime; `amortized_floor`
1805 // is the profit-guaranteed price (the spent-allowance regime). A buy above the
1806 // floor is speculative and debits the source's allowance on success.
1807 let ceiling = deps.config.serve_economics.max_buy_per_mb(&mk(warm));
1808 let amortized_floor = deps.config.serve_economics.max_buy_per_mb(&mk(false));
1809 match ceiling {
1810 // No economic gate: only the static ceiling applies, no allowance interaction.
1811 None => EconGate::Allow {
1812 rate_ceiling: effective_rate_ceiling(candidate_rate, deps.config.max_rate_per_mb),
1813 speculative: false,
1814 },
1815 Some(max_buy) if candidate_rate > max_buy => {
1816 let regime = if warm {
1817 crate::metrics::ServeEconomicsRegime::Warming
1818 } else {
1819 crate::metrics::ServeEconomicsRegime::Amortized
1820 };
1821 deps.metrics.serve_economics_refused(regime);
1822 debug!(
1823 source = %source_hex(),
1824 candidate_rate,
1825 max_buy,
1826 ?regime,
1827 heat,
1828 "node-origin: candidate rate above serve-economics ceiling; declining relay"
1829 );
1830 EconGate::Skip
1831 }
1832 Some(max_buy) => {
1833 // Fold `max_buy` into the pull-commit ceiling so a bait-and-switch above
1834 // the cap aborts mid-pull, then take the lower of that and the quote.
1835 let bound = effective_rate_ceiling(deps.config.max_rate_per_mb, max_buy);
1836 let speculative = candidate_rate > amortized_floor.unwrap_or(candidate_rate);
1837 EconGate::Allow {
1838 rate_ceiling: effective_rate_ceiling(candidate_rate, bound),
1839 speculative,
1840 }
1841 }
1842 }
1843}
1844
1845/// Bytes to whole MB (round up), the unit the warming allowance accounts in.
1846const fn mb_of(bytes: u64) -> u64 {
1847 bytes.div_ceil(decdn_protocol::MB_BYTES)
1848}
1849
1850/// The ADR 040 frequency estimate for `hash_bytes` — the `margin` policy's heat
1851/// input. `0` (cold) when no estimator is wired.
1852fn heat_of(deps: &NodeOriginDeps, hash_bytes: [u8; 32]) -> u32 {
1853 deps.config
1854 .frequency_estimator
1855 .as_ref()
1856 .map_or(0, |e| e.estimate(Hash::from(hash_bytes)))
1857}
1858
1859/// Walk the ranked candidates (best-first), opening a channel and pulling from
1860/// each until one delivers, bounded by `budget` remaining attempts. Records a
1861/// reputation outcome for every candidate that reaches the wire;
1862/// candidates skipped earlier for an unresolvable operator address or a local
1863/// channel-open failure are intentionally not scored (neither is the provider's
1864/// fault). `budget` is the fetch-wide [`MAX_PROVIDER_ATTEMPTS`] remainder rather
1865/// than the constant itself (see [`PullOutcome`]). Every verified byte counts
1866/// toward the fill's funding recovery `gate`.
1867async fn try_pull(
1868 deps_lock: &Arc<OnceLock<NodeOriginDeps>>,
1869 deps: &NodeOriginDeps,
1870 ranked: &[Candidate],
1871 hash_bytes: [u8; 32],
1872 budget: usize,
1873 gate: &Arc<RecoveryGate>,
1874) -> PullOutcome<()> {
1875 let mut attempts = 0;
1876 let mut miss = PullMiss::Clean;
1877 let mut unfunded = Vec::new();
1878 for candidate in ranked.iter().take(budget) {
1879 attempts += 1;
1880 match pull_from_candidate(deps_lock, deps, candidate, hash_bytes, gate).await {
1881 Ok(()) => {
1882 return PullOutcome {
1883 payload: Ok(()),
1884 attempts,
1885 unfunded,
1886 };
1887 }
1888 Err(failed) => {
1889 if failed == PullMiss::Unfunded {
1890 unfunded.push(candidate.clone());
1891 }
1892 miss = miss.or(failed);
1893 }
1894 }
1895 }
1896 PullOutcome {
1897 payload: Err(miss),
1898 attempts,
1899 unfunded,
1900 }
1901}
1902
1903/// The end of a fill whose walks delivered nothing: when some candidates
1904/// refused this node's funding, run the fill's funding recovery step (ADR 003
1905/// § Funding recovery) and ask those candidates once more, else answer `miss`.
1906///
1907/// The first step of a fill is always allowed; a further step only after a
1908/// byte verified since the last one. A step that tops the pool up opens the
1909/// gate's settle window: a candidate that still refuses inside it is the
1910/// upstream's chain watcher lagging the new deposit, so the fill waits a
1911/// [`SETTLE_POLL_STEP`] and asks again instead of taking another step. A fill
1912/// that can take no step ends "funding needed" ([`PullMiss::FundingNeeded`]),
1913/// which still answers the client `NotFound`.
1914async fn recover_unfunded(
1915 deps_lock: &Arc<OnceLock<NodeOriginDeps>>,
1916 deps: &NodeOriginDeps,
1917 hash_bytes: [u8; 32],
1918 mut unfunded: Vec<Candidate>,
1919 mut miss: PullMiss,
1920 gate: &Arc<RecoveryGate>,
1921 mut seen: U256,
1922) -> Result<OriginFetch, OriginPullError> {
1923 while !unfunded.is_empty() {
1924 if gate.settling(tokio::time::Instant::now()) {
1925 tokio::time::sleep(SETTLE_POLL_STEP).await;
1926 } else {
1927 match node_step(gate, deps, seen, unfunded.len()).await {
1928 Ok(deposit) => seen = deposit,
1929 Err(end) => return miss_answer(miss.or(end)),
1930 }
1931 }
1932 let outcome = try_pull(deps_lock, deps, &unfunded, hash_bytes, unfunded.len(), gate).await;
1933 match outcome.payload {
1934 Ok(()) => return Ok(OriginFetch::AlreadyAdmitted),
1935 Err(failed) => miss = miss.or(failed),
1936 }
1937 unfunded = outcome.unfunded;
1938 }
1939 miss_answer(miss)
1940}
1941
1942/// The content bytes `store` holds of its `total_bytes` blob, or `None` when
1943/// the store cannot answer.
1944async fn held_bytes(store: &NodeAdmitStore, total_bytes: u64) -> Option<u64> {
1945 use decdn_client::RangedStore as _;
1946 let missing = store.missing_ranges(0, total_bytes).await.ok()?;
1947 let chunk = |n: bao_tree::ChunkNum| n.0.saturating_mul(1024).min(total_bytes);
1948 let mut bounds = missing.boundaries().iter().copied();
1949 let mut missing_bytes = 0u64;
1950 while let Some(start) = bounds.next() {
1951 let end = bounds.next().map_or(total_bytes, chunk);
1952 missing_bytes = missing_bytes.saturating_add(end.saturating_sub(chunk(start)));
1953 }
1954 Some(total_bytes.saturating_sub(missing_bytes))
1955}
1956
1957/// Run one funding recovery step of a fill under `gate`, for `candidates`
1958/// candidates that refused this node's funding. `seen` is the deposit the fill
1959/// last saw. A pool row above it is a sibling fill's step: the gate takes it as
1960/// raised and runs no step of its own. `Ok` is the deposit the fill sees next;
1961/// `Err` names the fill's end: no step allowed or possible, or a step that
1962/// failed.
1963pub(super) async fn node_step(
1964 gate: &RecoveryGate,
1965 deps: &NodeOriginDeps,
1966 seen: U256,
1967 candidates: usize,
1968) -> Result<U256, PullMiss> {
1969 let funder = NodeFunder::new(Arc::clone(&deps.buyer), Arc::clone(&deps.metrics), seen);
1970 // The node's step sizes itself from its pool row, so the spend it is
1971 // handed is not read.
1972 let current = || deps.buyer.pool_deposit().unwrap_or(seen);
1973 // One fill runs its steps one after another, so it has seen every top-up
1974 // its gate took; a sibling fill's step reaches it through the pool row.
1975 let mut top_ups = gate.top_ups();
1976 match gate
1977 .step(&funder, seen, &mut top_ups, current, U256::ZERO)
1978 .await
1979 {
1980 Stepped::Raised(deposit) => Ok(deposit),
1981 Stepped::Replaced(replaced) => {
1982 info!(
1983 closed = %replaced.closed,
1984 opened = %replaced.opened,
1985 "node-origin: the buyer pool accepts no more funds; a new pool replaces it"
1986 );
1987 gate.start_next_pass();
1988 Ok(deps.buyer.pool_deposit().unwrap_or(seen))
1989 }
1990 end @ (Stepped::NoProgress | Stepped::Unavailable) => {
1991 info!(
1992 candidates,
1993 why = no_step_why(&end),
1994 "node-origin: no candidate serves at this node's funding; the fill ends funding \
1995 needed"
1996 );
1997 Err(PullMiss::FundingNeeded)
1998 }
1999 Stepped::Failed(err) => {
2000 warn!(
2001 error = %sanitize_err_chain(&err),
2002 "node-origin: the funding recovery step failed"
2003 );
2004 Err(PullMiss::LocalFault)
2005 }
2006 }
2007}
2008
2009/// Why a fill's funding recovery step let no candidate be asked again: the
2010/// progress rule allowed no step, or the step could not add funds (which the
2011/// funder counted as `node_pull_recovery_step_refused`).
2012const fn no_step_why(stepped: &Stepped) -> &'static str {
2013 if matches!(stepped, Stepped::NoProgress) {
2014 "no byte was verified since the last recovery step"
2015 } else {
2016 "the recovery step could not add funds"
2017 }
2018}
2019
2020/// Attach this node's ADR 005 client identity binding to an upstream pull's
2021/// `PoolContext` (#1117). Signs over our OWN endpoint `NodeId` with the
2022/// channel's buyer key (`ctx.client_signer`) under the `CapacityBond` bind
2023/// domain, so the upstream can prove we own the named channel and, on its own
2024/// cache miss, chain a further reactive origin pull (`pull_authorized`). A
2025/// signing failure drops the candidate rather than sending an unbound request
2026/// the upstream would refuse to chain — try the next provider instead.
2027///
2028/// Returns the error rather than swallowing it, so the caller can hand it to
2029/// [`classify_pull_failure`]. `sign_client_binding` marks it [`LocalPullFault`], and
2030/// that classification only means anything if the error actually reaches the classifier:
2031/// this signs with `ctx.client_signer` — the SAME key that signs vouchers — and runs
2032/// BEFORE the stream open on both pull paths. So a node with a broken buyer key fails
2033/// here, on every candidate, and never reaches the voucher-signing `LocalPullFault` deeper
2034/// in the paid-pull requester's voucher leg (`UpstreamPull::pay_one`). Swallowed here, `node_pull_local_fault` stayed at zero in
2035/// precisely the emergency its doc describes ("a node that cannot sign a voucher cannot
2036/// pay for anything"), and an operator alerting on it got a false all-clear.
2037fn bind_upstream_ctx(deps: &NodeOriginDeps, ctx: PoolContext) -> anyhow::Result<PoolContext> {
2038 let own_node_id = B256::from(*deps.endpoint.id().as_bytes());
2039 let binding = sign_client_binding(&ctx.client_signer, own_node_id, &deps.bind_domain)?;
2040 Ok(ctx.with_client_binding(binding))
2041}
2042
2043/// The voucher ledger this pull must issue through: the LANE's, shared with every other
2044/// concurrent pull on it — never a fresh one per pull.
2045///
2046/// One call site per pull path, so neither can quietly go back to minting its own. Both did,
2047/// and the caller-owned-ledger entrypoints exist precisely
2048/// because that is broken: N concurrent pulls each seeded from `ctx.prior_*` all sign the
2049/// same next cumulative watermark and collide, the upstream accepts one and rejects the rest
2050/// as a regression. `ctx.prior_*` is only a SEED — it loses to a live ledger, which is at
2051/// least as far along as the row it was read from. See [`BuyerLedgers`].
2052fn lane_ledger(
2053 deps: &NodeOriginDeps,
2054 provider_addr: Address,
2055 ctx: &PoolContext,
2056) -> Arc<PoolLedger> {
2057 deps.ledgers.get_or_seed(
2058 decdn_incentive::LaneKey {
2059 pool_id: ctx.pool_id,
2060 signer: ctx.client_signer.address(),
2061 provider: provider_addr,
2062 },
2063 Cumulative {
2064 bytes: ctx.prior_bytes_delivered,
2065 amount: ctx.prior_amount,
2066 },
2067 )
2068}
2069
2070/// Attempt a single paid pull from one candidate: resolve its operator address,
2071/// open/reuse a buyer channel, and stream the whole blob into `deps.engine` via
2072/// the gap-driven [`drive`] loop, recording the reputation outcome. On success
2073/// the blob is admitted and durable in the store — no whole-blob buffer is ever
2074/// held in RAM (#1682). Returns the [`PullMiss`] this failure is on a miss (try
2075/// the next candidate either way — a local fault latches, it does not abort the
2076/// walk, #1560).
2077///
2078/// Runs inside an `upstream_stream` span. Its `hash`, `pool_id`, `peer` and
2079/// `local_node_id` fields match the upstream's `serve_stream` span field for
2080/// field (with `peer` and `local_node_id` swapped), so one trace query joins
2081/// the two sides of the transfer across nodes. Each gap the pull opens is a
2082/// child `open_progressive_pull` span that adds the gap's `byte_offset` and
2083/// `byte_len`.
2084async fn pull_from_candidate(
2085 deps_lock: &Arc<OnceLock<NodeOriginDeps>>,
2086 deps: &NodeOriginDeps,
2087 candidate: &Candidate,
2088 hash_bytes: [u8; 32],
2089 gate: &Arc<RecoveryGate>,
2090) -> Result<(), PullMiss> {
2091 let span = tracing::info_span!(
2092 "upstream_stream",
2093 otel.kind = "client",
2094 direction = "outbound",
2095 peer = %DhtNodeId::from_bytes(candidate.node_id),
2096 local_node_id = %deps.endpoint.id(),
2097 hash = %DhtHash::from_bytes(hash_bytes),
2098 pool_id = tracing::field::Empty,
2099 outcome = tracing::field::Empty,
2100 );
2101 let opened = AtomicBool::new(false);
2102 let result = pull_from_candidate_in_span(deps_lock, deps, candidate, hash_bytes, &opened, gate)
2103 .instrument(span.clone())
2104 .await;
2105 // Only a candidate the pull opened a stream to has a stream outcome; one
2106 // skipped before that (no address, above the buy ceiling, a pool-open
2107 // failure) is not a failed stream.
2108 if opened.load(Ordering::Relaxed) {
2109 deps.metrics.outbound_stream_ended(result.is_ok());
2110 }
2111 span.record(
2112 "outcome",
2113 match result {
2114 Ok(()) => "filled",
2115 Err(miss) => miss.as_str(),
2116 },
2117 );
2118 result
2119}
2120
2121/// The body of [`pull_from_candidate`], run inside its span.
2122// Sequential resolve → open → fetch → classify pipeline; the tracing macros and
2123// the success/failure classification inflate the cognitive-complexity + line
2124// metrics past threshold (same inflation noted in `chain_staker_set`). Splitting
2125// it would scatter a single linear flow across helpers.
2126#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
2127async fn pull_from_candidate_in_span(
2128 deps_lock: &Arc<OnceLock<NodeOriginDeps>>,
2129 deps: &NodeOriginDeps,
2130 candidate: &Candidate,
2131 hash_bytes: [u8; 32],
2132 opened: &AtomicBool,
2133 gate: &Arc<RecoveryGate>,
2134) -> Result<(), PullMiss> {
2135 let Ok(pk) = PublicKey::from_bytes(&candidate.node_id) else {
2136 return Err(PullMiss::Clean);
2137 };
2138 let Some(provider_addr) = deps
2139 .addr_resolver
2140 .address_of(&DhtNodeId::from_bytes(candidate.node_id))
2141 else {
2142 // No bonded address → we cannot safely open a channel to, or verify the
2143 // `slash_sig` of, this provider. Skip rather than guess.
2144 debug!("node-origin: candidate has no resolvable operator address; skipping");
2145 return Err(PullMiss::Clean);
2146 };
2147 // ADR 041 buy-side gate: refuse a candidate quoting above this node's buy ceiling
2148 // BEFORE opening a channel. A skip folds into the walk as `BelowMargin`.
2149 let heat = heat_of(deps, hash_bytes);
2150 let (rate_ceiling, speculative) =
2151 match economic_ceiling(deps, candidate.node_id.into(), heat, candidate.rate_per_mb) {
2152 EconGate::Allow {
2153 rate_ceiling,
2154 speculative,
2155 } => (rate_ceiling, speculative),
2156 EconGate::Skip => return Err(PullMiss::BelowMargin),
2157 };
2158 let ctx = match deps
2159 .buyer
2160 // Same channel-open bound as the window path (#1143) — see there.
2161 .open_or_reuse_pool(provider_addr, crate::selection::CHANNEL_OPEN_CALLER_BUDGET)
2162 .await
2163 {
2164 Ok(ctx) => ctx,
2165 Err(err) => {
2166 // A channel-open failure is OUR payment-side problem, not the
2167 // provider's fault — don't tar its reputation; just try the next.
2168 // Whether it is also a fault that must change our WIRE answer is a
2169 // per-reason question the classifier owns (#1560).
2170 return Err(record_pool_open_failure(deps, provider_addr, &err));
2171 }
2172 };
2173 // #1117: bind the request so the upstream can chain a reactive pull.
2174 let ctx = match bind_upstream_ctx(deps, ctx) {
2175 Ok(ctx) => ctx,
2176 Err(err) => {
2177 // As on the window path: our signing fault, metered as ours, peer unscored.
2178 // No channel: the bind failed before we could present a voucher on one.
2179 let verdict = classify_pull_failure(deps, pk, provider_addr, hash_bytes, None, &err);
2180 return Err(PullMiss::for_verdict(verdict));
2181 }
2182 };
2183 tracing::Span::current().record("pool_id", tracing::field::display(ctx.pool_id));
2184 let started = Instant::now();
2185 // The ledger is CALLER-owned, and the watermark is settled from it by a `Drop`
2186 // guard ([`SettleOnDrop`]) rather than by a copy-back after the await (#1145
2187 // review). Both halves of that matter.
2188 //
2189 // A `&mut VoucherProgress` out-param can only be copied back on a RETURN, and
2190 // this pull's defining property is that it need not return: it runs
2191 // with `hard_cap: None`, so nothing inside it ends a slow-but-progressing
2192 // transfer, and everything that does end one is external and DROPS the future —
2193 // the foreground `outer_pull_deadline`, or the serve future being dropped (client
2194 // disconnect, node shutdown). On every one of those paths the copy-back would never run
2195 // and the acked watermark would die with the frame, while the USDC it recorded
2196 // has already left the node. The next pull would then re-sign a cumulative
2197 // watermark the upstream has already advanced past, the upstream would reject it
2198 // as a regression, and the lane would wedge.
2199 //
2200 // The settle guard lives on the PULL THREAD, not here (see the `spawn_blocking`
2201 // block below): `drive` advances the shared lane `PoolLedger` from that thread,
2202 // and a cooperative cancel drops the `drive` future there, so the guard must
2203 // persist the FINAL watermark AFTER the drive has fully stopped advancing it.
2204 // Settling from this outer future instead would race the still-running thread and
2205 // persist a STALE cumulative, wedging the lane exactly as the copy-back did. So
2206 // nothing pre-`drive` on this outer future settles: the channel-open, bind, and
2207 // header handshake below issue no voucher.
2208 // As on the window path: a zero budget is our own misconfiguration, metered as ours.
2209 // Checked BEFORE the ledger, so a pull that cannot legally run never reaches the
2210 // wire and has nothing to settle.
2211 let deadlines = match deps.config.deadlines() {
2212 Ok(deadlines) => deadlines,
2213 Err(err) => {
2214 let verdict = classify_pull_failure(deps, pk, provider_addr, hash_bytes, None, &err);
2215 return Err(PullMiss::for_verdict(verdict));
2216 }
2217 };
2218 let ledger = lane_ledger(deps, provider_addr, &ctx);
2219
2220 // Streaming is bounded by INACTIVITY, with no overall wall-clock cap (#1134).
2221 //
2222 // An overall `pull_timeout` around this whole fetch would quietly cap the blob
2223 // size a node can pull through at roughly `pull_timeout × link speed` — at
2224 // the 20 s default, any blob needing more than ~20 s of transfer would be
2225 // unfetchable on this path. The stall bound catches the thing a deadline
2226 // should catch (an upstream that stops delivering) without penalising size or
2227 // link speed.
2228 //
2229 // The FOREGROUND serve path is still bounded — the delivery handler wraps the
2230 // whole `discover → probe → rank → pull` in `outer_pull_deadline`, so a client
2231 // never waits longer than that; on expiry it gets a clean miss and nothing
2232 // continues (#1610 removed the detached warm). So "no hard cap here" does not mean
2233 // "a client can wait forever".
2234 let _stream_guard = deps.metrics.outbound_stream_guard();
2235 opened.store(true, Ordering::Relaxed);
2236
2237 // Header handshake: a whole-tail open to read the committed `total_bytes`.
2238 // The drive below pulls the whole blob, so when this node holds none of it the
2239 // drive's first leg is this same open, and it adopts the pull (#2063) rather
2240 // than the upstream serving a throwaway open and then the same range again.
2241 // `NO_NAMESPACE`, as this hash-only populate path carries no served-client
2242 // namespace. `rate_ceiling` folds the ADR 041 buy cap in (computed above with
2243 // the skip decision). Its dial runs on the node's main runtime, so its
2244 // connection's driver lives there and outlives the pull thread that adopts it.
2245 let handshake_source = PeerSource::new(
2246 &deps.endpoint,
2247 EndpointAddr::new(pk),
2248 Arc::new(Mutex::new(ctx.clone())),
2249 Arc::clone(&ledger),
2250 &deps.slash_domain,
2251 provider_addr,
2252 NO_NAMESPACE,
2253 deps.config.max_blob_size_bytes,
2254 rate_ceiling,
2255 deadlines,
2256 // A pull leg runs on a per-serve runtime, so it dials per leg on the
2257 // long-lived dial runtime and shares no connection map (#1675).
2258 None,
2259 )
2260 .with_dial_runtime(deps.dial_runtime.clone());
2261 let handshake = timed_open(
2262 handshake_source.open_whole(hash_bytes),
2263 Arc::clone(&deps.metrics),
2264 );
2265 let (header, whole) = match handshake.await {
2266 Ok(pair) => pair,
2267 Err(err) => {
2268 // No bytes pulled, no voucher paid — nothing to settle.
2269 return Err(candidate_miss(
2270 deps,
2271 pk,
2272 provider_addr,
2273 hash_bytes,
2274 ctx.pool_id,
2275 &err,
2276 ));
2277 }
2278 };
2279 // The drive adopts the pull only while it is fresh, measured from here: the
2280 // thread and runtime below start before the drive's first open.
2281 let opened_at = tokio::time::Instant::now();
2282 let total_bytes = header.total_bytes;
2283
2284 // Run `drive()` on a dedicated blocking-pool thread with its own current-thread
2285 // runtime, exactly as the window-paced serve-miss pull leg does
2286 // (`node_origin::pull_leg::run_pull_leg`). `drive`'s future is non-`Send`
2287 // categorically — `IngestStore::ingest_stream` is a
2288 // return-position-impl-trait-in-trait with no `Send` bound, for any
2289 // `R: BaoRangeReader` (see `decdn_client::source::IngestStore`'s docs) — which
2290 // `Origin::fetch`'s `+ Send` trait bound forbids inline. Every axis is therefore
2291 // captured OWNED: no `FillSession` (no serve leg reads beside a populate) and no
2292 // window/leech pacer (this tier has no downstream paid frontier to pace against, so
2293 // it pulls the whole blob under a plain [`BudgetPacer`]).
2294 //
2295 // Cancellation, settle and the dial runtime mirror `run_pull_leg`, and each is
2296 // load-bearing:
2297 //
2298 // - CANCELLATION. `spawn_blocking` tasks are never aborted when their `JoinHandle`
2299 // is dropped, so a bare thread would keep pulling and PAYING vouchers for a blob
2300 // nobody awaits once the `fetch` future is dropped (`outer_pull_deadline` expiry,
2301 // client disconnect, node shutdown — #1610). The outer future holds a
2302 // `cancel.drop_guard()`, so dropping it cancels the token; the pull thread runs
2303 // `drive` under a `select!` against `cancel.cancelled()` and stops.
2304 // - ON-THREAD SETTLE. `drive` advances the shared lane `PoolLedger` from the pull
2305 // thread, so the [`SettleOnDrop`] guard lives THERE, holding the shared deps
2306 // `Arc`, and persists the FINAL watermark (#852) only after `drive` has fully
2307 // stopped advancing it. Settling from the outer future would race the still-running
2308 // thread and persist a STALE cumulative, wedging the lane.
2309 // - DIALS ON THE MAIN RUNTIME. The pull thread's runtime drops when the thread
2310 // returns, so every upstream dial runs on `deps.dial_runtime` instead. Each
2311 // connection's QUIC driver lives there and carries a clean finish, a cancel or
2312 // an error through its close to draining, so the node's `Endpoint::close()`
2313 // never waits on a connection whose driver died with this runtime.
2314 let hash = Hash::from(hash_bytes);
2315 let endpoint = deps.endpoint.clone();
2316 let dial_runtime = deps.dial_runtime.clone();
2317 let slash_domain = deps.slash_domain.clone();
2318 let engine = deps.engine.clone();
2319 let metrics = Arc::clone(&deps.metrics);
2320 let ledger_for_drive = Arc::clone(&ledger);
2321 let deps_for_thread = Arc::clone(deps_lock);
2322 let max_blob_size_bytes = deps.config.max_blob_size_bytes;
2323 // Every newly verified byte counts toward the fill's funding recovery gate.
2324 let gate_for_thread = Arc::clone(gate);
2325 // The outer `fetch`-side future owns the drop guard: dropping this future (deadline
2326 // expiry / disconnect / shutdown) cancels the token, which the pull thread selects
2327 // on to stop the drive.
2328 let cancel = CancellationToken::new();
2329 let cancel_for_thread = cancel.clone();
2330 let _cancel_guard = cancel.drop_guard();
2331 // The pull runs on its own thread and runtime, which starts with no span.
2332 // Carry the caller's span across so the pull's events stay in its trace.
2333 let pull_span = tracing::Span::current();
2334 let join = tokio::task::spawn_blocking(move || {
2335 let rt = tokio::runtime::Builder::new_current_thread()
2336 .enable_all()
2337 .build()?;
2338 let _entered = pull_span.enter();
2339 Ok::<_, std::io::Error>(rt.block_on(async move {
2340 let store = NodeAdmitStore::new(engine, hash, total_bytes, None)
2341 .counting_received(Arc::clone(&metrics));
2342 // Capture the lane seed before `ctx` moves behind the mutex, so the
2343 // on-thread settle can tell whether this stream advanced the watermark.
2344 let pool_id = ctx.pool_id;
2345 let prior_amount = ctx.prior_amount;
2346 let ctx = Arc::new(std::sync::Mutex::new(ctx));
2347 // #852: persist the buyer watermark on EVERY exit — clean completion, a
2348 // terminal drive error, or a cooperative cancel — from THIS pull thread,
2349 // AFTER the `drive` below has fully stopped advancing the shared ledger.
2350 let _settle = SettleOnDrop {
2351 deps: Arc::clone(&deps_for_thread),
2352 provider_addr,
2353 pool_id,
2354 prior_amount,
2355 ledger: Arc::clone(&ledger_for_drive),
2356 };
2357 let source = PeerSource::new(
2358 &endpoint,
2359 EndpointAddr::new(pk),
2360 Arc::clone(&ctx),
2361 Arc::clone(&ledger_for_drive),
2362 &slash_domain,
2363 provider_addr,
2364 NO_NAMESPACE,
2365 max_blob_size_bytes,
2366 rate_ceiling,
2367 deadlines,
2368 // Per-serve runtime: dial per leg (#1675).
2369 None,
2370 )
2371 .with_dial_runtime(dial_runtime);
2372 let source = PrimedSource::new(TimedSource::new(source, Arc::clone(&metrics)));
2373 // The handshake's pull is the drive's first leg only when that leg is
2374 // the whole blob: this node holds none of it.
2375 let whole_range = align_range(0, 0, total_bytes).ok();
2376 let first = first_leg(&store, &[(0, 0)], u64::MAX, max_blob_size_bytes)
2377 .await
2378 .ok()
2379 .flatten();
2380 match whole_range {
2381 Some(range) if first.as_ref() == Some(&range) => {
2382 source.prime(hash_bytes, range, header, whole, opened_at);
2383 }
2384 _ => drop((header, whole)),
2385 }
2386 let pacer = BudgetPacer::new();
2387 // The bytes the store holds before the drive: what it holds after,
2388 // less these, is the newly verified bytes the fill's funding
2389 // recovery gate counts. Bytes a leg re-delivers to bill them are
2390 // already held, so they count nothing.
2391 let held_before = held_bytes(&store, total_bytes).await;
2392 // Whole blob: offset 0, len `total_bytes`. `drive` derives missing ranges
2393 // from the ranged store, so a later pull of the same blob resumes by
2394 // re-deriving gaps: no truncate, no rewind buffer. `drive` finalizes the
2395 // store when the whole blob is present, so the caller holds no whole-blob
2396 // buffer of its own on success.
2397 let cancelled;
2398 let result = tokio::select! {
2399 biased;
2400 r = drive(
2401 &store,
2402 &source,
2403 &pacer,
2404 &ctx,
2405 &ledger_for_drive,
2406 hash_bytes,
2407 0,
2408 total_bytes,
2409 None,
2410 None,
2411 None,
2412 // Single-source candidate pull: one lane is the whole pool.
2413 None,
2414 ) => {
2415 cancelled = false;
2416 r
2417 }
2418 () = cancel_for_thread.cancelled() => {
2419 cancelled = true;
2420 Ok(())
2421 }
2422 };
2423 if let (Some(before), Some(after)) =
2424 (held_before, held_bytes(&store, total_bytes).await)
2425 && after > before
2426 {
2427 gate_for_thread.record_verified(after - before);
2428 }
2429 // A primed pull the drive never opened closes now.
2430 source.clear();
2431 (result, pool_id, cancelled)
2432 }))
2433 })
2434 .await;
2435 let elapsed = started.elapsed();
2436 // A genuine cooperative cancel (the `fetch` future was dropped) is neither a clean
2437 // delivery nor a fault: do not score or classify it. The two THREAD-LEVEL failure
2438 // shapes — the blocking task panicked (`JoinError`) or its dedicated runtime failed
2439 // to build (`io::Error`, resource exhaustion) — say nothing about the PROVIDER, but
2440 // they are OUR fault and must not launder into a clean miss (#1560), so they map to
2441 // [`PullMiss::LocalFault`] directly rather than falling through the transport-fault
2442 // classifier onto `PullMiss::Clean`.
2443 let (result, pool_id, cancelled) = match join {
2444 Ok(Ok(triple)) => triple,
2445 Ok(Err(err)) => {
2446 warn!(error = %err, "node-origin: pull-thread runtime build failed; local fault");
2447 return Err(PullMiss::LocalFault);
2448 }
2449 Err(join_err) => {
2450 warn!(%join_err, "node-origin: pull thread panicked; local fault");
2451 return Err(PullMiss::LocalFault);
2452 }
2453 };
2454 if cancelled {
2455 // The pull was abandoned mid-transfer; the on-thread settle already persisted
2456 // the watermark. Nothing to score.
2457 return Err(PullMiss::Clean);
2458 }
2459 match result {
2460 Ok(()) => {
2461 // Content delivered by this provider for a from-zero whole-blob pull is
2462 // `total_bytes`. `paid_wait` is not subtracted here, matching the
2463 // gap-driven `run_pull_leg` path (settle waits are rare and the driver
2464 // bounds them).
2465 // ADR 041: a clean speculative pull debits the source's warming allowance
2466 // by the full buy cost (whole MB at the candidate's buy rate).
2467 if speculative {
2468 deps.config.warming.debit_speculative(
2469 candidate.node_id.into(),
2470 Hash::from_bytes(hash_bytes),
2471 candidate.rate_per_mb.saturating_mul(mb_of(total_bytes)),
2472 );
2473 }
2474 record_outcome(
2475 deps,
2476 pk,
2477 &Outcome::Delivered {
2478 bytes: total_bytes,
2479 elapsed,
2480 },
2481 );
2482 Ok(())
2483 }
2484 Err(err) => Err(candidate_miss(
2485 deps,
2486 pk,
2487 provider_addr,
2488 hash_bytes,
2489 pool_id,
2490 &err,
2491 )),
2492 }
2493}
2494
2495/// The miss one candidate's failed pull is, after [`classify_pull_failure`]
2496/// scored and metered it. A failure that names no fault of this node and that
2497/// refused this node's funding ([`decdn_client::Fault::Unaffordable`]: an
2498/// `Unfunded` refusal, a funding voucher rejection, or this node's pool short
2499/// of the next voucher) is [`PullMiss::Unfunded`], so the walk's funding
2500/// recovery step can ask the candidate again (ADR 003 § Funding recovery).
2501fn candidate_miss(
2502 deps: &NodeOriginDeps,
2503 pk: PublicKey,
2504 provider_addr: Address,
2505 hash_bytes: [u8; 32],
2506 pool_id: B256,
2507 err: &anyhow::Error,
2508) -> PullMiss {
2509 let verdict = classify_pull_failure(deps, pk, provider_addr, hash_bytes, Some(pool_id), err);
2510 match PullMiss::for_verdict(verdict) {
2511 PullMiss::Clean if decdn_client::classify(err) == decdn_client::Fault::Unaffordable => {
2512 PullMiss::Unfunded
2513 }
2514 miss => miss,
2515 }
2516}
2517
2518/// Settles what a pull paid — on EVERY way out of it, including a drop.
2519///
2520/// This is a `Drop` guard rather than a pair of calls after the await because the
2521/// pull can end without returning, and on that path the money is already gone
2522/// (#1145 review). See the comment at its construction in [`pull_from_candidate`]
2523/// for which cancellations are reachable and why each one is by design.
2524///
2525/// It settles the voucher watermark, so the next reuse of this channel signs the
2526/// nonce the upstream actually committed to (#852) — just as real on a cancelled
2527/// pull as on a returned one, since the bytes were bought either way.
2528///
2529/// It deliberately does NOT record a reputation outcome. Reputation is a judgement
2530/// about the peer and needs the pull's result to make it; a drop has no result, and
2531/// a cancelled transfer is our decision, not the provider's misconduct.
2532///
2533/// `Debug` is hand-written: `NodeOriginDeps` is not `Debug`-derivable (it is a bag of trait
2534/// objects), and the guard is a field of `Debug`-deriving pull state.
2535///
2536/// BOTH pull paths settle through this and only this. Settling in terminal
2537/// methods instead would lose the watermark: the serve loop drives a pull as a
2538/// future on the iroh `accept` task, and a node shutdown or a downstream reset
2539/// DROPS it, so no terminal method runs (#1145 review).
2540struct SettleOnDrop {
2541 /// The runtime deps, reached through the shared `OnceLock`. Holding the `Arc`
2542 /// (not a borrow) lets the guard cross onto a pull thread and outlive the future
2543 /// that spawned it — both pull paths drop it off their original stack.
2544 /// `get()` is `None` only if the node was never provisioned, in which case there
2545 /// is no store to persist to and nothing to settle.
2546 deps: Arc<OnceLock<NodeOriginDeps>>,
2547 provider_addr: Address,
2548 pool_id: B256,
2549 /// The lane's cumulative amount the pull started from, so a `Drop` can tell
2550 /// whether this stream advanced the watermark past its seed before persisting.
2551 prior_amount: U256,
2552 ledger: Arc<PoolLedger>,
2553}
2554
2555impl std::fmt::Debug for SettleOnDrop {
2556 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2557 f.debug_struct("SettleOnDrop")
2558 .field("provider_addr", &self.provider_addr)
2559 .field("pool_id", &self.pool_id)
2560 .finish_non_exhaustive()
2561 }
2562}
2563
2564impl Drop for SettleOnDrop {
2565 fn drop(&mut self) {
2566 let Some(deps) = self.deps.get() else {
2567 return;
2568 };
2569 // `settlement`, not `snapshot`: a `Drop` cannot await. And not `committed`
2570 // either — that is the watermark of what the upstream ACKED, and the drop we are
2571 // handling can land inside the ack wait, where a voucher is committed upstream
2572 // and unacked here (ADR 003 persists before it acks). `settlement` adds back the
2573 // voucher still on the wire, which is what we actually owe (#1122). Settling at
2574 // `committed` there re-signs a spent cumulative on the next reuse, which the
2575 // upstream rejects as a regression — stranding lane progress.
2576 let progress = VoucherProgress::from_ledger(&self.ledger, self.prior_amount);
2577 persist_buyer_progress(deps, self.provider_addr, self.pool_id, &progress);
2578 }
2579}
2580
2581/// Persist whatever the upstream acked, regardless of Ok/Err (#852): a
2582/// mid-stream failure or a paid-but-corrupt (hash-mismatch) delivery can still
2583/// have advanced the upstream's accepted-voucher watermark. Skipping this lets
2584/// the channel re-sign a stale voucher on its next reuse and be rejected. The
2585/// bytes are already paid for, so a persist failure must not fail the pull;
2586/// surface it loudly instead (it breaks the next reuse). Both the buffered
2587/// [`pull_from_candidate`] and the window-paced pull leg reach it via
2588/// [`SettleOnDrop`].
2589fn persist_buyer_progress(
2590 deps: &NodeOriginDeps,
2591 provider_addr: Address,
2592 pool_id: B256,
2593 progress: &VoucherProgress,
2594) {
2595 let Some(write) = ProgressWrite::of(progress) else {
2596 return;
2597 };
2598 if let Err(err) = deps.buyer.record_progress(provider_addr, pool_id, write) {
2599 deps.metrics.node_pull_progress_persist_failure();
2600 warn!(%provider_addr, error = %err, "node-origin: failed to persist buyer voucher progress");
2601 }
2602}
2603
2604/// What a refusal tells us about the peer, and therefore what we should do with it
2605/// (#1144, refined in the #1145 review).
2606///
2607/// Deliberately an exhaustive match rather than a `matches!`: a new `StreamError` variant
2608/// must not silently inherit any of these — not "honest" (a future failure code would go
2609/// unscored), not "fault" (honest peers get tarred), and not "durable" (a transient
2610/// condition would suppress a healthy peer for minutes). It has to be a decision.
2611#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2612enum RefusalVerdict {
2613 /// A true, lasting fact about this (peer, hash) pair: the peer answered
2614 /// `Declined`. Suppress the pair for the full [`NegativeProbeCache`] TTL,
2615 /// because asking again soon gets the same answer.
2616 DurableMiss,
2617 /// Transient, or not attributable to the peer at all. Suppress the pair only briefly
2618 /// ([`REFUSAL_SUPPRESSION_TTL`]) — long enough that a peer serving nothing stops
2619 /// burning a candidate slot on every miss in a retry burst, short enough that we do
2620 /// not blackhole a healthy peer over a condition that has already passed.
2621 Transient,
2622 /// OUR fault. Score nothing, suppress nothing.
2623 OurFault,
2624}
2625
2626/// How long a (peer, hash) pair is suppressed after a refusal we cannot attribute to the
2627/// peer, or that we expect to pass on its own ([`RefusalVerdict::Transient`]).
2628///
2629/// Much shorter than the negative cache's own TTL (5 min), and the asymmetry is the
2630/// point. A probe's `has_blob: false` is an authoritative statement about content the
2631/// peer just checked. A `NotFound` *refusal* is not: `ServeRejectReason::wire_error`
2632/// deliberately collapses several reject reasons onto the wire `NotFound` so that a probing
2633/// client cannot map out other clients' remaining pool balances — and some of them are ours
2634/// or transient: our signer's live floor at its cap on that peer, an `UnknownChannel` or an
2635/// unconfirmed pool (while the upstream's chain watcher catches up), and a load shed. We
2636/// cannot tell them apart, and we must not: the collapse is a privacy property.
2637///
2638/// So the refusal is suppressed on the assumption it may be *us*. At the full TTL, a
2639/// pool that ran dry for one pull — or the pre-observation window right after we open
2640/// a pool — blackholed a perfectly healthy upstream for five minutes.
2641///
2642/// A node-origin leg does not suppress for a backpressure refusal
2643/// ([`record_backpressure_refusal`]). The refusal is usually the peer's per-signer
2644/// cap, filled by this node's own other pulls, and a suppression would take the
2645/// peer out of every other pull's candidate walk on the hash before that cap
2646/// clears. The assembly may wait and ask the same peer again. It suppresses the
2647/// pair only when the peer refuses past the wait budget
2648/// ([`record_backpressure_exhausted`]).
2649const REFUSAL_SUPPRESSION_TTL: Duration = Duration::from_secs(30);
2650
2651// `match_same_arms`: `NotFound` and `Unfunded` both map to `Transient`, but for
2652// different reasons; each arm carries its own reasoning, so keep them apart.
2653#[allow(clippy::match_same_arms)]
2654const fn classify_refusal(error: &StreamError) -> RefusalVerdict {
2655 match error {
2656 // "No, and stop asking" (ADR 005 §Open-time refusal classes). Asking this
2657 // peer for this hash again inside the TTL gets the same answer, so don't
2658 // spend a candidate slot finding out.
2659 StreamError::Declined => RefusalVerdict::DurableMiss,
2660 // "Not now", and not attributable: see `REFUSAL_SUPPRESSION_TTL`.
2661 StreamError::NotFound => RefusalVerdict::Transient,
2662 // Our OWN upstream buyer pool or capability falls short at this peer
2663 // (ADR 003 §Funding recovery). It says nothing about the peer, so it is
2664 // `Transient`: the remedy is our own funding recovery.
2665 StreamError::Unfunded => RefusalVerdict::Transient,
2666 // `VoucherRejected` does not reach here: `pull_verdict` unwraps it out of
2667 // `UpstreamRefused` and routes it to `voucher_verdict`, which is the only place that
2668 // decides what a rejected voucher costs the channel (#1145 review).
2669 //
2670 // The arm stays because the match is exhaustive on purpose — a new `StreamError` must
2671 // break this build — and because `RefusalVerdict::OurFault` is still the right answer
2672 // to the question THIS function asks ("what does this refusal say about the peer?"):
2673 // nothing. It just is not the whole answer, and this function is not the one that can
2674 // give it. Routing a mid-stream rejection through here alone is what let a wedged
2675 // channel skip its remedy entirely and be handed back on every subsequent miss.
2676 StreamError::VoucherRejected { .. } => RefusalVerdict::OurFault,
2677 }
2678}
2679
2680/// What a failed pull was actually caused by — the whole classification decision, as a
2681/// value.
2682///
2683/// Separated from the *acting* on it ([`classify_pull_failure`]) so the decision can be
2684/// tested without a live `NodeOriginDeps`, which needs an iroh endpoint. That matters more
2685/// than it sounds. The ladder below is an ORDERED chain of `downcast_ref`s whose order is
2686/// load-bearing and invisible to the compiler, ending in a catch-all that scores the peer
2687/// `Unreachable`. Every mis-attribution this module is prone to is an error falling one
2688/// arm further than it should and landing there: an honest `NotFound` refusal (#1144),
2689/// a mid-stream `StreamError`, a broken local signer. Making the decision a pure function
2690/// means "which arm does this error land in?" is a question a test can just ask.
2691#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2692enum PullVerdict {
2693 /// The bytes that actually arrived crossed OUR configured ceiling (#1895) — the
2694 /// blob may be fine for other nodes with a wider cap. The peer's `total_bytes`
2695 /// claim never triggers this; only received bytes do.
2696 Oversize,
2697 /// The provider quoted a per-MB rate above the buyer's effective ceiling — the lower
2698 /// of its own probe rate and our configured absolute cap (#1375). We refused before
2699 /// paying; the signed over-quote is retained on the `RateAboveCeiling` error for a
2700 /// caller to replay to `SlashJudge`, but this node does not auto-submit a challenge
2701 /// (deferred). Reputation-neutral, and metered + suppressed: an over-*config* quote is
2702 /// our tight policy, not proof the provider is bad; an over-*probe* quote is a possible
2703 /// bait-and-switch we do not adjudicate here (and it is only slashable when the probe
2704 /// bound, not the config bound, was exceeded). Either way the rate is durable for this
2705 /// (peer, hash) — re-probing gets the same quote — so we suppress the pair (like
2706 /// [`Self::OurDeadline`]) rather than tar the peer, and count it (like
2707 /// [`Self::Oversize`], which meters but does not suppress).
2708 RateCeiling,
2709 /// OUR deadline fired: a possibly mis-sized local budget, not evidence about the peer.
2710 OurDeadline,
2711 /// The peer went SILENT mid-stream (#1134). Unlike [`Self::OurDeadline`] this IS about
2712 /// the peer: a clock that resets on every byte can only fire on one that stopped.
2713 Stalled,
2714 /// The peer shed the connection or stream at the transport with
2715 /// `APP_ERR_RATE_LIMITED` (ADR 013 §Application Error Codes) before any signed
2716 /// message existed (#1986). The transport-level twin of
2717 /// `Refused(Transient)` for a load-shed `StreamError::NotFound`, and it gets the same
2718 /// treatment: backpressure is respected, not punished. Metered and suppressed for
2719 /// [`REFUSAL_SUPPRESSION_TTL`], never scored — the peer answered, it just declined
2720 /// the work, and the `global-full` layer is not about this caller at all.
2721 RateLimited,
2722 /// The peer rejected a voucher and this lane to this provider is finished, but the
2723 /// shared pool deposit is NOT: it still backs every other lane and is refundable through
2724 /// the pool's own grace-window close and reclaim.
2725 ///
2726 /// Split from [`Self::OurVoucherRetryable`] because the two want opposite actions, and
2727 /// collapsing them makes a drained lane invisible. `UpstreamVoucherRejected` carries a
2728 /// `VoucherRejectReason` whose variants prescribe *different* remedies — resend, top
2729 /// up, suppress, stop — so discarding the reason with a bare `.is_some()` would collapse
2730 /// every one of them to "skip this candidate, say nothing".
2731 ///
2732 /// The pool row is the node's handle on the deposit, and the recovery sweeps enumerate
2733 /// the store (`load_all`), so it is KEPT rather than deleted. The provider is SUPPRESSED
2734 /// for a bounded window instead: the pool has no per-provider deadline to key a horizon
2735 /// on — the shared deposit fans out across every provider — so a lane-terminal rejection
2736 /// takes the provider out of ranking for a fixed window while the pool's deposit stays
2737 /// intact and available to every other lane.
2738 OurDeadLane(VoucherRejectReason),
2739 /// The peer rejected a voucher we presented, but the peer is FINE — the fault is in
2740 /// OUR buyer pool. `PoolExhausted` lands here: the pool WE fund the upstream from can
2741 /// no longer cover further credit (ADR 003 §Pool solvency). The remedy is a top-up of
2742 /// our pool and a retry, keeping the healthy peer rather than suppressing it.
2743 OurVoucherRetryable(VoucherRejectReason),
2744 /// The peer refused delivery, carrying the wire code's own verdict (#1144).
2745 Refused(RefusalVerdict),
2746 /// A fault in THIS node — a broken signer, a bad encode, a bad range, or its own
2747 /// cache store failing under the pull (a full or read-only disk, a cache code bug).
2748 /// It says nothing about the peer, and a node in this state would otherwise tar
2749 /// every honest provider it meets.
2750 OurLocalFault,
2751 /// Reachable, paid, and served the wrong bytes.
2752 Corruption,
2753 /// A leg finished cleanly but moved neither the paid nor the delivered frontier
2754 /// ([`LegNoProgress`], #2194): this node's store did not keep the bytes, its ledger
2755 /// did not record the payment, or the upstream ended the stream without taking the
2756 /// final proof. Metered, logged and suppressed for the pair, never scored: the
2757 /// likelier cause is on this node, and the pair would only repeat the paid leg.
2758 LegNoProgress,
2759 /// Everything else: a failed dial, a dropped connection, a bad slash signature, an
2760 /// unexpected frame. The residual — and the arm that actually scores a dead node.
2761 Unreachable,
2762}
2763
2764/// A buyer lane that can no longer pay but whose shared pool DEPOSIT is still escrowed.
2765/// Keep the pool row, stop using the provider, and say so loudly.
2766///
2767/// The pool row is not bookkeeping — it is the node's handle on the money. The pool's
2768/// grace-window close and reclaim refund the deposit and need the `pool_id`; the recovery
2769/// sweeps find their work by enumerating the store (`load_all`). A row this function deleted
2770/// would therefore be a deposit that nothing in this codebase can ever see again.
2771///
2772/// So this deliberately does NOT retire the pool. What it does instead:
2773///
2774/// - **Suppress the PROVIDER, for all hashes, for a bounded window.** The pool row survives
2775/// and backs every other lane, so a lane-terminal rejection must not take the pool down —
2776/// only this provider. Two suppressions cover it: a short per-`(peer, hash)` negative-cache
2777/// entry ([`REFUSAL_SUPPRESSION_TTL`]) for the immediate same-blob retry, and a
2778/// provider-wide entry in `wedged_providers` keyed by the peer and held for
2779/// [`WEDGED_PROVIDER_SUPPRESSION_SECS`], so `probe_and_rank` drops the provider from
2780/// ranking for every hash until then. The pool has no per-provider deadline to key the
2781/// horizon on — the shared deposit fans out across every provider — so the window is fixed.
2782/// - **Score nothing.** The peer behaved correctly; our accounting is what broke.
2783///
2784/// The cost is this provider for the suppression window; the pool's deposit is untouched and
2785/// stays available to every other lane.
2786fn wedged_channel(
2787 deps: &NodeOriginDeps,
2788 pk: PublicKey,
2789 provider_addr: Address,
2790 hash_bytes: [u8; 32],
2791 reason: VoucherRejectReason,
2792 channel: Option<B256>,
2793) {
2794 deps.metrics.node_pull_pool_wedged();
2795 // Immediate cover: suppress this (peer, hash) for the short refusal TTL so a retry for the
2796 // SAME blob does not re-present the same voucher before the provider-wide horizon lands.
2797 deps.negative_cache.record_failure_with_ttl(
2798 DhtNodeId::from_bytes(*pk.as_bytes()),
2799 DhtHash::from_bytes(hash_bytes),
2800 REFUSAL_SUPPRESSION_TTL,
2801 );
2802 // Provider-wide: a provider whose voucher this lane cannot pay is one we cannot pay for any
2803 // hash right now, so take it out of ranking for a bounded window. The horizon itself belongs
2804 // to `record_wedged` — the pool has no per-provider deadline to key one on (the deposit fans
2805 // out across every provider), so there is nothing for this call site to choose.
2806 deps.record_wedged(&pk);
2807 warn!(
2808 %provider_addr, pool_id = ?channel, ?reason,
2809 "node-origin: upstream rejected our voucher on terms this lane cannot recover from; \
2810 suppressing the provider for a bounded window while the pool's own deposit is \
2811 unaffected (#1122)"
2812 );
2813}
2814
2815/// How long a provider that rejected a voucher on a lane-terminal reason is kept out of
2816/// ranking (seconds — a duration, not an epoch stamp; the map VALUE is the epoch second).
2817/// Bounded because the pool has no per-provider deadline to key the horizon on — the shared
2818/// deposit outlives any single lane.
2819const WEDGED_PROVIDER_SUPPRESSION_SECS: u64 = 3600;
2820
2821/// What a voucher rejection tells us, and therefore what to do about it.
2822///
2823/// Exhaustive on purpose, like [`classify_refusal`]: a new `VoucherRejectReason` must break
2824/// this build rather than silently inherit a verdict. The reasons are not variations on one
2825/// theme — three genuinely different things arrive on this wire code:
2826///
2827/// - **Our signer is broken.** `BadSignature`/`WrongSigner` mean the upstream could not
2828/// verify a signature WE produced. That is not a payment problem, it is a defect in this
2829/// node, and it hits every candidate we try — so it belongs in the loud
2830/// [`PullVerdict::OurLocalFault`] arm, which exists for exactly this. Routing it to the
2831/// payment bucket also *hides* it: the ladder checks `UpstreamVoucherRejected` before
2832/// `LocalPullFault`, so a broken buyer key produces a `debug!` about payments instead of
2833/// the `warn!` about a node that cannot pay anyone.
2834/// - **This lane to this provider is finished, but the pool's DEPOSIT is not gone.** The
2835/// signer's spending cap is exhausted (`SpendingCapExhausted`) or its capability expired
2836/// (`CapabilityExpired`), our accounting drifted (`AmountRegression`/`BytesRegression`/
2837/// `UnderFold`), or the voucher was addressed to the wrong pool or a different provider
2838/// (`WrongPool`/`WrongProvider`). No further voucher on this lane is accepted, but the
2839/// pool row still holds a deposit worth keeping — so the provider is suppressed for a
2840/// bounded window and the pool row is KEPT rather than deleted.
2841/// - **Try again.** `PoolExhausted` — the pool WE fund the upstream from can no longer
2842/// cover further credit (ADR 003 §Pool solvency). Every upstream returns it, so it is a
2843/// statement about us, not the peer. The fill's funding recovery step tops our pool up
2844/// (ADR 003 § Funding recovery) and asks again, rather than suppress a healthy peer.
2845///
2846/// Wallet-less resume: this classifier does NOT special-case a bundled
2847/// `SpendingCapExhausted`/`AmountRegression`/`BytesRegression`/`UnderFold`/`Underpaid`, and it does
2848/// not need to. The gap-driven `decdn_client::drive` loop (this node's own cache-miss buyer leg)
2849/// already retries a resumable rejection in its own loop before it can ever surface here: it
2850/// reseeds the pool's ledger and reopens the pull, transparently, and this classifier sees only the
2851/// FINAL outcome. So by the time `pull_verdict` downcasts an
2852/// error to `UpstreamVoucherRejected` and reaches this function, the rejection is genuinely
2853/// terminal: either the reason was never gated, it carried no bundle, the bundle failed shape
2854/// validation or authentication, the bundle did not advance our ledger (an echo, or a bytes-only
2855/// `UnderFold`), or the bounded resume attempts were exhausted. `OurDeadLane` remains the correct
2856/// verdict for every lane-terminal reason in that case — the lane really is unusable, and the
2857/// deposit worth keeping the pool row for is not what needs reclaiming.
2858const fn voucher_verdict(reason: VoucherRejectReason, has_bundle: bool) -> PullVerdict {
2859 match reason {
2860 // An `Underpaid` with no watermark comes from a lane that has accepted no
2861 // voucher yet, so no watermark can have diverged: our own pricing is below
2862 // the quote, and every candidate refuses it the same way. Suppressing the
2863 // peer would blame the wrong party.
2864 VoucherRejectReason::Underpaid if !has_bundle => PullVerdict::OurLocalFault,
2865
2866 // Our own signing or metering is broken, and it hits every candidate.
2867 // `BadPreimage` and `ChainIndexZero` belong here for the same reason
2868 // a bad signature does: both mean this node released a proof no upstream
2869 // can accept — a wrong seed, a wrong chain, or an index that never
2870 // travels the wire — so suppressing the peer would blame the wrong party
2871 // and hide a buyer-side bug. `ChunkPriceMismatch` is the same shape: we
2872 // signed a price that is not the rate this node was quoted.
2873 VoucherRejectReason::BadSignature
2874 | VoucherRejectReason::WrongSigner
2875 | VoucherRejectReason::BadPreimage
2876 | VoucherRejectReason::ChainIndexZero
2877 | VoucherRejectReason::ChunkPriceMismatch => PullVerdict::OurLocalFault,
2878
2879 // Our OWN buyer pool, not the upstream — retry, do not suppress the peer.
2880 // `PoolExhausted` says the pool WE fund the upstream from can no longer cover
2881 // further credit; it is a statement about us, so every upstream returns it and
2882 // routing it to `OurDeadLane` would walk the candidate list suppressing each
2883 // healthy peer for an hour, outliving any top-up. The remedy is the fill's
2884 // funding recovery step on our pool and one more ask, so keep the peer.
2885 // Not fatal, and not the peer's fault: this stream had not carried the
2886 // current epoch's `chain_root` voucher before its first reveal. The fix
2887 // is to re-anchor and resend, which is what a retry does — and the
2888 // resend costs nothing, because a metering voucher at or below the
2889 // watermark is already-satisfied and pays no whole chunk.
2890 VoucherRejectReason::PoolExhausted | VoucherRejectReason::UnanchoredPreimage => {
2891 PullVerdict::OurVoucherRetryable(reason)
2892 }
2893 // Terminal for THIS lane while the pool row is still worth keeping. The signer's
2894 // cap is spent — either the upstream's voucher-amount check
2895 // (`SpendingCapExhausted`) or its mid-stream cross-provider cap-headroom
2896 // re-check (`SignerCapExhausted`) — or its capability expired
2897 // (`CapabilityExpired`), our accounting drifted and no resync healed it
2898 // (`AmountRegression`/`BytesRegression`/`UnderFold`/a bundled `Underpaid`), or
2899 // the voucher named the wrong pool or a different provider
2900 // (`WrongPool`/`WrongProvider`). None of these has surrendered
2901 // the pool row's value outright — a mis-addressed or drifted voucher spends
2902 // nothing, and an exhausted cap or expired capability means too little for THIS
2903 // signer right now — so the provider is suppressed and the pool row is KEPT.
2904 VoucherRejectReason::WrongPool
2905 | VoucherRejectReason::WrongProvider
2906 | VoucherRejectReason::AmountRegression
2907 | VoucherRejectReason::BytesRegression
2908 | VoucherRejectReason::UnderFold
2909 | VoucherRejectReason::Underpaid
2910 | VoucherRejectReason::SpendingCapExhausted
2911 | VoucherRejectReason::SignerCapExhausted
2912 | VoucherRejectReason::CapabilityExpired => PullVerdict::OurDeadLane(reason),
2913 }
2914}
2915
2916/// The ordered sentinel ladder. Pure: no metrics, no reputation, no I/O.
2917fn pull_verdict(err: &anyhow::Error) -> PullVerdict {
2918 if err.downcast_ref::<BlobTooLarge>().is_some() {
2919 return PullVerdict::Oversize;
2920 }
2921 if err.downcast_ref::<RateAboveCeiling>().is_some() {
2922 return PullVerdict::RateCeiling;
2923 }
2924 // Above the peer-blaming arms, alongside the other local faults: the upstream
2925 // answered honestly ("the blob is only N bytes"), and the offset it refused is one
2926 // WE computed — a resume frontier that overran the blob (#1530). Without an arm of
2927 // its own it falls through to `Unreachable`, which marks an honest peer down with a
2928 // local EWMA hit (ADR 008 scoring is local-only) on the strength of our own
2929 // arithmetic. `ResumeOffsetPastEnd`'s own doc says it: "this is a statement about
2930 // the offset, not about the peer".
2931 if err.downcast_ref::<ResumeOffsetPastEnd>().is_some() {
2932 return PullVerdict::OurLocalFault;
2933 }
2934 // Ahead of the catch-all, which would score an honest upstream `Unreachable` for a
2935 // leg it served and was paid for.
2936 if err.downcast_ref::<LegNoProgress>().is_some() {
2937 return PullVerdict::LegNoProgress;
2938 }
2939 if err.downcast_ref::<PullTimeout>().is_some() {
2940 return PullVerdict::OurDeadline;
2941 }
2942 if err.downcast_ref::<PullStalled>().is_some() {
2943 return PullVerdict::Stalled;
2944 }
2945 // Ahead of the catch-all, deliberately: a `0x10` close is the ONE transport failure
2946 // that is a statement by the peer rather than an absence of one (#1986). Left to the
2947 // residual it scores `Unreachable` for exactly the condition a handler-level
2948 // load-shed `NotFound` is exonerated for, and a `global-full` shed scores every
2949 // concurrent prober at once.
2950 if err.downcast_ref::<UpstreamRateLimited>().is_some() {
2951 return PullVerdict::RateLimited;
2952 }
2953 if let Some(rejected) = err.downcast_ref::<UpstreamVoucherRejected>() {
2954 return voucher_verdict(rejected.reason, rejected.bundle.is_some());
2955 }
2956 // This node's own pool cannot cover the next voucher (the pacer refused the
2957 // leg): a statement about us, never the peer, exactly like an upstream's
2958 // `PoolExhausted`. Ahead of the catch-all, which would score the peer.
2959 if err.downcast_ref::<decdn_client::PoolExhausted>().is_some() {
2960 return PullVerdict::OurVoucherRetryable(VoucherRejectReason::PoolExhausted);
2961 }
2962 // Ahead of `UpstreamRefused` and the catch-all, deliberately: a local signing or encode
2963 // fault surfaces while we are talking to a peer, and every arm below this one blames
2964 // the peer to some degree. A node with a broken buyer key hits this on EVERY candidate,
2965 // so getting the order wrong here does not mis-score one provider — it tars the whole
2966 // candidate list with a local `Unreachable` EWMA hit (ADR 008 scoring is local-only,
2967 // no cross-node propagation) on the strength of our own defect.
2968 if err.downcast_ref::<LocalPullFault>().is_some() {
2969 return PullVerdict::OurLocalFault;
2970 }
2971 if let Some(refused) = err.downcast_ref::<UpstreamRefused>() {
2972 // A `VoucherRejected` arriving as a mid-stream refusal is the SAME event as one
2973 // arriving in reply to a voucher, and must get the same remedy (#1145 review).
2974 //
2975 // The client's `resolve_voucher_slot` types a `VoucherRejected` into
2976 // `UpstreamVoucherRejected` wherever it lands, so this unwrap is a defensive backstop
2977 // for any `VoucherRejected` that still reaches here inside `UpstreamRefused`: were it
2978 // left folded into the refusal ladder it would be ruled `OurFault` — score nothing,
2979 // suppress nothing, do nothing — and skip the whole channel remedy above, leaving a
2980 // wedged or settled channel in the store to be handed straight back on the next miss,
2981 // forever, on a `debug!` line invisible at the project's default `RUST_LOG=info`.
2982 //
2983 // Unwrap it here rather than in `classify_refusal`, because the answer is not a
2984 // refusal verdict at all: it is a statement about our CHANNEL, and `voucher_verdict`
2985 // is the one place that decides what a rejected voucher costs.
2986 if let StreamError::VoucherRejected { reason, bundle } = refused.error() {
2987 return voucher_verdict(*reason, bundle.is_some());
2988 }
2989 return PullVerdict::Refused(classify_refusal(refused.error()));
2990 }
2991 if err.downcast_ref::<HashMismatch>().is_some() {
2992 return PullVerdict::Corruption;
2993 }
2994 // Ahead of the catch-all: this node's own store failed under the pull (#2286).
2995 // A stream the peer ended early is `CacheError::Feed`, not a store fault, so it
2996 // still falls through and scores `Unreachable`.
2997 if is_local_store_fault(err) {
2998 return PullVerdict::OurLocalFault;
2999 }
3000 PullVerdict::Unreachable
3001}
3002
3003/// Whether `err`'s chain holds a fault of this node's own cache store: a
3004/// [`decdn_cache::CacheError::Store`] (a full, read-only or broken disk, a store
3005/// actor fault) or a [`decdn_cache::CacheError::Internal`] (a cache code bug).
3006///
3007/// It walks the chain because a store query fails with the `CacheError` boxed
3008/// under a `RangedStoreError::Backend`. It does not rest on the `io::ErrorKind`
3009/// under the `CacheError`: iroh-blobs reports a failed data-file write as a
3010/// kind-less `Other`, so a full disk does not say so.
3011fn is_local_store_fault(err: &anyhow::Error) -> bool {
3012 err.chain().any(|cause| {
3013 matches!(
3014 cause.downcast_ref::<decdn_cache::CacheError>(),
3015 Some(decdn_cache::CacheError::Store(_) | decdn_cache::CacheError::Internal(_))
3016 )
3017 })
3018}
3019
3020/// Meter a backpressure refusal on a node-origin leg — a first-leg or header
3021/// handshake, or a ranged run — and return its verdict, or `None` when `err` is
3022/// not backpressure (#2178).
3023///
3024/// Backpressure is an open-stage refusal that usually clears with time: a
3025/// transport rate-limit ([`PullVerdict::RateLimited`]), or a signed `NotFound`
3026/// answer to the stream open. The leg goes only to a source whose
3027/// probe advertised the blob, so a `NotFound` from it is rarely an honest miss. It
3028/// is one of the refusals the seller's `ServeRejectReason::wire_error` collapses
3029/// onto `NotFound`, and the common one is the per-signer live cap: this node pays
3030/// every upstream leg from one buyer signer, so its other concurrent pulls on the
3031/// same hash can fill that cap. The collapse also carries refusals that do not
3032/// clear by waiting (an unconfirmed pool, a chain-stale seller); the assembly's
3033/// wait budget bounds what those cost.
3034///
3035/// Two refusals stay out. `Unfunded`: another provider may reserve a smaller
3036/// floor and admit the pool, so the caller moves on. A mid-stream `NotFound`:
3037/// the per-signer cap refuses only at admission, and a
3038/// source that serves some bytes and then refuses must not reset the wait budget
3039/// on every round.
3040///
3041/// On `Some`, this meters the refusal exactly as [`classify_pull_failure`] does but
3042/// records no `(peer, hash)` suppression (see [`REFUSAL_SUPPRESSION_TTL`]). On
3043/// `None`, it does nothing, and the caller classifies the failure as usual.
3044fn record_backpressure_refusal(
3045 deps: &NodeOriginDeps,
3046 provider_addr: Address,
3047 err: &anyhow::Error,
3048) -> Option<PullVerdict> {
3049 let verdict = backpressure_verdict(err)?;
3050 if verdict == PullVerdict::RateLimited {
3051 deps.metrics.node_upstream_rate_limited();
3052 } else {
3053 deps.metrics.node_pull_refused();
3054 deps.metrics.node_pull_refused_unattributable();
3055 }
3056 debug!(%provider_addr, error = %err, "node-origin: upstream refused a leg for backpressure; not suppressing it, as the refusal usually clears with time");
3057 Some(verdict)
3058}
3059
3060/// The pure half of [`record_backpressure_refusal`]: the backpressure verdict
3061/// `err` carries, or `None` when it is not backpressure.
3062fn backpressure_verdict(err: &anyhow::Error) -> Option<PullVerdict> {
3063 let verdict = pull_verdict(err);
3064 match verdict {
3065 PullVerdict::RateLimited => Some(verdict),
3066 PullVerdict::Refused(RefusalVerdict::Transient) => {
3067 let at_open = err.downcast_ref::<UpstreamRefused>().is_some_and(|r| {
3068 r.evidence().is_some() && !matches!(r.error(), StreamError::Unfunded)
3069 });
3070 at_open.then_some(verdict)
3071 }
3072 _ => None,
3073 }
3074}
3075
3076/// Record that a ranged assembly dropped `pk`, the only holder of a
3077/// still-missing range, after it refused `waits` backpressure waits in a row with
3078/// no gap progress (#2178).
3079///
3080/// By this point the refusal has outlasted the time this node's own other pulls
3081/// need to pay and release the holder's per-signer cap, so it is probably not
3082/// ours. Suppress the `(peer, hash)` pair for [`REFUSAL_SUPPRESSION_TTL`], so the
3083/// next miss on the hash does not spend the whole wait budget on it again. Score
3084/// nothing: a refusal is still an answer.
3085fn record_backpressure_exhausted(
3086 deps: &NodeOriginDeps,
3087 pk: PublicKey,
3088 hash_bytes: [u8; 32],
3089 waits: u32,
3090) {
3091 deps.metrics.node_pull_backpressure_exhausted();
3092 deps.negative_cache.record_failure_with_ttl(
3093 DhtNodeId::from_bytes(*pk.as_bytes()),
3094 DhtHash::from_bytes(hash_bytes),
3095 REFUSAL_SUPPRESSION_TTL,
3096 );
3097 warn!(
3098 peer = %pk,
3099 hash = %Hash::from(hash_bytes),
3100 waits,
3101 "node-origin: the only holder of a still-missing range kept refusing for \
3102 backpressure; dropping it and ending the assembly short"
3103 );
3104}
3105
3106/// Classify a failed pull and fold the appropriate (or no) reputation outcome,
3107/// shared by the buffered and window-paced paths (#856). Buyer-side faults are
3108/// exonerated (don't tar the provider); a refusal is exonerated too (#1144 — a
3109/// peer that answers is reachable, whatever it answers); a transport-level
3110/// rate-limit shed is exonerated the same way (#1986); a hash mismatch is
3111/// `Corruption`; everything else is `Unreachable`.
3112///
3113/// [`pull_verdict`] makes the decision; this acts on it, and RETURNS it so the
3114/// caller can act on it too (#1560). The two consumers want different halves of the
3115/// same answer: this function asks "what does this failure say about the PEER?" and
3116/// spends the reputation/suppression/channel remedies accordingly, while the caller
3117/// asks "what may we tell our own client?" and folds the verdict into a
3118/// [`PullMiss`]. Returning it is what stops the second question being answered by
3119/// silence: without it every non-hit looks identical to the serve path, and a fault in
3120/// this node is signed to a client as a clean `NotFound` about the content.
3121///
3122/// A caller that has already answered on the wire may drop the verdict: by then
3123/// the `StreamResponse` is signed `ok: true` and sent, so the failure is an abort, not a
3124/// refusal code, and there is no answer left to pick. A caller that has NOT yet answered
3125/// owes the verdict a [`PullMiss`]; dropping it there signs this node's own fault to a
3126/// client as an absent blob (#1560).
3127///
3128/// `channel` is the buyer pool the pull was paying from, or `None` for the failures that
3129/// happen before there is one to pay from (a pool open that never completed, a local
3130/// binding-signature fault). It is `Option` rather than plumbed unconditionally because the
3131/// distinction is real: only a pull that presented a voucher can have one rejected, so only
3132/// those sites can reach [`PullVerdict::OurDeadLane`] and name the pool to suppress against.
3133// The tracing macros inflate the cognitive-complexity metric past threshold.
3134#[allow(clippy::cognitive_complexity)]
3135fn classify_pull_failure(
3136 deps: &NodeOriginDeps,
3137 pk: PublicKey,
3138 provider_addr: Address,
3139 hash_bytes: [u8; 32],
3140 channel: Option<B256>,
3141 err: &anyhow::Error,
3142) -> PullVerdict {
3143 let suppress = |ttl: Option<Duration>| {
3144 let node = DhtNodeId::from_bytes(*pk.as_bytes());
3145 let hash = DhtHash::from_bytes(hash_bytes);
3146 match ttl {
3147 Some(ttl) => deps.negative_cache.record_failure_with_ttl(node, hash, ttl),
3148 None => deps.negative_cache.record_failure(node, hash),
3149 }
3150 };
3151
3152 let verdict = pull_verdict(err);
3153 match verdict {
3154 // OUR ceiling, not the provider's fault — it may legitimately serve larger blobs to
3155 // nodes configured with a higher `max_blob_size`. Metered, not scored (#1895). The
3156 // abort fires once the RECEIVED bytes cross the ceiling, so we paid the upstream for
3157 // the prefix we took (bounded to roughly one ceiling), never for the peer's claim.
3158 PullVerdict::Oversize => {
3159 deps.metrics.node_pull_too_large();
3160 debug!(%provider_addr, error = %err, "node-origin: upstream blob crossed our size ceiling on received bytes; pull aborted");
3161 }
3162 // The provider quoted above our effective rate ceiling (#1375). We refused before
3163 // paying; the signed over-quote is retained ON the `RateAboveCeiling` error for a
3164 // caller to act on, though this handler does not itself submit a challenge
3165 // (auto-slashing is deferred). Metered like `Oversize` so the refusal is
3166 // operator-visible, then suppressed for the full durable TTL (as `OurDeadline` does,
3167 // NOT `Oversize`, which only meters): the quote is a lasting fact about this
3168 // (peer, hash) — re-probing gets the same rate. Reputation-neutral: whether it is a
3169 // bait-and-switch or just our tight config we do not adjudicate here, so we do not
3170 // tar the peer.
3171 PullVerdict::RateCeiling => {
3172 deps.metrics.node_pull_rate_above_ceiling();
3173 suppress(Some(REFUSAL_SUPPRESSION_TTL));
3174 debug!(%provider_addr, error = %err, "node-origin: upstream quoted above our rate ceiling; refused before paying, suppressing the pair");
3175 }
3176 // A possibly mis-sized local budget, not evidence the provider is unreachable
3177 // (#857). Unscored — but NOT ignored (#1145 review).
3178 //
3179 // Exonerating and doing nothing are different things, and collapsing them left the
3180 // cheapest griefer in the protocol unanswered. A peer that probes honestly
3181 // (`has_blob: true`, low rate, low RTT → ranks #1), accepts the stream, signs a
3182 // valid `StreamResponse`, and then sends NOTHING lands here — our deadline fired, so
3183 // it is `OurDeadline`. It was then neither scored nor suppressed, so it stayed
3184 // top-ranked and burned a full budget on EVERY subsequent miss, for every hash,
3185 // forever, at zero cost to itself. Refusal suppression above closes exactly that hole
3186 // for refusals ("a peer that advertises everything and serves nothing burned a candidate
3187 // slot on every miss forever"), left open one stage over for a peer that does not
3188 // even bother to answer.
3189 //
3190 // Suppression is the instrument that fits: scoped to (peer, hash), TTL'd, and
3191 // reputation-neutral — so it costs an honest-but-slow peer one TTL and costs a
3192 // silent one its permanent free lunch, without broadcasting a judgement about either.
3193 // That neutrality is what lets it be applied to a verdict we cannot attribute: we
3194 // are not saying the peer is bad, only that we will not keep waiting on it.
3195 PullVerdict::OurDeadline => {
3196 deps.metrics.node_pull_timeout();
3197 suppress(Some(REFUSAL_SUPPRESSION_TTL));
3198 debug!(%provider_addr, error = %err, "node-origin: pull hit our local deadline; suppressing briefly, not tarring upstream reputation");
3199 }
3200 // A throughput-floor abort is non-attributable (#1797), the same class as
3201 // `OurDeadline`: a stream that falls below the floor may be slow because of the link,
3202 // congestion, or our own slow consumption, none of which the peer can be blamed for,
3203 // and a throughput signal is spoofable in both directions (ADR 005 §Non-empty, ADR
3204 // 008). So it is metered and the `(peer, hash)` pair is suppressed — which stops a
3205 // peer that accepts a stream and then stalls from burning a candidate slot on every
3206 // miss — but no `Outcome` is recorded and reputation is untouched. `PullStalled` and
3207 // `PullTimeout` stay distinct metrics; the only difference from `OurDeadline` here is
3208 // the counter.
3209 PullVerdict::Stalled => {
3210 deps.metrics.node_pull_stalled();
3211 suppress(Some(REFUSAL_SUPPRESSION_TTL));
3212 debug!(%provider_addr, error = %err, "node-origin: upstream fell below the throughput floor; suppressing briefly, not tarring upstream reputation");
3213 }
3214 // The peer shed us at the transport with `APP_ERR_RATE_LIMITED` (#1986): the same
3215 // event as a handler-level load-shed `NotFound`, one layer down, and it earns the
3216 // same remedy. Suppressing the `(peer, hash)` pair briefly stops a shedding peer
3217 // from burning a candidate slot on every retry of this miss; scoring it would tar
3218 // a peer for honestly saying "not now" — and, for the node-wide `global-full`
3219 // layer, tar every peer probing it in that window at once.
3220 PullVerdict::RateLimited => {
3221 deps.metrics.node_upstream_rate_limited();
3222 suppress(Some(REFUSAL_SUPPRESSION_TTL));
3223 debug!(%provider_addr, error = %err, ttl = ?REFUSAL_SUPPRESSION_TTL, "node-origin: upstream shed the pull at the transport (rate-limited); suppressing briefly, not tarring upstream reputation");
3224 }
3225 // Our payment-side fault — the provider is not scored (#857). What separates this arm
3226 // from the retryable one below is what it costs the LANE.
3227 //
3228 // This lane can no longer pay, but the shared pool DEPOSIT is still escrowed: a
3229 // desync, a drained-cap balance, a mis-addressed voucher. The pool row is the handle
3230 // on that money (the recovery sweeps enumerate the store), so it is KEPT and the
3231 // provider is suppressed instead of the row being deleted. `warn!`, not `debug!`, for
3232 // the same reason the local-fault arm is: at the default `RUST_LOG=info` a `debug!`
3233 // is invisible, and money is at stake.
3234 PullVerdict::OurDeadLane(reason) => {
3235 deps.metrics.node_pull_voucher_rejected();
3236 wedged_channel(deps, pk, provider_addr, hash_bytes, reason, channel);
3237 }
3238 // The peer is fine, the fault is our buyer pool: `PoolExhausted` means the deposit we
3239 // fund the upstream from can no longer cover further credit, cleared by a top-up and a
3240 // retry. Skip the candidate this once and leave the peer alone — suppressing here would
3241 // throw away a healthy provider over our own funding gap.
3242 PullVerdict::OurVoucherRetryable(reason) => {
3243 deps.metrics.node_pull_voucher_rejected();
3244 debug!(
3245 %provider_addr, ?reason, error = %err,
3246 "node-origin: upstream rejected the voucher but the channel is healthy; \
3247 left intact for a retry"
3248 );
3249 }
3250 // A refusal proves the peer is reachable and answering, so it is not `Unreachable`
3251 // on its own; scoring it `Unreachable` would tar a node exactly as hard for honestly
3252 // saying it lacks a blob as for being dead (#1144).
3253 //
3254 // Exonerating it is not the same as ignoring it, though. Every candidate that got
3255 // this far answered `has_blob = true` at probe, so a refusal is a peer contradicting
3256 // itself, and recording nothing would let a peer that advertises everything and serves
3257 // nothing keep winning the ranker and burn a `MAX_PROVIDER_ATTEMPTS` slot on every
3258 // miss, forever. The negative cache is the right instrument — scoped to (peer, hash),
3259 // TTL'd, reputation-neutral — and `RefusalVerdict` decides what the suppression is
3260 // worth: five minutes for a peer that truthfully says the blob is gone, far less for
3261 // a `NotFound`, which can be a load shed, a stale chain, or a pool the peer has not
3262 // confirmed yet.
3263 PullVerdict::Refused(verdict) => {
3264 deps.metrics.node_pull_refused();
3265 match verdict {
3266 RefusalVerdict::DurableMiss => {
3267 suppress(None);
3268 debug!(%provider_addr, error = %err, "node-origin: upstream declined this blob; negative-caching this (peer, hash) for the full TTL without tarring reputation");
3269 }
3270 RefusalVerdict::Transient => {
3271 // Metered (#1520). This arm is where a *buyer-side* problem
3272 // lands: a seller refusing our pool or signer answers
3273 // `Unfunded`, or `NotFound` when the state is one it collapses,
3274 // so without a counter here a node whose own deposit cannot
3275 // buy anything sees every pull refused with nothing in its
3276 // telemetry saying why.
3277 deps.metrics.node_pull_refused_unattributable();
3278 suppress(Some(REFUSAL_SUPPRESSION_TTL));
3279 debug!(%provider_addr, error = %err, ttl = ?REFUSAL_SUPPRESSION_TTL, "node-origin: upstream refused for a reason we cannot attribute to it; briefly suppressing this (peer, hash) without tarring reputation");
3280 }
3281 RefusalVerdict::OurFault => {
3282 debug!(%provider_addr, error = %err, "node-origin: upstream refused on OUR payment fault; exonerating and leaving it selectable");
3283 }
3284 }
3285 }
3286 // A broken signer, a bad encode, a bad range, a failed cache store — none of it
3287 // says anything about the peer, and a node in this state walks the whole candidate
3288 // list tarring every honest provider it meets with an `Unreachable` (a local EWMA
3289 // hit; ADR 008 scoring is local-only, with no cross-node propagation) on the
3290 // strength of its own defect. `warn!`, not `debug!`: a node that cannot sign or
3291 // store cannot complete a pull, so this is operator-actionable — and it is about US.
3292 PullVerdict::OurLocalFault => {
3293 deps.metrics.node_pull_local_fault();
3294 warn!(
3295 %provider_addr, error = %format_args!("{err:#}"),
3296 "node-origin: LOCAL fault during a pull (signer/encode/range/store) — not the \
3297 upstream's; exonerating it"
3298 );
3299 }
3300 PullVerdict::LegNoProgress => {
3301 if let Some(stuck) = err.downcast_ref::<LegNoProgress>() {
3302 warn_leg_no_progress(&deps.metrics, hash_bytes, Some((pk, provider_addr)), stuck);
3303 }
3304 suppress(Some(REFUSAL_SUPPRESSION_TTL));
3305 }
3306 PullVerdict::Corruption => {
3307 debug!(peer = %pk, %provider_addr, error = %err, "node-origin: upstream served corrupt bytes; scoring corruption");
3308 record_outcome(deps, pk, &Outcome::Corruption);
3309 }
3310 PullVerdict::Unreachable => {
3311 debug!(peer = %pk, %provider_addr, error = %format_args!("{err:#}"), "node-origin: upstream pull failed; scoring unreachable");
3312 record_outcome(deps, pk, &Outcome::Unreachable);
3313 }
3314 }
3315 verdict
3316}
3317
3318/// Meter and log a [`LegNoProgress`] (#2194): a leg that finished cleanly but moved
3319/// neither frontier. `provider` is the upstream's key and operator address, or
3320/// `None` for an own-origin leg. The log carries the range, both frontiers and the
3321/// channel's paid wire, which tell a store fault from a ledger fault.
3322pub(super) fn warn_leg_no_progress(
3323 metrics: &crate::metrics::Metrics,
3324 hash_bytes: [u8; 32],
3325 provider: Option<(PublicKey, Address)>,
3326 stuck: &LegNoProgress,
3327) {
3328 metrics.node_pull_leg_no_progress();
3329 warn!(
3330 hash = %DhtHash::from_bytes(hash_bytes),
3331 provider = ?provider.map(|(pk, _)| pk),
3332 provider_addr = ?provider.map(|(_, addr)| addr),
3333 offset = stuck.offset,
3334 len = stuck.len,
3335 paid_frontier = stuck.paid_frontier,
3336 delivered_frontier = stuck.delivered_frontier,
3337 paid_wire = stuck.paid_wire,
3338 "node-origin: a clean leg advanced neither the paid nor the delivered frontier; \
3339 ending the gap instead of re-paying the range",
3340 );
3341}
3342
3343/// The local reputation for `pk`, as the `f32` the selection score consumes.
3344/// Reputation is local-only (ADR 008): each node ranks peers from its own
3345/// observations, with no network aggregation. An unseen peer scores neutral
3346/// (local `initial_score`), so a cold provider ranks neither favoured nor
3347/// excluded (ADR 008 §Cold-Start).
3348#[allow(clippy::cast_possible_truncation)] // reputation ∈ [0,1]; f32 has ample precision for a ranking weight.
3349fn peer_reputation(deps: &NodeOriginDeps, pk: PublicKey) -> f32 {
3350 deps.local_rep.score(pk) as f32
3351}
3352
3353/// Tracing target of the per-peer reputation event [`record_outcome`] emits on
3354/// every recorded outcome. The `decdn_node_pull_{success,unreachable,corruption}_total`
3355/// counters are aggregates; this event names the peer and outcome behind each fold,
3356/// including ADR 030 region penalties, which have no counter, and the score the peer
3357/// now holds. Idle decay moves scores without an event. It logs at `debug!`, because
3358/// a dead peer is re-probed on every cache miss. Enable it alone with
3359/// `RUST_LOG=info,decdn::reputation=debug`. `RUST_LOG` applies at startup only: a
3360/// config reload replaces the filter with `observability.log_level` and drops the
3361/// per-target setting.
3362const REPUTATION_LOG_TARGET: &str = "decdn::reputation";
3363
3364/// Fold a pull/probe outcome into the local EWMA reputation score (ADR 008
3365/// §Local Score Calculation) and bump the matching delivery metric.
3366fn record_outcome(deps: &NodeOriginDeps, pk: PublicKey, outcome: &Outcome) {
3367 fold_outcome(&deps.local_rep, &deps.metrics, pk, outcome);
3368}
3369
3370/// The body of [`record_outcome`], taking the reputation table and the metrics
3371/// directly so the fold, the metric, and the attribution event are testable
3372/// without a full [`NodeOriginDeps`].
3373fn fold_outcome(local_rep: &LocalReputation, metrics: &Metrics, pk: PublicKey, outcome: &Outcome) {
3374 let score = local_rep.record(pk, *outcome);
3375 debug!(
3376 target: REPUTATION_LOG_TARGET,
3377 peer = %pk, ?outcome, score,
3378 "node-origin: reputation outcome recorded"
3379 );
3380 match *outcome {
3381 Outcome::Delivered { .. } => metrics.node_pull_success(),
3382 Outcome::Corruption => metrics.node_pull_corruption(),
3383 Outcome::Unreachable => metrics.node_pull_unreachable(),
3384 // Region-latency mismatch (ADR 030) folds into the local score above but
3385 // has no delivery metric; `Outcome` is `#[non_exhaustive]`, so any future
3386 // variant likewise records into the score without a metric until an
3387 // explicit arm lands.
3388 _ => {}
3389 }
3390}
3391
3392/// Round-trip-time milliseconds as a saturating `u32` for the selection score.
3393/// A sub-millisecond RTT truncates to 0; the score applies its 1 ms floor.
3394#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // RTT ≥ 0; saturated below u32::MAX.
3395fn ms_to_u32(rtt_ms: f64) -> u32 {
3396 if rtt_ms >= f64::from(u32::MAX) {
3397 u32::MAX
3398 } else if rtt_ms <= 0.0 {
3399 0
3400 } else {
3401 rtt_ms as u32
3402 }
3403}
3404
3405/// Current Unix time in microseconds for probe/stream request correlation. A
3406/// pre-epoch clock saturates to `0` (the server's recency check rejects it)
3407/// rather than panicking.
3408fn now_micros() -> u64 {
3409 SystemTime::now()
3410 .duration_since(UNIX_EPOCH)
3411 .map_or(0, |d| u64::try_from(d.as_micros()).unwrap_or(u64::MAX))
3412}
3413
3414#[cfg(test)]
3415#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
3416mod tests;