Skip to main content

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, &region, 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;