Skip to main content

decdn_node/handlers/
probe.rs

1//! `cdn/probe/v1` handler — unauthenticated latency + rate probe.
2
3use std::num::NonZeroUsize;
4use std::sync::Arc;
5use std::time::Duration;
6
7use alloy::dyn_abi::Eip712Domain;
8use alloy::primitives::B256;
9use alloy::signers::local::PrivateKeySigner;
10use decdn_cache::{CacheEngine, Hash, ProbeHoldOutcome};
11use decdn_incentive::ProbeSlashData;
12use decdn_protocol::{
13    ALPN_PROBE, APP_ERR_RATE_LIMITED, Coverage, FrameError, ProbeMessage, ProbeResponseBody,
14    ProbeResponseExt, decode_message, encode_probe_response, is_unknown_variant,
15    message::ProbeResponse, num_blocks, read_frame, write_frame,
16};
17use iroh::PublicKey;
18use iroh::endpoint::{Connection, RecvStream, SendStream, VarInt};
19use iroh::protocol::{AcceptError, ProtocolHandler};
20
21use crate::dht::routing::NodeId;
22use crate::dht::staker_set::StakerSet;
23use crate::dispatch::{ConnectionLimiter, RejectReason};
24use crate::handlers::probe_rate_limit::{ProbeRateLimiter, ProbeRejectLayer};
25use crate::metrics::{Metrics, ProbeHoldUnavailableReason};
26use crate::warn_throttle::WarnThrottle;
27
28// Server-side timeouts. Each ceiling exists so a single peer cannot pin a
29// handler task indefinitely by stalling at one of the protocol's ordered
30// steps.
31const ACCEPT_BI_TIMEOUT: Duration = Duration::from_secs(5);
32const PROBE_READ_TIMEOUT: Duration = Duration::from_secs(5);
33const PROBE_CLOSE_TIMEOUT: Duration = Duration::from_secs(3);
34/// Bound on the post-rejection close-frame flush. The QUIC `CONNECTION_CLOSE`
35/// frame is best-effort; we wait briefly for the peer to acknowledge so the
36/// `0x10 RATE_LIMITED` reason byte (and its layer label) reach them, but cap
37/// the wait so a malicious flooder can't keep the handler alive by refusing
38/// to acknowledge.
39const REJECTION_CLOSE_TIMEOUT: Duration = Duration::from_millis(250);
40
41/// Minimum gap between probe request read-fault `warn!` lines. Each line
42/// carries the offending `peer` and the `suppressed` count.
43const PROBE_READ_FAULT_WARN_INTERVAL: Duration = Duration::from_mins(1);
44
45// QUIC application error codes defined by ADR 013 §Application Error Codes.
46//
47// `APP_ERR_NO_ERROR = 0` is the implicit QUIC "no app error" default and is
48// what the handler returns for transport-level conditions (read timeout,
49// mid-frame EOF, peer reset) where no protocol fault occurred. Named to
50// avoid magic-number drift; #577 M1 motivated splitting it out from
51// `APP_ERR_MALFORMED_MESSAGE` so peers don't apply protocol-fault backoff
52// to a transport drop.
53const APP_ERR_NO_ERROR: u32 = 0x00;
54const APP_ERR_UNSUPPORTED_MESSAGE: u32 = 0x01;
55const APP_ERR_MESSAGE_TOO_LARGE: u32 = 0x02;
56const APP_ERR_MALFORMED_MESSAGE: u32 = 0x03;
57
58/// Stake-lane probe-acceptance policy (#757, ADR 003 §Admission and
59/// Priority). Reserves probe-hold headroom for stake-lane requesters —
60/// registered operators issuing node-to-node cache-miss probes — so
61/// end-client probe load cannot starve them under hold-budget pressure.
62///
63/// "Stake lane" is the `CapacityBond.isActive` predicate (registered + bond
64/// ≥ minBond + not unbonding + not ejected), resolved from the same
65/// chain-followed [`StakerSet`] the DHT handler consumes — no per-probe
66/// chain read and no new chain wiring (the issue's constraint). It is a
67/// faithful binary reading of ADR 003's `bondOf >= bond_required(...)`
68/// prioritization; finer capacity-scaled bond tiering is out of scope.
69///
70/// Construction is gated by the operator: the runtime only builds this when
71/// `cache.stake_lane_reserved_holds > 0`, so a single-lane deployment passes
72/// `None` and the handler skips the stake-lane check entirely.
73#[derive(Clone)]
74pub struct StakeLanePolicy {
75    /// Chain-followed active-staker set, keyed by iroh `NodeId`. The probe
76    /// requester's id (`conn.remote_id()`) is looked up directly — the
77    /// `CapacityBond` holds the NodeId↔operator binding, so a `true` here
78    /// means the requester is a registered, sufficiently-bonded operator.
79    staker_set: Arc<dyn StakerSet>,
80    /// Number of hold slots reserved for the stake lane. End-client probes
81    /// are shed once `probe_hold_slots_used >= max_holds.saturating_sub(reserved_holds)`
82    /// (the `saturating_sub` makes `reserved_holds >= max_holds` clamp the
83    /// end-client ceiling to `0`, i.e. "stake lane only"). Typed
84    /// [`NonZeroUsize`] so the "reservation off" case is *unrepresentable*
85    /// here — it is encoded as `None` at the call site — rather than relying
86    /// on a documented `> 0` convention.
87    reserved_holds: NonZeroUsize,
88    /// The configured `max_probe_holds` budget — the ceiling the reservation
89    /// is subtracted from. Carried here (rather than re-read from the cache)
90    /// because `max_probe_holds` is restart-required, so the value is stable
91    /// for the handler's lifetime.
92    max_holds: usize,
93}
94
95impl std::fmt::Debug for StakeLanePolicy {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.debug_struct("StakeLanePolicy")
98            .field("reserved_holds", &self.reserved_holds)
99            .field("max_holds", &self.max_holds)
100            .finish_non_exhaustive()
101    }
102}
103
104impl StakeLanePolicy {
105    /// Build a policy. `reserved_holds` is [`NonZeroUsize`] so the "off" case
106    /// (a zero reservation) cannot be constructed — callers encode it as
107    /// `None`. `max_holds` is the configured `max_probe_holds`.
108    #[must_use]
109    pub fn new(
110        staker_set: Arc<dyn StakerSet>,
111        reserved_holds: NonZeroUsize,
112        max_holds: usize,
113    ) -> Self {
114        Self {
115            staker_set,
116            reserved_holds,
117            max_holds,
118        }
119    }
120}
121
122/// Serves `cdn/probe/v1`: reads a framed [`ProbeMessage::Request`], writes a
123/// framed [`ProbeMessage::Response`].
124///
125/// `rate_per_mb` is the served price, fixed at startup. Reprice by restarting
126/// the daemon (see `runtime::reload::warn_restart_required_sections`).
127pub struct ProbeHandler {
128    node_id: PublicKey,
129    rate_per_mb: u64,
130    metrics: Arc<Metrics>,
131    limiter: Arc<ConnectionLimiter>,
132    /// ADR 005 §Probe rate limiting three-layer token-bucket limiter
133    /// (global → per-IP → per-peer). Runs in addition to `limiter` (the
134    /// connection-level [`ConnectionLimiter`]) — see
135    /// [`crate::handlers::probe_rate_limit`] for why both layers run.
136    probe_rate_limiter: Arc<ProbeRateLimiter>,
137    /// Cache engine — queried for blob presence and the probe-triggered
138    /// eviction hold (ADR 005 §Probe-triggered eviction hold).
139    cache: CacheEngine,
140    /// Operator Ethereum key used to produce the EIP-712 `slash_sig`
141    /// (#406 loads it; ADR 014 §1 mandates it on every response).
142    eth_signer: Arc<PrivateKeySigner>,
143    /// `SlashJudge` EIP-712 domain, built once from
144    /// `blockchain.{slash_judge_address,chain_id}`.
145    slash_domain: Eip712Domain,
146    /// Optional stake-lane probe-acceptance reservation (#757). `None` (the
147    /// single-lane default) admits every hold from one shared budget with no
148    /// reserved headroom; `Some` reserves hold headroom for registered
149    /// node-to-node requesters under budget pressure.
150    stake_lane: Option<StakeLanePolicy>,
151    /// Mirrors `ResolvedCache::relay_foreign_namespaces` (ADR 002 origin-only
152    /// node policy). `true` (the default) is the unchanged relay path below.
153    /// `false` restricts `has_blob` to backend-held content only — a
154    /// store-present blob it will now decline to relay is never advertised —
155    /// so a foreign decline never reads as "advertised but didn't serve".
156    relay_foreign_namespaces: bool,
157    /// Liveness of the node's chain reads (ADR 011 § Serving while chain-stale).
158    /// `Some` only when the blacklist watcher is wired; while it reads stale the
159    /// handler answers `has_blob: false` for every hash rather than sign a
160    /// `ProbeResponse` advertising content whose takedown status it can no longer
161    /// confirm. `None` (dev/test, no chain) leaves the presence answer unchanged.
162    chain_freshness: Option<crate::chain_freshness::ChainFreshness>,
163    /// Throttle for the `warn!` on a probe request that times out, fails to
164    /// frame or decode, or arrives as the wrong message. The probe ALPN is
165    /// unauthenticated, so any peer triggers it at will.
166    read_fault_warn: WarnThrottle,
167}
168
169impl std::fmt::Debug for ProbeHandler {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        f.debug_struct("ProbeHandler")
172            .field("node_id", &self.node_id)
173            .field("rate_per_mb", &self.rate_per_mb)
174            .finish_non_exhaustive()
175    }
176}
177
178impl ProbeHandler {
179    /// The ALPN this handler answers on: `cdn/probe/v1`, the unauthenticated
180    /// latency and availability probe.
181    pub const ALPN: &'static [u8] = ALPN_PROBE;
182
183    /// Build the probe handler from the runtime state it answers out of: the
184    /// node's identity and advertised rate, the rate limiters that gate an
185    /// unauthenticated caller, and the signer that attests each response.
186    #[allow(clippy::missing_const_for_fn)] // Arc::new isn't const.
187    #[allow(clippy::too_many_arguments)] // wiring struct; each arg is distinct runtime state.
188    pub fn new(
189        node_id: PublicKey,
190        rate_per_mb: u64,
191        metrics: Arc<Metrics>,
192        limiter: Arc<ConnectionLimiter>,
193        probe_rate_limiter: Arc<ProbeRateLimiter>,
194        cache: CacheEngine,
195        eth_signer: Arc<PrivateKeySigner>,
196        slash_domain: Eip712Domain,
197        stake_lane: Option<StakeLanePolicy>,
198        relay_foreign_namespaces: bool,
199        chain_freshness: Option<crate::chain_freshness::ChainFreshness>,
200    ) -> Self {
201        Self {
202            node_id,
203            rate_per_mb,
204            metrics,
205            limiter,
206            probe_rate_limiter,
207            cache,
208            eth_signer,
209            slash_domain,
210            stake_lane,
211            relay_foreign_namespaces,
212            chain_freshness,
213            read_fault_warn: WarnThrottle::new(PROBE_READ_FAULT_WARN_INTERVAL),
214        }
215    }
216
217    /// The advertised `total_bytes` for a blob already established as present.
218    ///
219    /// `total_bytes` is an unsigned, optional hint outside the `slash_sig`
220    /// typehash, so `None` degrades cleanly to "unknown but fetchable" — no
221    /// consumer treats it as a presence signal. An `Err` here is still worth a
222    /// log: every caller has *just* proven the blob present, so a failing
223    /// `inspect` means the store started degrading in between, which is a
224    /// strictly stronger signal than the plain miss the `Err` arms warn about.
225    async fn inspect_size(&self, hash: Hash) -> Option<u64> {
226        match self.cache.inspect(hash).await {
227            Ok(preview) => preview.size_bytes,
228            Err(e) => {
229                tracing::warn!(
230                    error = %e.display_chain(),
231                    %hash,
232                    "inspect failed for a blob just proven present; advertising without a size"
233                );
234                None
235            }
236        }
237    }
238
239    // Linear, ordered protocol sequence (limit → accept → read → hold →
240    // clamp → sign → write → close). Splitting it would scatter the ADR-013
241    // app-error-code mapping and the ADR-005 ordering invariants across
242    // helpers and make them harder to audit against the spec — same
243    // rationale as the `read_probe_request` cognitive-complexity allow.
244    #[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
245    async fn serve(&self, conn: Connection) -> anyhow::Result<()> {
246        let _permit = match self.limiter.acquire(&conn) {
247            Ok(p) => p,
248            Err(reason) => {
249                // Rate-limited rejection is normal load-shedding, not a
250                // protocol fault: returning `Err` here would have iroh log
251                // every rejection as an `AcceptError`, amplifying log
252                // volume under flood (exactly what the attacker wants).
253                // The dispatch layer already emits a structured debug log
254                // and a metric counter for the rejection.
255                conn.close(
256                    VarInt::from_u32(APP_ERR_RATE_LIMITED),
257                    reason.as_str().as_bytes(),
258                );
259                // Wait for the close frame to be acknowledged so the peer
260                // reliably observes the 0x10 RATE_LIMITED code and the
261                // layer-label reason byte — except on `GlobalFull`. Under
262                // a global-cap flood every rejection would otherwise
263                // park a task here for up to `REJECTION_CLOSE_TIMEOUT`,
264                // and at thousands of rejections per second that is the
265                // memory-pressure path the limiter exists to prevent.
266                // Layer label is least useful for `GlobalFull` anyway
267                // (operators pivot on the metric counter, not the close
268                // reason byte). Per-source rejections are rate-limited
269                // by the bucket itself, so the bounded wait is safe.
270                if reason != RejectReason::GlobalFull {
271                    let _ = tokio::time::timeout(REJECTION_CLOSE_TIMEOUT, conn.closed()).await;
272                }
273                return Ok(());
274            }
275        };
276
277        // ADR 005 §Probe rate limiting: three-layer token-bucket limiter
278        // (global → per-IP → per-peer) applied *before* any signature is
279        // computed and *before* any eviction-hold slot is allocated. It runs in
280        // addition to the `ConnectionLimiter` permit above (per-source IP +
281        // global concurrency) — see `probe_rate_limit` module docs for why
282        // both run. Both the requester NodeId and the source IP are available
283        // pre-stream, so a rejected probe never opens a bidi stream.
284        let requester = NodeId::from_bytes(*conn.remote_id().as_bytes());
285        let peer_ip = crate::rate_limit::peer_ip(&conn);
286        if let Err(layer) = self.probe_rate_limiter.check(&requester, peer_ip) {
287            // Load-shedding, not a protocol fault: don't return `Err` (which
288            // iroh would log per rejection, amplifying log volume under flood —
289            // exactly what the attacker wants). The limiter already bumped the
290            // per-layer rejection counter; emit a debug log too so operators
291            // can correlate the offending peer/IP, matching the per-source
292            // (`ConnectionLimiter::acquire_inner`) and DHT
293            // (`close_stream_with_rate_limit`) reject paths.
294            tracing::debug!(
295                layer = layer.as_str(),
296                requester = ?requester,
297                peer_ip = ?peer_ip,
298                "probe request rejected by the three-layer rate limiter"
299            );
300            conn.close(
301                VarInt::from_u32(APP_ERR_RATE_LIMITED),
302                layer.as_str().as_bytes(),
303            );
304            // Wait briefly for the close frame so the peer observes the 0x10
305            // RATE_LIMITED code and the layer label — except on the global
306            // layer, where a flood would otherwise park a task per rejection
307            // (operators pivot on the metric counter, not the close reason,
308            // for a global flood). Per-peer/per-IP rejections are bounded by
309            // their own buckets, so the capped wait is safe.
310            if layer != ProbeRejectLayer::Global {
311                let _ = tokio::time::timeout(REJECTION_CLOSE_TIMEOUT, conn.closed()).await;
312            }
313            return Ok(());
314        }
315
316        let _guard = self.metrics.connection_guard();
317
318        // ADR 005 caps probe at 1 bidi stream per connection; the transport-level
319        // cap is the union across ALPNs, so probe's tighter bound is enforced
320        // here by accepting exactly one stream and then closing.
321        let (mut send, mut recv) = tokio::time::timeout(ACCEPT_BI_TIMEOUT, conn.accept_bi())
322            .await
323            .map_err(|_| anyhow::anyhow!("accept_bi timed out after {ACCEPT_BI_TIMEOUT:?}"))?
324            .map_err(|e| anyhow::anyhow!("accept_bi failed: {e}"))?;
325
326        let req = match read_probe_request(&mut send, &mut recv).await {
327            Ok(req) => req,
328            Err(ProbeReadError { err, app_code }) => {
329                self.metrics.probe_read_fault();
330                if let Some(suppressed) = self.read_fault_warn.admit() {
331                    tracing::warn!(
332                        peer = %conn.remote_id(),
333                        app_code,
334                        error = %err,
335                        suppressed,
336                        interval = ?self.read_fault_warn.interval(),
337                        "probe request read failed"
338                    );
339                }
340                // ADR 013 scopes app error codes to streams, but probe is 1:1
341                // connection:stream — also close the connection with the same
342                // code so the peer observes it deterministically even if the
343                // stream RESET racing with connection teardown gets clobbered.
344                conn.close(VarInt::from_u32(app_code), b"probe-error");
345                // Handled: the peer has its close code and the fault is counted
346                // and logged above. An `Err` here would make iroh's router log
347                // it again, unthrottled, once per bad probe from any peer.
348                return Ok(());
349            }
350        };
351
352        let hash = Hash::from_bytes(req.hash);
353
354        // Stake-lane probe-acceptance reservation (#757, ADR 003 §Admission
355        // and Priority). Under hold-budget pressure, reserve the last
356        // `reserved_holds` slots for stake-lane requesters (registered
357        // operators issuing node-to-node cache-miss probes) so end-client
358        // load cannot starve them. Evaluated *before* `try_probe_hold` and
359        // gated by `self.stake_lane`, so it is a strict no-op for
360        // single-lane operators (the `None` default). Soft and best-effort:
361        // the slots-used read races a concurrent hold, which is acceptable
362        // for a priority heuristic (admission is implementation-defined per
363        // ADR 003) and never a safety property.
364        let stake_lane_reserved = match self.stake_lane.as_ref() {
365            Some(policy) => {
366                let used = self.cache.probe_hold_slots_used();
367                // `is_stake_lane` is resolved lazily: `stake_lane_reserved_out`
368                // calls it only after the cheap ceiling guards pass, so the
369                // uncongested common path skips the staker-set lookup (an
370                // RwLock read + NodeId conversion) entirely (#757 review).
371                stake_lane_reserved_out(policy.reserved_holds.get(), policy.max_holds, used, || {
372                    let requester = NodeId::from_bytes(*conn.remote_id().as_bytes());
373                    policy.staker_set.is_active(&requester)
374                })
375            }
376            None => false,
377        };
378
379        // Tracks whether the eventual `has_blob: true` (if any) is store- or
380        // origin-sourced, and whether the operator has explicitly opted the
381        // store out of advertisement (`max_probe_holds == 0`) — both feed the
382        // final coverage derivation below, after `has_blob`/`total_bytes`
383        // settle. `store_present` mirrors the store branch's own verdict
384        // *before* the origin fold below can flip a `false` to `true`, so a
385        // post-fold `true` with `!store_present` unambiguously means "the
386        // origin, not the store, is why we can serve this".
387        let mut store_present = false;
388        let mut holds_disabled = false;
389
390        // Chain-staleness gate (ADR 011 §Serving while chain-stale). A
391        // `has_blob: true` is signed slash evidence (ADR 011 §Slash evidence), and
392        // while the node cannot reach the chain it cannot confirm this hash was not
393        // taken down during the blind window. When stale the node advertises
394        // nothing: `has_blob: false` is a reputation-benign true negative, so a
395        // client routes to a peer whose reads are live — the same posture the
396        // serve-admit gate in `dispatch.rs` takes. `None` (no chain wired —
397        // dev/test) leaves the answer unchanged. Read once and applied in two
398        // places: it short-circuits the presence computation below (no store read,
399        // no origin fold, no live HEAD), AND forces the final coverage empty, since
400        // `has_blob` is re-derived from `coverage` — gating only the presence
401        // computation would let the coverage read re-affirm the held blob.
402        let chain_stale = self
403            .chain_freshness
404            .as_ref()
405            .is_some_and(crate::chain_freshness::ChainFreshness::is_stale);
406
407        let (has_blob, total_bytes) = if chain_stale {
408            (false, None)
409        } else if self.relay_foreign_namespaces {
410            // Probe-triggered eviction hold (ADR 005 §Probe-triggered eviction
411            // hold). The hold is **best-effort**: a node answers `has_blob: true`
412            // whenever it holds the blob and has not refused it, then keeps a 35s
413            // eviction hold *if it can* so the blob is still resident for the
414            // follow-up pull. Budget pressure or a stake-lane reservation only means
415            // "place no hold" — never "deny an honest answer", so a probe flood that
416            // fills the hold cache can never suppress a truthful `has_blob`.
417            //
418            // What an unheld advertisement actually costs, if the blob is
419            // LRU-evicted before the pull lands: one wasted round trip. The delivery
420            // path answers a plain `NotFound` and the requester goes elsewhere. It
421            // is NOT a slash (no offense pairs a probe with a miss — ADR 014), and
422            // it is NOT a reputation penalty either: `classify_refusal` rates
423            // `NotFound` as `RefusalVerdict::Transient`, which briefly suppresses
424            // the (peer, hash) pair *without* scoring the peer, because a miss is
425            // not attributable to it (see `node_origin.rs`). Do not justify this
426            // path by reputation — nothing records an outcome for it.
427            //
428            // The hold is attempted *after* the rate limiter (ADR 005 §Probe rate
429            // limiting: hold admission occurs only after the limiter passes — do not
430            // reorder).
431            let (has_blob, total_bytes) = if stake_lane_reserved {
432                // End-client shed to protect stake-lane hold headroom (#757): place
433                // no hold, but still answer honestly — presence does not depend on a
434                // slot. `has()` already folds in `refuses()` (denied / chain-denied /
435                // operator-evicted), so it alone settles presence; the second
436                // `refuses` call is not a distinct predicate but a re-check that
437                // narrows the TOCTOU window the `await` opens, because signing
438                // `has_blob: true` for a blacklisted hash is a blacklist-violation
439                // offense (ADR 014).
440                self.metrics
441                    .probe_hold_unavailable(ProbeHoldUnavailableReason::StakeLaneReserved);
442                match self.cache.has(hash).await {
443                    Ok(true) if !self.cache.refuses(hash) => (true, self.inspect_size(hash).await),
444                    Ok(_) => (false, None),
445                    // Same reasoning as the `try_probe_hold` `Err` arm below: a store
446                    // fault is not "absent" and must not be silently indistinguishable
447                    // from one. This path runs only under hold pressure — precisely
448                    // when a degrading backend is most worth surfacing.
449                    Err(e) => {
450                        tracing::warn!(
451                            error = %e.display_chain(),
452                            %hash,
453                            "cache error during stake-lane shed presence check; \
454                             answering has_blob:false"
455                        );
456                        (false, None)
457                    }
458                }
459            } else {
460                match self.cache.try_probe_hold(hash).await {
461                    Ok(ProbeHoldOutcome::Held) => (true, self.inspect_size(hash).await),
462                    Ok(ProbeHoldOutcome::BudgetExhausted) => {
463                        // Present but every hold slot is live: advertise anyway and
464                        // forgo the hold. A probe flood that fills the best-effort
465                        // hold cache cannot suppress an honest answer — the blob is
466                        // here now (a `BudgetExhausted` outcome already means present
467                        // and not refused). If it is evicted before the pull, that
468                        // costs one wasted round trip (see the block comment above).
469                        // Raising `max_probe_holds` is still the remedy for the lost
470                        // holds (#739).
471                        self.metrics
472                            .probe_hold_unavailable(ProbeHoldUnavailableReason::Exhausted);
473                        (true, self.inspect_size(hash).await)
474                    }
475                    Ok(ProbeHoldOutcome::HoldsDisabled) => {
476                        // Holds disabled by config (`max_probe_holds == 0`) is the
477                        // operator's explicit opt-out from advertising *store-backed*
478                        // content: answer `has_blob: false` so this node is not
479                        // selected off the evictable store. Distinct from budget
480                        // pressure (which still advertises) — a deliberate choice,
481                        // not load (#739).
482                        //
483                        // It is not a blanket "answer false to everything": the
484                        // origin-held fallback below takes no hold and so is
485                        // unaffected by this setting. A node with a configured origin
486                        // still advertises origin-servable content with
487                        // `max_probe_holds == 0`.
488                        self.metrics
489                            .probe_hold_unavailable(ProbeHoldUnavailableReason::Disabled);
490                        holds_disabled = true;
491                        (false, None)
492                    }
493                    // Blob genuinely absent, operator-evicted, or refused
494                    // (blacklisted/denied) — a true negative, no signal needed.
495                    Ok(ProbeHoldOutcome::Unavailable) => (false, None),
496                    // A transient cache fault is *not* the same as "absent": the
497                    // node may actually hold the blob. We still conservatively
498                    // answer `has_blob: false` (a store it cannot read is one it
499                    // cannot serve), but a degrading backend must be
500                    // operator-visible rather than indistinguishable from a normal
501                    // miss. The registry has no metric for this; a warn log is the
502                    // actionable signal.
503                    Err(e) => {
504                        tracing::warn!(
505                            error = %e.display_chain(),
506                            "cache error during probe hold check; answering has_blob:false"
507                        );
508                        (false, None)
509                    }
510                }
511            };
512            store_present = has_blob;
513
514            // Origin-held fallback (#1130). If the store can't back a
515            // `has_blob: true` — the blob was never pulled into it, holds are
516            // disabled, or the store read faulted — but it is servable from a
517            // configured origin (fs directory entry, or a present pin), advertise it
518            // anyway so cold origin content is discoverable on the first probe
519            // rather than only after a warm.
520            //
521            // Origin content takes **no** eviction hold: it lives on disk / behind
522            // the origin, not in the evictable store, so hold-budget pressure and
523            // `max_probe_holds` are both irrelevant to it — which is why
524            // `max_probe_holds == 0` silences store-backed advertisements but not
525            // these. If the origin object later vanishes before the pull, the
526            // delivery path answers a plain miss and the requester retries
527            // elsewhere: one wasted round trip, not a slash (ADR 014) and not a
528            // reputation penalty (see the hold block comment above).
529            let (has_blob, total_bytes) =
530                fold_origin_held((has_blob, total_bytes), self.cache.origin_held_size(hash));
531            // Live-origin HEAD fallback (#1130 pt3). The index above covers fs
532            // enumeration ∪ pins; http/s3 do not list, so a non-pinned bucket object
533            // is invisible to it. When nothing has answered `true` yet, consult the
534            // origin directly (`HEAD`/`HeadObject`, no body) with a TTL memo so a
535            // non-pinned remote object is discoverable on the first probe rather than
536            // never. Skipped once `has_blob` is already true — no point paying a HEAD
537            // for a blob the store or the index already backs.
538            if has_blob {
539                (has_blob, total_bytes)
540            } else {
541                fold_origin_held(
542                    (has_blob, total_bytes),
543                    self.cache.origin_probe_size(hash).await,
544                )
545            }
546        } else {
547            // Origin-only nodes (ADR 002 origin-only node policy): advertise
548            // exactly what the serve gate will honor, which is backend-held
549            // content only. A store hit for a blob outside this node's own
550            // origin(s) must NOT be advertised here, or a later foreign-namespace
551            // serve decline reads as "advertised but didn't serve" (the reputation
552            // hazard this policy closes). This short-circuits the entire
553            // store-present / probe-hold / origin-held-index derivation above —
554            // presence is decided purely by the backend probe (index-or-live-HEAD,
555            // memoised) — and, like every other path here, still answers `false`
556            // for a `refuses`-listed hash (`origin_probe_size` checks it first).
557            match self.cache.origin_probe_size(hash).await {
558                Some(size) => (true, Some(size)),
559                None => (false, None),
560            }
561        };
562
563        // Final will-serve coverage (#1506): `has_blob` is redefined from
564        // "holds the whole blob" to "will serve at least one discovery
565        // block", so it is derived FROM `coverage` rather than the other way
566        // around — the two are a biconditional by construction, never two
567        // independently-set fields that could drift apart.
568        //
569        // - Origin-sourced (`has_blob` flipped true by the fold above, not by
570        //   the store branch): an origin serves every block and already
571        //   handed us the size via `total_bytes`, so it is all-ones.
572        // - Store-sourced or absent, and NOT the operator's explicit
573        //   `max_probe_holds == 0` opt-out: read the cache's own
574        //   cached-block bitmap. This is what upgrades a partial (non-
575        //   `Complete`) holder from the old `has_blob: false` to an honest
576        //   partial advertisement — `try_probe_hold`'s `has()` gate above
577        //   only recognizes `Complete` blobs, but a partial holder that
578        //   covers at least one block can still serve it.
579        // - `max_probe_holds == 0`: the operator opted the STORE out of
580        //   advertisement; honor that by forcing empty rather than letting a
581        //   cached partial leak back in under it (origin-held content is
582        //   unaffected — it never reaches this arm because it is already
583        //   `is_origin_sourced`).
584        let is_origin_sourced = has_blob && !store_present;
585        // The coverage and the size the response advertises, bound together: a
586        // partial holder reads its size from the same bitfield as its blocks
587        // (#2195), because the store branch above sizes only a `Complete` blob and
588        // ADR 005 asks a node to include the size whenever it knows it.
589        let (coverage, total_bytes) = if chain_stale {
590            // Chain-stale (above): advertise no coverage, so the re-derived
591            // `has_blob` below stays false. This is the arm that actually binds the
592            // stale answer — `has_blob` is the coverage biconditional.
593            (Coverage::empty(), total_bytes)
594        } else if is_origin_sourced {
595            (
596                total_bytes.map_or_else(Coverage::empty, |size| Coverage::full(num_blocks(size))),
597                total_bytes,
598            )
599        } else if holds_disabled || !self.relay_foreign_namespaces {
600            // `holds_disabled`: the operator's explicit store opt-out (above).
601            // `!self.relay_foreign_namespaces`: the origin-only policy (ADR
602            // 002, #1759) — presence there is decided purely by the backend
603            // probe, and a store hit for a blob outside this node's own
604            // origin must stay unadvertised (a later serve decline on it
605            // would read as "advertised but didn't serve", the reputation
606            // hazard that policy closes). Consulting the cache's coverage
607            // here would leak exactly that hit back in.
608            (Coverage::empty(), total_bytes)
609        } else {
610            match self.cache.coverage_sized(hash).await {
611                Ok((c, size)) => (c, total_bytes.or(size)),
612                Err(e) => {
613                    tracing::warn!(
614                        error = %e.display_chain(),
615                        %hash,
616                        "cache error deriving probe coverage; advertising none"
617                    );
618                    (Coverage::empty(), total_bytes)
619                }
620            }
621        };
622        let has_blob = !coverage.is_empty();
623        debug_assert_eq!(
624            has_blob,
625            !coverage.is_empty(),
626            "has_blob must be the coverage biconditional by construction"
627        );
628
629        // The node advertises its configured rate verbatim. The delivery floor
630        // is not a quote gate: a rate below the floor still sells and settles,
631        // and `PaymentPool.redeem` clamps only the vote-weight byte credit for
632        // sub-floor bytes (ADR 003 § Rate-floor enforcement).
633        let rate_per_mb = self.rate_per_mb;
634        let body = ProbeResponseBody {
635            hash: req.hash,
636            has_blob,
637            rate_per_mb,
638            timestamp_us: req.timestamp_us,
639        };
640        // EIP-712 secp256k1 slash_sig over the frozen signed set
641        // {hash, has_blob, rate_per_mb, timestamp_us} (ADR 014 §1). Mandatory
642        // and non-empty on every response — a signing failure fails the
643        // handler rather than emitting an unsigned response a requester MUST
644        // reject anyway.
645        let slash_data = ProbeSlashData {
646            hash: B256::from(req.hash),
647            has_blob,
648            rate_per_mb,
649            timestamp_us: req.timestamp_us,
650        };
651        let slash_sig = match slash_data.sign(self.eth_signer.as_ref(), &self.slash_domain) {
652            Ok(sig) => sig.as_bytes().to_vec(),
653            Err(e) => {
654                // Operator-actionable infra fault (key locked, remote/HSM
655                // signer offline). iroh logs a failed handler at warn level
656                // with only the top-level message, so log it at error level
657                // here before failing the handler (we must NOT emit an
658                // unsigned response, ADR 014 §1).
659                tracing::error!(
660                    error = %e,
661                    "probe slash_sig signing failed (eth signer unavailable?); \
662                     failing probe"
663                );
664                return Err(anyhow::anyhow!("probe slash_sig signing failed: {e}"));
665            }
666        };
667
668        let resp = ProbeResponse { body, slash_sig };
669        // `total_bytes` and `coverage` are unsigned, so they ride in the trailing
670        // extension rather than the signed base (ADR 013 §Tier 1). Two-phase
671        // encode: a receiver that predates a future extension field stops at the
672        // end of the base.
673        let ext = ProbeResponseExt {
674            total_bytes,
675            coverage,
676        };
677
678        let payload = encode_probe_response(&resp, Some(&ext))
679            .map_err(|e| anyhow::anyhow!("probe encode failed: {e}"))?;
680        write_frame(&mut send, &payload)
681            .await
682            .map_err(|e| anyhow::anyhow!("probe response write failed: {e}"))?;
683        send.finish()
684            .map_err(|e| anyhow::anyhow!("probe stream finish failed: {e}"))?;
685
686        self.metrics.probe_request();
687        // Wait for the client's close so the response bytes are flushed to the
688        // peer, but cap the wait so an idle/malicious client can't hold the
689        // connection (and inflate active_connections) forever.
690        let _ = tokio::time::timeout(PROBE_CLOSE_TIMEOUT, conn.closed()).await;
691        conn.close(0u32.into(), b"probe-done");
692        Ok(())
693    }
694}
695
696impl ProtocolHandler for ProbeHandler {
697    async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
698        self.serve(connection)
699            .await
700            .map_err(|e| AcceptError::from_boxed(e.into()))
701    }
702}
703
704const fn frame_err_code(e: &FrameError) -> u32 {
705    // Match every variant explicitly so a future `#[non_exhaustive]` /
706    // new variant fails the build instead of being silently collapsed
707    // into `APP_ERR_MALFORMED_MESSAGE`.
708    match e {
709        FrameError::TooLarge(_) => APP_ERR_MESSAGE_TOO_LARGE,
710        // #577 M1: transport-level fault (short read, peer reset,
711        // mid-frame EOF) is NOT a protocol fault. Mirrors the
712        // timeout-path convention in `read_probe_request` — peers must
713        // not apply MALFORMED-class backoff/penalty for a dropped
714        // connection.
715        FrameError::Io(_) => APP_ERR_NO_ERROR,
716        FrameError::Varint | FrameError::Decode(_) => APP_ERR_MALFORMED_MESSAGE,
717    }
718}
719
720/// Decide whether an end-client (non-stake-lane) probe should be refused a
721/// probe hold to preserve reserved headroom for stake-lane requesters —
722/// registered operators issuing node-to-node cache-miss probes (#757, ADR
723/// 003 §Admission and Priority).
724///
725/// Returns `true` only when ALL of: a reservation is configured
726/// (`reserved > 0`), holds are enabled (`max_holds > 0`), current hold usage
727/// has reached the end-client ceiling (`slots_used >= max_holds - reserved`),
728/// and the requester is **not** in the stake lane. The last `reserved` slots
729/// are thereby kept available for the stake lane.
730///
731/// `is_stake_lane` is evaluated **lazily** and only after the three cheap
732/// guards above have passed, so callers can pass a closure that performs the
733/// staker-set lookup (an `RwLock` read + `NodeId` conversion) and have it
734/// skipped entirely on the uncongested common path (#757 review).
735///
736/// `reserved == 0` is the default and makes this an unconditional no-op so
737/// single-lane operators are unaffected. The `max_holds == 0` short-circuit
738/// keeps an intentional holds-disabled config (which `try_probe_hold` maps
739/// to [`ProbeHoldOutcome::HoldsDisabled`]) from being mis-attributed to a
740/// reservation refusal.
741///
742/// This is a **soft, best-effort** gate: `slots_used` is sampled before the
743/// atomic `try_probe_hold`, so a concurrent hold may still cross the
744/// boundary. That is acceptable for a priority heuristic — it is never a
745/// safety property (ADR 003 frames admission as implementation-defined).
746fn stake_lane_reserved_out(
747    reserved: usize,
748    max_holds: usize,
749    slots_used: usize,
750    is_stake_lane: impl FnOnce() -> bool,
751) -> bool {
752    reserved > 0
753        && max_holds > 0
754        && slots_used >= max_holds.saturating_sub(reserved)
755        && !is_stake_lane()
756}
757
758/// Error from the probe-request read path carrying the ADR 013 app error
759/// code the handler should propagate to the peer.
760struct ProbeReadError {
761    err: anyhow::Error,
762    app_code: u32,
763}
764
765/// Reads one framed `ProbeMessage::Request` from `recv`. On failure, also
766/// resets/stops the streams with the appropriate ADR 013 app error code so
767/// long-lived (future) multi-stream connections can keep running; the caller
768/// additionally closes the whole connection for probe's 1:1 topology.
769///
770/// The cognitive-complexity allowance reflects that splitting this further
771/// would spread the ADR 013 error-code mapping across multiple helpers,
772/// making it harder to audit against the spec.
773#[allow(clippy::cognitive_complexity)]
774async fn read_probe_request(
775    send: &mut SendStream,
776    recv: &mut RecvStream,
777) -> Result<decdn_protocol::message::ProbeRequest, ProbeReadError> {
778    let reset = |send: &mut SendStream, recv: &mut RecvStream, code: u32| {
779        let v = VarInt::from_u32(code);
780        // reset/stop may fail if stream already closed by peer; ignore.
781        let _ = send.reset(v);
782        let _ = recv.stop(v);
783    };
784
785    let frame = match tokio::time::timeout(PROBE_READ_TIMEOUT, read_frame(recv)).await {
786        Err(_) => {
787            // ADR 013 defines no timeout-specific code; use the named
788            // `APP_ERR_NO_ERROR` constant (same convention now applied
789            // to `FrameError::Io(_)` per #577 M1).
790            reset(send, recv, APP_ERR_NO_ERROR);
791            return Err(ProbeReadError {
792                err: anyhow::anyhow!("probe request timed out after {PROBE_READ_TIMEOUT:?}"),
793                app_code: APP_ERR_NO_ERROR,
794            });
795        }
796        Ok(Err(e)) => {
797            let app_code = frame_err_code(&e);
798            reset(send, recv, app_code);
799            return Err(ProbeReadError {
800                err: anyhow::anyhow!("probe frame read failed: {e}"),
801                app_code,
802            });
803        }
804        Ok(Ok(frame)) => frame,
805    };
806
807    match decode_message::<ProbeMessage>(&frame) {
808        Err(e) => {
809            // ADR 013: an unknown enum discriminant is a Tier-2
810            // graceful-evolution signal — close with UNSUPPORTED_MESSAGE
811            // (0x01), not MALFORMED_MESSAGE (0x03). A genuine postcard/varint
812            // parse fault (in-range discriminant, bad payload) stays MALFORMED.
813            let app_code = if is_unknown_variant::<ProbeMessage>(&frame) {
814                APP_ERR_UNSUPPORTED_MESSAGE
815            } else {
816                APP_ERR_MALFORMED_MESSAGE
817            };
818            reset(send, recv, app_code);
819            Err(ProbeReadError {
820                err: anyhow::anyhow!("probe decode failed: {e}"),
821                app_code,
822            })
823        }
824        Ok((ProbeMessage::Request(req), _rest)) => Ok(req),
825        Ok((ProbeMessage::Response(_), _)) => {
826            reset(send, recv, APP_ERR_UNSUPPORTED_MESSAGE);
827            Err(ProbeReadError {
828                err: anyhow::anyhow!("unexpected ProbeMessage::Response on server stream"),
829                app_code: APP_ERR_UNSUPPORTED_MESSAGE,
830            })
831        }
832    }
833}
834
835/// Fold the origin-held index into the store's `has_blob` decision (#1130).
836///
837/// If the store already backs `has_blob: true`, keep it. Otherwise, when the
838/// blob is servable from a configured origin (`origin_size` is `Some`),
839/// advertise it with the origin's size — cold origin content is discoverable
840/// on the first probe. Pure so the policy is unit-testable without a live
841/// connection.
842const fn fold_origin_held(
843    store_decision: (bool, Option<u64>),
844    origin_size: Option<u64>,
845) -> (bool, Option<u64>) {
846    match store_decision {
847        (true, bytes) => (true, bytes),
848        (false, _) => match origin_size {
849            Some(size) => (true, Some(size)),
850            None => (false, None),
851        },
852    }
853}
854
855#[cfg(test)]
856#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
857mod tests;