decdn_node/handlers/client/mod.rs
1//! `cdn/client/v1` handler — paid blob delivery (ADR 005 §`cdn/client/v1`).
2//!
3//! Serves the revenue path: a payer opens one bidirectional QUIC stream per
4//! blob, the node answers with a signed [`StreamResponse`], then streams
5//! [`ChunkData`](decdn_protocol::client::ChunkData) frames, collecting one
6//! hash-chain preimage per delivered
7//! `CHUNK_BYTES` chunk — with a signed `Voucher` to open a chain, to roll one,
8//! and to settle a sub-chunk residual — and pausing only when the unpaid balance
9//! (`delivered − paid`) reaches the credit window — so delivery pipelines
10//! several intervals ahead of payment rather than stopping at each one — and
11//! finishing with [`ClientMessage::StreamEnd`]. A delivery fault rides in the
12//! initial response (`ok: false` + [`StreamError`]); a mid-stream voucher
13//! rejection is sent as a [`ClientMessage::StreamError`] and the stream is
14//! closed **cleanly** (no QUIC reset) so the client can read the reason.
15//!
16//! # Scope (#317 / #327)
17//!
18//! This handler validates vouchers per **lane** — a `(pool_id, signer,
19//! provider)` triple keyed by [`LaneKey`] — against the persisted
20//! [`PoolStateStore`]. A lane's watermark is hydrated from the store at
21//! construction (see [`ClientHandler::new`]) and, for a lane first seen live,
22//! created on the first voucher from its off-chain capability handle. A voucher
23//! whose `pool_id` names an unknown pool is rejected with
24//! [`VoucherRejectReason::WrongPool`]. After accepting a voucher the handler
25//! emits a redeem hint (via the `redeem_hint` sender wired on
26//! [`ClientHandlerDeps`]) so the settlement service can redeem the accrued claim
27//! once it crosses its threshold.
28
29use std::collections::{HashMap, VecDeque};
30use std::sync::Arc;
31use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
32use std::time::Duration;
33
34use alloy::dyn_abi::Eip712Domain;
35use alloy::primitives::{Address, B256, U256};
36use alloy::signers::local::PrivateKeySigner;
37use dashmap::DashMap;
38use decdn_cache::{CHUNK_GROUP_BYTES, CacheEngine, CacheError, Hash};
39use decdn_incentive::rate::{DEFAULT_TOLERANCE_BPS, RateError, min_payment, verify_rate};
40use decdn_incentive::store::{PoolStateStore, StoreError};
41use decdn_incentive::{
42 Capability, LaneKey, LaneState, RetrySignal, SignedCapability, SignedVoucher, StreamSlashData,
43 verify_binding, voucher_reject_reason, wire_voucher_to_signed,
44};
45use decdn_protocol::client::{
46 ClientMessage, StreamError, StreamRequest, StreamRequestExt, StreamResponse,
47 StreamResponseBody, StreamResponseExt, VoucherRejectReason, WatermarkBundle,
48};
49use decdn_protocol::{
50 ALPN_CLIENT, APP_ERR_RATE_LIMITED, CHUNK_BYTES, FrameError, decode_message, encode_message,
51 is_unknown_variant, read_frame, write_frame,
52};
53use iroh::PublicKey;
54use iroh::endpoint::{Connection, RecvStream, SendStream, VarInt};
55use iroh::protocol::{AcceptError, ProtocolHandler};
56use tokio::io::AsyncReadExt;
57use tokio::sync::{Mutex, OwnedSemaphorePermit, Semaphore, mpsc};
58
59use crate::dispatch::{ConnectionLimiter, RejectReason};
60use crate::metrics::Metrics;
61use crate::node_origin::NodeOrigin;
62use crate::receipt_log::{RawReceipt, ReceiptSink};
63use crate::warn_throttle::WarnThrottle;
64
65// The paid-delivery methods are split across concern-focused submodules, each
66// a bare `impl ClientHandler` block over the fields defined here. Support
67// types, consts, and free functions stay in this module so every submodule
68// (and the test module) can reach them via `use super::*` — Rust makes a
69// module's private items visible to its descendants (#1254).
70mod delivery;
71mod dispatch;
72mod fill;
73mod outcome;
74mod proof_wait;
75mod ramp;
76mod serve_encoder;
77mod serve_leg;
78mod serve_waits;
79mod voucher;
80mod window;
81mod wire;
82
83pub(crate) use proof_wait::PROOF_WAIT_CEILING;
84
85/// Default per-connection concurrent-stream cap for `cdn/client/v1` (ADR 005
86/// §Concurrent stream limits). The QUIC transport config also caps bidi
87/// streams at this value; the application semaphore makes the per-ALPN bound
88/// explicit and testable.
89pub const MAX_CLIENT_STREAMS: usize = 100;
90
91// Per-stage timeouts so a stalled peer cannot pin a stream task indefinitely.
92const REQUEST_READ_TIMEOUT: Duration = Duration::from_secs(5);
93/// How long the serve loop waits for a proof while the connection sends no new
94/// STREAM frame. The proofs of one owed chunk never wait past
95/// [`proof_wait::PROOF_WAIT_CEILING`] in total, from the chunk's first proof
96/// wait (see [`proof_wait`]). Traffic on sibling streams of the same
97/// connection counts as progress, so it can hold a wait up to that ceiling.
98const VOUCHER_READ_TIMEOUT: Duration = Duration::from_secs(10);
99const REJECTION_CLOSE_TIMEOUT: Duration = Duration::from_millis(250);
100/// After a clean `StreamEnd`, how long the serve waits for the client's FIN while
101/// draining its send half (see [`drain_recv_to_fin`]). A conforming client
102/// finishes its send right after its last voucher, so the FIN lands within a round
103/// trip; this only bounds a client that completes delivery but never FINs from
104/// pinning the serve task.
105const POST_END_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
106/// Fallback overall deadline for opening a window-paced pull (#856) when no
107/// pull-through deadline is configured. In practice the runtime always sets
108/// one alongside the window provider, so this only guards a misconfiguration.
109const WINDOW_PULL_FALLBACK_DEADLINE: Duration = Duration::from_mins(1);
110/// The largest first frame a serve writes ([`ClientHandler::first_frame_target`]):
111/// four bao chunk groups. It sits above the one-group floor the pull side
112/// reserves for a prefetch past a shut window (`decdn_client::PULL_WINDOW_FLOOR`),
113/// so the miss leg's pairing holds.
114const FIRST_FRAME_BYTES: u64 = 4 * decdn_bao_range::CHUNK_GROUP_BYTES;
115
116/// Application-layer idle-close ceiling (ADR 005 §Connection lifetime): a served
117/// connection is closed this long after its last stream closes — or after it is
118/// accepted, if no stream ever opens. Distinct from
119/// the QUIC transport idle timeout (`runtime::QUIC_MAX_IDLE_TIMEOUT`, also 30s
120/// today — the two are independent constants that happen to match): keep-alive
121/// PINGs refresh the transport timer, so a peer can hold a connection open
122/// indefinitely while sending zero streams — only this app-layer clock reclaims
123/// it. ADR 005's "no unacknowledged vouchers in flight" clause never delays the
124/// reaper here: vouchers only ever flow *inside* a stream, so a connection with
125/// no stream in flight has no voucher in flight either (sent or received), and
126/// the rule reduces to purely stream-idle.
127const APP_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
128
129// QUIC application error codes (ADR 013 §Application Error Codes). A clean
130// voucher rejection does NOT use these — it writes a `StreamError` frame and
131// finishes the stream so the reason survives.
132const APP_ERR_NO_ERROR: u32 = 0x00;
133const APP_ERR_UNSUPPORTED_MESSAGE: u32 = 0x01;
134const APP_ERR_MALFORMED_MESSAGE: u32 = 0x03;
135
136/// Per-lane delivery state: the validated voucher watermark plus the lane-wide
137/// cumulative byte counter that feeds voucher reconstruction (ADR 003 §Voucher
138/// wire format — `bytes_delivered` is not on the wire).
139#[derive(Debug)]
140struct LaneDeliveryState {
141 state: LaneState,
142 /// Lane-wide cumulative bytes the lane's claim covers: the signed bytes
143 /// extended by the chunks the chain has proved.
144 bytes_delivered_cumulative: U256,
145 /// Cumulative wire bytes CREDITED to streams' paid headroom on this lane
146 /// (design rule #1). Monotone and never exceeds the chain-extended watermark
147 /// the lane has settled, so a benign already-satisfied proof — which does
148 /// not raise the watermark — can only advance a stream's window for bytes
149 /// the lane has actually settled, never for unpaid delivered bytes.
150 paid_credited: U256,
151 /// Wall-clock (Unix milliseconds) of the last accepted voucher on this lane,
152 /// or `0` when this process has accepted none since it hydrated the lane
153 /// (issue #1733). Stamped by [`ClientHandler::commit_one_proof`] under the
154 /// per-lane lock it already holds — a field write, not a separate global
155 /// mutex — and read back by [`LaneActivityClock::ages`] for the admin
156 /// "seconds since last voucher" readout. Best-effort liveness bookkeeping:
157 /// it gates nothing, and the stamp lifecycle follows the lane row (a
158 /// forgotten lane drops it automatically).
159 last_voucher_at: AtomicU64,
160 /// Paid wire bytes that finished streams on this lane returned for the next
161 /// stream's credit ramp (ADR 003 §Credit window). In memory only: a lane this
162 /// process loads or registers starts with an empty pool.
163 ramp_pool: Arc<ramp::RampPool>,
164}
165
166impl LaneDeliveryState {
167 /// The delivery state for a lane this process loads or registers, with its
168 /// whole claim — signed bytes and proved chain frontier — already credited.
169 ///
170 /// Every byte the lane's claim covers was delivered before this entry
171 /// existed — by an earlier process, or before the lane was last forgotten —
172 /// to a stream that either took the credit or ended with it unclaimed. No
173 /// stream outlives the entry it drew on. A new stream delivers
174 /// only new bytes, so none of that claim is headroom it may draw on.
175 /// Crediting only the signed half would hand each restart the proved
176 /// frontier — up to a whole chain — as free credit for proofs that pay
177 /// nothing.
178 fn hydrated(state: LaneState) -> Self {
179 let owed_bytes = state.owed_bytes();
180 Self {
181 state,
182 bytes_delivered_cumulative: owed_bytes,
183 paid_credited: owed_bytes,
184 last_voucher_at: AtomicU64::new(0),
185 ramp_pool: Arc::default(),
186 }
187 }
188}
189
190/// Read handle over the client handler's live lane registry, exposing each
191/// lane's whole-seconds age since its last accepted voucher for the admin
192/// `lanes` surface (issue #1733). Cloning shares the same registry `Arc`, so
193/// the admin surface and the handler observe one set of lanes. The timestamp
194/// lives on the lane's own delivery state, stamped under the per-lane lock the
195/// voucher-accept path already holds, so reading liveness needs no separate
196/// global lock.
197#[derive(Clone)]
198pub struct LaneActivityClock {
199 lanes: Arc<DashMap<LaneKey, Arc<Mutex<LaneDeliveryState>>>>,
200}
201
202impl std::fmt::Debug for LaneActivityClock {
203 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204 f.debug_struct("LaneActivityClock").finish_non_exhaustive()
205 }
206}
207
208impl LaneActivityClock {
209 /// An activity clock over an empty registry — reports "never" for every
210 /// lane. Used where no client handler is wired (a node with no payment
211 /// surface, and admin unit tests).
212 #[must_use]
213 pub fn empty() -> Self {
214 Self {
215 lanes: Arc::new(DashMap::new()),
216 }
217 }
218
219 /// Whole seconds since each live lane last accepted a voucher, keyed by
220 /// [`LaneKey`]. A lane whose stamp is `0` (none accepted since this process
221 /// hydrated it) is omitted, so a caller reads it back as "never" rather than
222 /// a bogus zero age. Best-effort: `Instant`-free wall-clock arithmetic, so a
223 /// clock that stepped backwards can only under-report the age — the safe
224 /// direction for a diagnostic.
225 pub async fn ages(&self) -> HashMap<LaneKey, u64> {
226 let now_ms = unix_millis();
227 // Snapshot the live lane handles first (cloning the per-lane `Arc`s
228 // releases the registry's shard guards), then read each stamp with only
229 // its own (brief) lane lock held — never a shard guard across an await.
230 let handles: Vec<(LaneKey, Arc<Mutex<LaneDeliveryState>>)> = self
231 .lanes
232 .iter()
233 .map(|e| (*e.key(), Arc::clone(e.value())))
234 .collect();
235 let mut out = HashMap::with_capacity(handles.len());
236 for (key, lane) in handles {
237 let stamped = lane.lock().await.last_voucher_at.load(Ordering::Relaxed);
238 if stamped != 0 {
239 out.insert(key, now_ms.saturating_sub(stamped) / 1000);
240 }
241 }
242 out
243 }
244
245 /// Test-only: a clock whose registry holds each `lane` already stamped
246 /// "now", so [`ages`](Self::ages) reports a near-zero age for it. Mirrors
247 /// what the voucher-accept path does to a live lane, without a running
248 /// handler. Used to drive the admin `lanes` ordering test.
249 #[cfg(test)]
250 pub(crate) fn with_stamped_lanes(lanes: &[LaneKey]) -> Self {
251 let now = unix_millis();
252 let map = DashMap::new();
253 for &key in lanes {
254 let state = LaneState::hydrate(
255 key.pool_id,
256 key.signer,
257 key.provider,
258 U256::MAX,
259 0,
260 U256::ZERO,
261 U256::ZERO,
262 None,
263 decdn_incentive::LaneChain::NONE,
264 );
265 map.insert(
266 key,
267 Arc::new(Mutex::new(LaneDeliveryState {
268 state,
269 bytes_delivered_cumulative: U256::ZERO,
270 paid_credited: U256::ZERO,
271 last_voucher_at: AtomicU64::new(now),
272 ramp_pool: Arc::default(),
273 })),
274 );
275 }
276 Self {
277 lanes: Arc::new(map),
278 }
279 }
280}
281
282/// Wall-clock now in milliseconds since the Unix epoch, or `0` if the system
283/// clock is before the epoch. Stamps [`LaneDeliveryState::last_voucher_at`] on
284/// each accepted voucher and is read back by [`LaneActivityClock::ages`] (issue
285/// #1733). The `0` fallback doubles as the "never stamped" sentinel and only
286/// makes a lane look staler than it is — the safe direction for a best-effort
287/// diagnostic.
288pub(super) fn unix_millis() -> u64 {
289 std::time::SystemTime::now()
290 .duration_since(std::time::UNIX_EPOCH)
291 .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
292}
293
294impl ClientHandler {
295 /// A read handle over this handler's live lane registry for the admin
296 /// `lanes` surface (issue #1733): the last-voucher clock the operator's
297 /// "seconds since last voucher" readout reads. Shares the registry `Arc`, so
298 /// it observes every stamp the voucher-accept path writes without a separate
299 /// shared structure.
300 #[must_use]
301 pub fn lane_activity_clock(&self) -> LaneActivityClock {
302 LaneActivityClock {
303 lanes: Arc::clone(&self.lanes),
304 }
305 }
306}
307
308/// One capability signer's slice of a pool's floor accounting (ADR 003 §Pool
309/// solvency, per-signer floor isolation). Two independent concerns live here.
310///
311/// `live_reservation` is the `µUSDC` this signer's in-flight streams currently
312/// reserve — un-vouchered floor delivered ahead of payment, released as each stream
313/// pays. It is the concurrency/solvency dimension: it carries no memory and never
314/// penalizes a signer for quitting. Bounding it per signer (`k · one_window`) is
315/// what stops one capability key from taking a shared pool's whole live floor
316/// budget from its co-tenants (ADR 003 §Pool solvency, per-signer floor isolation).
317#[derive(Debug, Default, Clone, Copy)]
318pub(super) struct SignerFloorState {
319 live_reservation: U256,
320}
321
322impl SignerFloorState {
323 /// A row that carries no information: no live reservation. An unseen signer reads
324 /// the same, so such a row can be pruned.
325 fn is_empty(self) -> bool {
326 self.live_reservation.is_zero()
327 }
328}
329
330/// One pool's floor accounting (ADR 003 §Pool solvency). `live_reservation` is the
331/// `µUSDC` currently reserved by in-flight streams — the hard money envelope, summed
332/// across every signer and bounded by `remaining − M`. It is ephemeral: no stream is
333/// live at restart, so it clears.
334///
335/// `signers` holds each capability signer's [`SignerFloorState`]: its slice of
336/// `live_reservation`, so admission can bound one signer's concurrent un-vouchered
337/// exposure. The pool's `live_reservation` stays the sum of the signer slices —
338/// every charge and release touches both levels under one lock hold.
339#[derive(Debug, Clone)]
340pub(super) struct PoolFloorState {
341 live_reservation: U256,
342 signers: HashMap<Address, SignerFloorState>,
343 /// Generation stamp, unique across every entry this process creates. A
344 /// [`FloorReservation`] copies it at charge time and both reconcile paths
345 /// compare it, so a guard whose pool was reclaimed
346 /// ([`ClientHandler::forget_pool_floor`] removed the entry) and whose `pool_id`
347 /// a later admission then re-entered reconciles against nothing, rather than
348 /// decrementing counters it never contributed to against a signer row belonging
349 /// to a different pool generation.
350 epoch: u64,
351}
352
353/// Source of [`PoolFloorState::epoch`]. Process-wide and monotonic, so no two
354/// entries share a stamp — including an entry re-created under a `pool_id` a
355/// removed entry previously held.
356static POOL_FLOOR_EPOCH: AtomicU64 = AtomicU64::new(0);
357
358impl Default for PoolFloorState {
359 fn default() -> Self {
360 Self {
361 live_reservation: U256::ZERO,
362 signers: HashMap::new(),
363 epoch: POOL_FLOOR_EPOCH.fetch_add(1, Ordering::Relaxed),
364 }
365 }
366}
367
368impl PoolFloorState {
369 /// Live floor credit committed across every signer on the pool — the pool
370 /// solvency quantity, bounded by `remaining − M`.
371 const fn live_committed(&self) -> U256 {
372 self.live_reservation
373 }
374
375 /// Drop a signer's entry once it carries no information — no live reservation. An
376 /// unseen signer reads the same way (zero live reservation), so keeping such a row
377 /// changes no decision.
378 ///
379 /// Without this the map only ever grows. ADR 003 §Revocation makes short-expiry
380 /// session keys the intended usage, so a busy publisher mints signer identities
381 /// steadily, and every one that reserves and pays cleanly would leave an empty row
382 /// alive until the pool is reclaimed on-chain — inside a map locked on every
383 /// admission.
384 ///
385 /// Safe against a live guard: a repaid guard's `Drop` returns before touching
386 /// the map at all, and an unrepaid guard holds `live_reservation > 0` — every
387 /// admission reserves at least one chunk at the on-chain-floored rate, so a
388 /// reservation is never zero — and it cannot have its row pruned out from
389 /// under it.
390 fn prune_spent(&mut self, signer: Address) {
391 if self
392 .signers
393 .get(&signer)
394 .is_some_and(|lane| lane.is_empty())
395 {
396 self.signers.remove(&signer);
397 }
398 }
399}
400
401/// RAII hold for one stream's span-capped reservation against a pool's budget.
402///
403/// Construction charges the reserved amount to the pool's `live_reservation` (and
404/// the signer's slice of it). [`Self::release_if_repaid`] frees the reservation
405/// once THIS stream's cumulative payment reaches the reserved floor. On drop (every
406/// exit path — success, `?`, disconnect, panic) the guard releases the live
407/// reservation if it was not already repaid, so an abandoned stream never holds a
408/// pool's floor headroom past its own lifetime. The reservation is owned by the
409/// guard and never adjusted by hand, and every counter update saturates.
410#[must_use = "dropping the guard at once releases its live reservation"]
411pub(super) struct FloorReservation {
412 map: Arc<std::sync::Mutex<HashMap<B256, PoolFloorState>>>,
413 pool_id: B256,
414 /// Generation of the [`PoolFloorState`] this reservation is charged against
415 /// (see [`PoolFloorState::epoch`]). Both reconcile paths skip an entry whose
416 /// stamp differs: such an entry belongs to a later pool generation, and this
417 /// guard's reservation went with the one that was removed.
418 epoch: u64,
419 /// The capability signer this reservation belongs to, so its live slice is
420 /// released on the same signer row it was charged to.
421 signer: Address,
422 reserved: U256,
423 /// Set by [`Self::release_live_repaid`]; makes drop a no-op (live already freed).
424 /// Idempotent.
425 repaid: AtomicBool,
426}
427
428impl FloorReservation {
429 /// Reserve `reserved` `µUSDC` of the pool's budget. Charges
430 /// `live_reservation += reserved` (saturating) under the sync lock — an O(1) map
431 /// touch with no `.await` held, so a blocking lock is correct even on the async
432 /// serve path (mirrors `lane_gauge_publish`). A poisoned lock recovers the
433 /// guard rather than panicking; the reservation is best-effort accounting, never
434 /// a safety gate.
435 ///
436 /// The standalone increment-then-build form, used only by the accumulator's
437 /// own unit tests. The serve path admits through
438 /// [`ClientHandler::try_reserve_floor`] instead, which needs the increment to
439 /// happen inside its own check-and-reserve lock hold and so builds the guard
440 /// via [`Self::new_charged`].
441 #[cfg(test)]
442 fn reserve(
443 map: Arc<std::sync::Mutex<HashMap<B256, PoolFloorState>>>,
444 pool_id: B256,
445 signer: Address,
446 reserved: U256,
447 ) -> Self {
448 let epoch = {
449 let mut guard = map
450 .lock()
451 .unwrap_or_else(std::sync::PoisonError::into_inner);
452 let entry = guard.entry(pool_id).or_default();
453 entry.live_reservation = entry.live_reservation.saturating_add(reserved);
454 let lane = entry.signers.entry(signer).or_default();
455 lane.live_reservation = lane.live_reservation.saturating_add(reserved);
456 entry.epoch
457 };
458 Self::new_charged(map, pool_id, signer, reserved, epoch)
459 }
460
461 /// Build a guard for a floor that is ALREADY charged to `live_reservation`
462 /// under the caller's own lock hold. This does NOT touch the map — the
463 /// increment happens exactly once, at the caller's atomic check-and-reserve,
464 /// so re-incrementing here would double-charge the pool. Used by
465 /// [`ClientHandler::try_reserve_floor`], whose single lock hold covers both the
466 /// budget check and the increment; `FloorReservation::reserve` is the test-only
467 /// standalone form that increments first, then delegates here.
468 const fn new_charged(
469 map: Arc<std::sync::Mutex<HashMap<B256, PoolFloorState>>>,
470 pool_id: B256,
471 signer: Address,
472 reserved: U256,
473 epoch: u64,
474 ) -> Self {
475 Self {
476 map,
477 pool_id,
478 epoch,
479 signer,
480 reserved,
481 repaid: AtomicBool::new(false),
482 }
483 }
484
485 /// Mark the reservation repaid: the lane's cumulative payment reached a floor,
486 /// so free the live reservation now (`live_reservation -= reserved`, saturating)
487 /// and make the eventual drop a no-op. Idempotent — only the first call moves
488 /// the live counter.
489 fn release_live_repaid(&self) {
490 if self.repaid.swap(true, Ordering::Relaxed) {
491 return; // already repaid; live already released
492 }
493 let mut guard = self
494 .map
495 .lock()
496 .unwrap_or_else(std::sync::PoisonError::into_inner);
497 // `get_mut`, not `entry().or_default()`, same as the drop guard: if the
498 // pool was reclaimed (`forget_pool_floor` removed its entry) the live
499 // reservation went with the entry, and re-inserting a default state here
500 // resurrects an entry for a closed pool that nothing removes again — the
501 // in-memory face of #1781. The epoch test covers the other half of that
502 // window: an entry present under this `pool_id` but stamped differently is
503 // a LATER generation, re-entered by an admission that ran after the remove,
504 // and subtracting from it would report a reservation this guard never
505 // charged to it.
506 if let Some(entry) = guard
507 .get_mut(&self.pool_id)
508 .filter(|entry| entry.epoch == self.epoch)
509 {
510 entry.live_reservation = entry.live_reservation.saturating_sub(self.reserved);
511 // The signer entry moves with the pool total — the two levels are one
512 // accumulator, so a release that touched only one would leave the
513 // signer's share permanently consumed by a stream that paid for it.
514 if let Some(lane) = entry.signers.get_mut(&self.signer) {
515 lane.live_reservation = lane.live_reservation.saturating_sub(self.reserved);
516 }
517 entry.prune_spent(self.signer);
518 }
519 }
520
521 /// Release the live reservation once cumulative payment (`paid_micro`) reaches
522 /// the amount that was reserved. Matches release to the reserved size at any
523 /// `credit_ramp_divisor`: with the ramp disabled the reservation is the full
524 /// `credit_max`, so release must wait for that much to be paid rather than a
525 /// single chunk. Idempotent (delegates to [`Self::release_live_repaid`]).
526 fn release_if_repaid(&self, paid_micro: U256) {
527 if paid_micro >= self.reserved {
528 self.release_live_repaid();
529 }
530 }
531
532 /// Release the reservation for a serve that was REFUSED before the serve loop
533 /// ran — no upstream USDC fronted, no downstream byte delivered. Frees the live
534 /// reservation promptly (rather than at drop) and marks the guard repaid so the
535 /// drop is a clean no-op. Mechanically a refused-unspent serve and a fully-repaid
536 /// one both owe nothing, so this delegates to [`Self::release_live_repaid`]; the
537 /// distinct name states the intent at the refusal call sites.
538 fn release_unspent(&self) {
539 self.release_live_repaid();
540 }
541}
542
543impl Drop for FloorReservation {
544 fn drop(&mut self) {
545 // Release the live reservation on every exit path (success, `?`, disconnect,
546 // panic) unless a paying stream already released it via
547 // [`Self::release_live_repaid`]. An abandoned stream must not hold a pool's
548 // floor headroom past its own lifetime, so an unrepaid drop frees the
549 // reservation at both the pool and signer levels. No abandonment charge
550 // survives the drop: bounding un-vouchered floor is the admission-time job of
551 // the pool ceiling and the per-signer live cap (ADR 003 §Pool solvency).
552 self.release_live_repaid();
553 }
554}
555
556/// Which floor gate refused an admission
557/// ([`ClientHandler::try_reserve_floor`]). They stay distinct here so the
558/// per-reason metric separates "this pool cannot pay" from the node-local
559/// per-signer live cap, and they part on the wire too: the pool arm speaks
560/// [`StreamError::Unfunded`] to the proven requester (both floor gates run past
561/// the lane-ownership proof), while the signer arm stays a plain `NotFound`.
562#[derive(Debug, Clone, Copy, PartialEq, Eq)]
563pub(super) enum FloorRefusal {
564 /// The pool-wide ceiling: `remaining − M` cannot cover the live floor
565 /// reservation already committed across every signer on the pool plus this new
566 /// floor. This is the hard money envelope; it clears with a top-up.
567 PoolExhausted,
568 /// The per-signer LIVE concurrency cap: the pool can still pay, but THIS signer
569 /// already holds `k` windows of live un-vouchered reservation, so admitting
570 /// another would let one signer front more concurrent credit than its share of
571 /// the pool covers. Carries the cap that refused, read under the same lock hold
572 /// as the decision, so the operator line reports the number that actually
573 /// applied. Clears as the signer's in-flight streams pay.
574 SignerAtCap { signer_cap: U256 },
575}
576
577/// What a floor-admission refusal needs to describe itself
578/// ([`ClientHandler::log_floor_refusal`]). Grouped rather than passed positionally
579/// because the two messages read different fields: the pool arm wants the pool's
580/// headroom against the reserved `ceiling`, the signer arm wants the signer. The
581/// signer's cap is NOT here — it rides on [`FloorRefusal::SignerAtCap`], read under
582/// the lock hold that made the decision. `remaining` is the raw `getPool` value, so
583/// the callee derives the headroom rather than every call site deriving it.
584#[derive(Debug, Clone, Copy)]
585pub(super) struct FloorRefusalSite {
586 pub pool_id: B256,
587 pub signer: Address,
588 pub hash: Hash,
589 pub remaining: U256,
590 /// The `µUSDC` this admission asked to reserve.
591 pub ceiling: U256,
592}
593
594impl From<FloorRefusal> for ServeRejectReason {
595 /// The refusal reason a serve-path admission gate reports for this cap. Keeping
596 /// the mapping on the conversion makes an inconsistent cap/reason pairing
597 /// unrepresentable at the call sites, exactly as
598 /// [`ServeRejectReason::wire_error`] does for the wire codes.
599 fn from(refusal: FloorRefusal) -> Self {
600 match refusal {
601 FloorRefusal::PoolExhausted => Self::InsufficientDeposit,
602 FloorRefusal::SignerAtCap { .. } => Self::SignerFloorAtCap,
603 }
604 }
605}
606
607/// Server-side classification of a `serve_stream` refusal, used to pick the
608/// per-reason reject counter (#876). Finer-grained than the wire `StreamError`,
609/// which carries only the three open-time refusal classes (ADR 005 §Open-time
610/// refusal classes): `CacheMiss`, `UnknownChannel`, and `OwnerMismatch` all
611/// ship as `NotFound` (to avoid leaking channel existence), but are distinct
612/// here so an operator can, e.g., isolate an unknown-channel abuse campaign.
613#[derive(Debug, Clone, Copy, PartialEq, Eq)]
614enum ServeRejectReason {
615 /// The node withdrew the blob after a probe: an operator evict or a
616 /// corruption quarantine with no blacklist entry behind it.
617 EvictedSinceProbe,
618 CacheMiss,
619 /// A fault in this node: a local store fault, or a fill that faulted on
620 /// this node's own side. Signed `Declined`, never a false absence.
621 InternalError,
622 UnknownChannel,
623 OwnerMismatch,
624 /// The pool's on-chain remaining deposit, minus the node's refundable floor
625 /// `M`, can no longer cover a credit window (ADR 003 §Pool solvency). It is
626 /// reachable only past the lane-ownership proof (a known lane keyed to a
627 /// verified signer holding an owner-signed capability), so its audience is the
628 /// proven requester — never an unauthenticated prober — and it ships the wire
629 /// [`StreamError::Unfunded`], which counts toward that requester's funding
630 /// recovery (see [`Self::wire_error`]).
631 InsufficientDeposit,
632 /// A wired `PoolView` could not confirm the request's pool on-chain: the pool
633 /// has no on-chain record, is closed/reclaimed, or the admit-path `getPool`
634 /// faulted; or the admit-path `getAuthorization` faulted for a signer the node
635 /// holds no cached registered read of. The node refuses rather than serve a
636 /// pool it cannot confirm is live and solvent, or a signer it cannot confirm
637 /// can pay (ADR 003 §Pool solvency). Collapses to `NotFound` on the wire
638 /// (see [`Self::wire_error`]) — this refusal can precede any lane-ownership
639 /// proof, and an RPC fault is "not now", so it stays a plain miss: a client
640 /// cannot tell an unconfirmed pool from an absent blob, while an operator can
641 /// tell a chain/RPC problem from real deposit exhaustion.
642 PoolUnconfirmed,
643 /// The request's pool is `Closing` on-chain: it takes no top-up, and its
644 /// redemption ends at the dispute deadline (ADR 003 §Pool solvency).
645 /// `proven` says the requester holds a lane on the pool, so it passed the
646 /// lane-ownership proof: it hears `Unfunded` and its owner opens a new pool.
647 /// Anyone else hears `NotFound`, so pool state stays unmappable.
648 PoolClosing {
649 /// Whether the requester passed the lane-ownership proof.
650 proven: bool,
651 },
652 /// The request's voucher signer cannot pay: it is registered on-chain with
653 /// `cap − spent` below a serve floor or with an expired registration, or its
654 /// lane's capability is inside this node's expiry margin. A signer's `cap` is
655 /// shared across every provider (ADR 003 §Pool solvency), so a "spent"
656 /// capability — one whose signer has already drawn its full `cap` at other
657 /// nodes — is uncashable here: the node would serve for vouchers it could
658 /// never redeem. Ships [`StreamError::Unfunded`] (see [`Self::wire_error`]):
659 /// both gates key on a verified binding for a registered signer or a lane,
660 /// so the requester is proven.
661 SignerCapExhausted,
662 /// One capability signer's live un-vouchered reservation already fills its
663 /// `k`-window concurrency cap (ADR 003 §Pool solvency, per-signer floor
664 /// isolation). The pool itself can still pay and co-tenants are unaffected; the
665 /// cap is signer-scoped and clears as that signer's in-flight streams pay.
666 /// Collapses to `NotFound` on the wire (see [`Self::wire_error`]) — a node-local
667 /// concurrency stop, cleared by waiting rather than by a top-up, so unlike
668 /// [`Self::InsufficientDeposit`] it names no funding state.
669 SignerFloorAtCap,
670 /// A whole-blob request from an active staker, refused while this node's own
671 /// upstream open for the blob is in progress and no live fill exists to
672 /// coalesce onto (#2224). Answering it would let partial holders pull the
673 /// blob from each other in a loop. Collapses to `NotFound` on the wire (see
674 /// [`Self::wire_error`]) so the requester tries its next candidate and scores
675 /// no fault: the refusal is a loop guard, never a sign of a degraded node,
676 /// whatever an earlier tier of the request saw.
677 PullLoopGuard,
678 /// A cache-HIT serve shed under node overload — egress saturation, or this
679 /// client's fair-share cap while the node is pressured. Distinct from
680 /// [`Self::LoadShedMiss`] for the per-reason metric ONLY — both collapse to
681 /// `NotFound` on the wire (see [`Self::wire_error`]) so a client cannot
682 /// read node load, and both are reputation-benign (a client scores
683 /// `NotFound` as no fault).
684 LoadShedHit,
685 /// A cache-MISS serve shed under node overload — concurrency pressure,
686 /// per-client fairness, or egress saturation. See [`Self::LoadShedHit`].
687 LoadShedMiss,
688 /// The requested range starts at or past this node's size claim for the
689 /// blob. Ships [`StreamError::Declined`]: the size is this node's claim,
690 /// and another node can disagree (ADR 005 §Open-time refusal classes).
691 RangeNotSatisfiable,
692 /// A pull-through request whose start sits at or past this node's
693 /// buyer-side `max_blob_size` ceiling (ADR 005 §`max_blob_size` enforcement).
694 /// The gate reads only the requester's `byte_offset`, never the upstream's
695 /// unverified `total_bytes` claim (#1895), and fires before the node commits
696 /// to the stream. Ships the wire [`StreamError::Declined`] (see
697 /// [`Self::wire_error`]), so the requester drops this node for the blob.
698 BlobTooLarge,
699 /// The blob is on this operator's local denylist (ADR 011 §Local Denylist).
700 HashDenied,
701 /// The blob is on the governance blacklist (ADR 011 §On Blacklist Event).
702 /// Separate from [`Self::HashDenied`] for the operator's metrics ONLY — the
703 /// two are deliberately one and the same on the wire, see
704 /// [`Self::wire_error`].
705 ChainHashDenied,
706 /// The channel's funding address is on the origin blacklist — the operator's
707 /// local `denied_origins` or the on-chain one (ADR 011 §On Blacklist Event).
708 OriginDenied,
709 /// The origin-only policy (#1759, `cache.relay_foreign_namespaces = false`)
710 /// declined a hash this node's own backend genuinely does not hold. Ships
711 /// [`StreamError::Declined`] (see [`Self::wire_error`]): this node will not
712 /// relay the hash during this fetch, and another node may (ADR 002
713 /// §Retrieval by namespace).
714 ForeignNamespaceDeclined,
715 /// The node has been unable to reach the chain for longer than
716 /// `blockchain.chain_staleness_grace_sec` (ADR 011 § Serving while
717 /// chain-stale), so its deny-set, pool-solvency, and signer-cap guards are
718 /// all reading stale state. It refuses rather than sign a serve it can no
719 /// longer vouch for. Collapses to `NotFound` on the wire (see
720 /// [`Self::wire_error`]): the refusal is reputation-benign and the client
721 /// should re-route to a peer whose chain reads are live, exactly as for a
722 /// miss.
723 ChainStale,
724}
725
726impl ServeRejectReason {
727 /// The wire class a refusal for this reason signs to the client (ADR 005
728 /// §Open-time refusal classes). The reason is the single source of truth;
729 /// keeping the mapping on the type makes an inconsistent class/reason
730 /// pairing unrepresentable at the call sites, and the finer split survives
731 /// only in the per-reason metric (#876).
732 ///
733 /// The requester side of this mapping is `decdn_client::UpstreamRefused`,
734 /// which recovers the class — and ONLY the class — from a refusal (#1144).
735 /// A requester never ends a fetch on one node's refusal, and no class scores
736 /// the node as degraded.
737 const fn wire_error(self) -> StreamError {
738 match self {
739 // "No at the current funding". Spoken only to a proven requester: the
740 // floor and signer gates run past the lane-ownership proof or key on a
741 // verified binding for a registered signer, and a closing pool names
742 // its audience in `proven`. That requester already reads the pool's
743 // `remaining` and its signer's `{cap, expiry, spent}`, so the class
744 // leaks it nothing (ADR 003 §Pool solvency).
745 Self::InsufficientDeposit
746 | Self::SignerCapExhausted
747 | Self::PoolClosing { proven: true } => StreamError::Unfunded,
748 // "Not now": try other nodes, and this one may answer differently
749 // later. One code for a miss, a load shed, a stale chain, an
750 // unconfirmable pool, and every lane-existence refusal, so none of
751 // them can be told apart off the wire.
752 Self::CacheMiss
753 | Self::UnknownChannel
754 | Self::OwnerMismatch
755 | Self::PoolUnconfirmed
756 | Self::PoolClosing { proven: false }
757 | Self::SignerFloorAtCap
758 | Self::PullLoopGuard
759 | Self::LoadShedHit
760 | Self::LoadShedMiss
761 | Self::ChainStale => StreamError::NotFound,
762 // "This node will not serve this hash during this fetch". The
763 // requester drops this node for the hash and asks no further. One
764 // code for all of them means a client cannot tell a local denylist
765 // from a governance entry or a withdrawn blob, so it cannot map an
766 // operator's private legal exposure (ADR 011 §StreamRequest Response).
767 Self::EvictedSinceProbe
768 | Self::InternalError
769 | Self::RangeNotSatisfiable
770 | Self::BlobTooLarge
771 | Self::HashDenied
772 | Self::ChainHashDenied
773 | Self::OriginDenied
774 | Self::ForeignNamespaceDeclined => StreamError::Declined,
775 }
776 }
777}
778
779/// The result of a reactive cache-miss fill attempt (#1129).
780///
781/// Separates a genuine absence from a transient backend fault, which a bare
782/// `bool` cannot. The cache engine already draws this distinction (it
783/// deliberately prefers `OriginError` over `NotFound` when an origin faulted);
784/// preserving it here keeps the handler from collapsing both to "not filled" and
785/// refusing with `CacheMiss` — wire [`StreamError::NotFound`] — when the
786/// real cause is the operator's own S3/fs origin being down.
787///
788/// Why the reason code matters, stated precisely (the wire codes' own docs in
789/// `decdn_protocol::client` are the authority here):
790///
791/// - `CacheMiss` answers `NotFound` = "not now: try other nodes". It is the code
792/// a healthy-but-empty node returns, and this node can serve later.
793/// - `InternalError` answers `Declined` = "this node will not serve this hash
794/// during this fetch": the client drops this node for the hash, because this
795/// node is broken.
796///
797/// Both steer a client to another node, so this is not the difference between
798/// "retry" and "give up". What it buys is (a) an honest signal that the node is
799/// degraded rather than merely empty, and (b) the per-reason reject metric — the
800/// ONLY server-side place the true cause is observable, since the wire carries
801/// only the reason's class ([`ServeRejectReason::wire_error`]). An operator whose origin is 5xx-ing must
802/// not see that reported as a cache miss.
803///
804/// Note the reason code is NOT covered by the response's EIP-712 `slash_sig`,
805/// which signs only [`StreamResponseBody`] (`ok: false`); `StreamResponse::error`
806/// is explicitly "unsigned and informational". So a misclassification is a
807/// correctness and observability bug, not a false attestation.
808///
809/// A deadline expiry is deliberately NOT a `HardFault` — see
810/// [`ClientHandler::on_pull_through_timeout`].
811#[derive(Debug, Clone, Copy, PartialEq, Eq)]
812enum FillOutcome {
813 /// The blob is now present locally; fall through to the size gate + delivery.
814 Filled,
815 /// The tier produced no blob and no evidence of a fault. Covers BOTH a genuine
816 /// clean miss (a source was asked and did not have it) and a tier that was
817 /// never attempted at all (pull-through unconfigured, or the request not
818 /// authorized to make this node spend). The two are deliberately one variant:
819 /// neither is evidence of a fault, so both leave the terminal classification to
820 /// whatever the other tiers found. Terminal (when no tier fills and none
821 /// faulted): `NotFound`.
822 CleanMiss,
823 /// A backend/store fault, or a fault in this node's own buyer leg — the node is
824 /// degraded, not empty. Terminal: `InternalError`, which answers `Declined`, so a
825 /// client routes around it and the operator's reject metric names the real cause.
826 ///
827 /// Two different lifetimes arrive here, and the variant deliberately does not
828 /// distinguish them, because the client's answer is the same either way:
829 ///
830 /// - TRANSIENT: the operator's origin is 5xx-ing or its store is briefly unhappy.
831 /// Passes on its own.
832 /// - PERMANENT: this node's buyer side cannot pay at all — a broken signer, an
833 /// unusable deadline config, a channel store it cannot read (#1560). The node-origin
834 /// surfaces these as `OriginPullError::Permanent`, which the engine collapses into
835 /// `CacheError::OriginError` like any other origin failure. It recurs on every
836 /// request for EVERY hash until an operator intervenes.
837 ///
838 /// Deliberately narrow: ONLY `CacheError::OriginError` and `CacheError::Store`
839 /// qualify. A `BlobTooLarge` / `HashMismatch` / `VerifyFailed` is deterministic
840 /// and will recur on every request for that hash — reporting those as "this
841 /// node is broken" would steer clients off a perfectly healthy node forever
842 /// over one oversized blob.
843 HardFault,
844}
845
846impl FillOutcome {
847 /// The `outcome` value a `pull_through` span records.
848 const fn as_str(self) -> &'static str {
849 match self {
850 Self::Filled => "filled",
851 Self::CleanMiss => "clean_miss",
852 Self::HardFault => "hard_fault",
853 }
854 }
855}
856
857impl FillOutcome {
858 /// The reject reason a *terminal* miss carries, given whether any tier
859 /// attempted for this request hit a hard fault. Falling THROUGH to a further
860 /// tier after a fault is legitimate (a different source may still serve) — so
861 /// a fault seen on an earlier tier must be remembered here rather than
862 /// overwritten by a later clean miss, which would report a degraded node as a
863 /// merely-empty one.
864 const fn miss_reason(fault_seen: bool) -> ServeRejectReason {
865 if fault_seen {
866 ServeRejectReason::InternalError
867 } else {
868 ServeRejectReason::CacheMiss
869 }
870 }
871
872 /// Whether this outcome is a hard backend fault.
873 const fn is_fault(self) -> bool {
874 matches!(self, Self::HardFault)
875 }
876
877 /// Whether the blob is now present locally.
878 const fn is_filled(self) -> bool {
879 matches!(self, Self::Filled)
880 }
881}
882
883/// The serve-miss exit that refused a request. It names the cause in the
884/// refusal's log line only: the wire answer depends on the refusal's reason,
885/// never on its path ([`ServeRejectReason::wire_error`]).
886#[derive(Debug, Clone, Copy, PartialEq, Eq)]
887enum MissPath {
888 /// The pull leg opened, but its ranked candidates do not cover part of the
889 /// range this node does not hold (#2195).
890 CoverageGate(CoverageShortfall),
891 /// The pull leg open ended without a target, for the reason the
892 /// [`PullMiss`](crate::node_origin::PullMiss) names: no reachable provider
893 /// or every candidate declined (`clean_miss`), every candidate quoted above
894 /// the buy ceiling (`below_margin`), a fault in this node (`local_fault`),
895 /// this node's own wallet unable to fund a pool (`funding_needed`), or every
896 /// candidate refusing this node's funding with no recovery step left
897 /// (`unfunded`).
898 PullLegMiss(crate::node_origin::PullMiss),
899 /// The pull leg open ran past its deadline: the configured pull-through
900 /// timeout, or [`WINDOW_PULL_FALLBACK_DEADLINE`] when none is set.
901 PullLegTimeout,
902 /// The buffered pull-through ran and did not fill the blob. A deadline
903 /// expiry lands here too: this tier has no separate timeout path.
904 BufferedMiss,
905 /// The request may make this node spend, but no node-to-node pull-through
906 /// is configured. The local-origin tiers may have run and missed first.
907 NoPullThrough,
908 /// The request may not make this node spend: it carries no verified
909 /// binding, or this node holds no lane for its signer. No fill tier ran.
910 /// A remote peer reaches this path at will, so its line is throttled.
911 NoLane,
912}
913
914impl MissPath {
915 /// The `path` value the refusal's log line records.
916 const fn as_str(self) -> &'static str {
917 match self {
918 Self::CoverageGate(_) => "coverage_gate",
919 Self::PullLegMiss(_) => "pull_leg_miss",
920 Self::PullLegTimeout => "pull_leg_timeout",
921 Self::BufferedMiss => "buffered_miss",
922 Self::NoPullThrough => "no_pull_through",
923 Self::NoLane => "no_lane",
924 }
925 }
926}
927
928/// Why the coverage gate refused: the part of the pull range no ranked
929/// candidate covers (#2195).
930#[derive(Debug, Clone, Copy, PartialEq, Eq)]
931struct CoverageShortfall {
932 /// Start of the range the pull would fetch. It differs from the request's
933 /// start when this serve attaches to a live fill for the front of it.
934 pull_offset: u64,
935 /// Length of the range the pull would fetch.
936 pull_len: u64,
937 /// How many ranked candidates the pull leg holds.
938 candidates: usize,
939 /// The first 1 KiB chunk no candidate covers.
940 first_uncovered_chunk: Option<u64>,
941}
942
943/// A terminal serve-miss refusal: the reject reason
944/// ([`FillOutcome::miss_reason`]) and the exit that refused.
945#[derive(Debug, Clone, Copy, PartialEq, Eq)]
946struct MissRefusal {
947 /// The reason the refusal carries: `CacheMiss`, or `InternalError` when a
948 /// tier this request attempted hit a hard fault.
949 reason: ServeRejectReason,
950 /// The exit that refused.
951 path: MissPath,
952}
953
954impl MissRefusal {
955 /// The refusal `path` sends, given whether any tier attempted for this
956 /// request hit a hard fault.
957 const fn new(fault_seen: bool, path: MissPath) -> Self {
958 Self {
959 reason: FillOutcome::miss_reason(fault_seen),
960 path,
961 }
962 }
963}
964
965/// Construction bundle for [`ClientHandler`] — the 16 required runtime deps plus
966/// every optional wiring hook, so a handler's full configuration is one literal
967/// at its call site instead of a `new()` call followed by a setter chain.
968///
969/// Build it with [`ClientHandlerDeps::new`] (required fields only; every optional
970/// defaults to `None`), set the `Some` optionals the deployment enables, then
971/// pass it to [`ClientHandler::new`]. Each optional field's runtime semantics are
972/// documented on the matching [`ClientHandler`] field.
973pub struct ClientHandlerDeps {
974 /// This node's iroh identity. Carried for diagnostics — the handler does
975 /// not put it on the wire; `StreamResponseBody` has no node-id field.
976 pub node_id: PublicKey,
977 /// Shared metrics registry the serve path records into.
978 pub metrics: Arc<Metrics>,
979 /// Per-source connection-rate gate, applied before any work is done.
980 pub limiter: Arc<ConnectionLimiter>,
981 /// Overload-protection gate: sheds new serves under resource pressure.
982 pub shed: Arc<crate::load_shed::LoadShedController>,
983 /// Blob store the serve path reads from, and the pull-through target on a
984 /// miss.
985 pub cache: CacheEngine,
986 /// The operator's Ethereum key. Signs the `slash_sig` on each
987 /// `StreamResponse`; every other use reads it only for its address.
988 /// Client bindings are verified here, never signed.
989 pub eth_signer: Arc<PrivateKeySigner>,
990 /// EIP-712 domain for slash attestations.
991 pub slash_domain: Eip712Domain,
992 /// EIP-712 domain for payment vouchers.
993 pub voucher_domain: Eip712Domain,
994 /// EIP-712 domain for lane bindings.
995 pub bind_domain: Eip712Domain,
996 /// Durable per-lane cumulative state. Fences voucher replay across a
997 /// restart (ADR 003 §Off-chain voucher state persistence).
998 pub channel_state_store: Arc<dyn PoolStateStore>,
999 /// Non-blocking sink for the served-and-paid audit log. One receipt per
1000 /// accepted voucher, so a single delivery emits several.
1001 pub receipt_sink: Arc<dyn ReceiptSink>,
1002 /// Cached `getPool` view (owner + remaining), read by the floor-`M` solvency
1003 /// gate and the ADR 011 funder gate. `None` (tests) disables both gates —
1004 /// they fail open, exactly as before E4 wired the view.
1005 pub pool_view: Option<Arc<dyn crate::pool_view::PoolView>>,
1006 /// Refundable minimum-remaining-deposit floor `M` (token base units). The
1007 /// seller refuses to serve a lane's pool once its on-chain remaining
1008 /// (`getPool.deposit − getPool.totalRedeemed`) minus this floor can no
1009 /// longer cover the next credit window. Threaded from config by E4/F; here
1010 /// it is a plain field the floor-M guard reads.
1011 pub pool_min_remaining_deposit: U256,
1012 /// The served per-MB price, fixed at startup. Reprice by restarting the
1013 /// daemon (see `runtime::reload::warn_restart_required_sections`).
1014 pub rate_per_mb: u64,
1015 /// Cap on concurrently served streams within one connection — the
1016 /// semaphore is built per accepted connection, not per node.
1017 pub max_concurrent_streams: usize,
1018 /// Live content deny-set (ADR 011): the operator's local denylist unioned
1019 /// with the on-chain origin blacklist. NOT an `Option`, unlike the wiring
1020 /// hooks below — an empty deny-set is a correct steady state (most operators
1021 /// deny nothing), so there is no "unwired" case to represent, and an
1022 /// `Option` would only add a way to fail open on a takedown gate. It is a
1023 /// required [`ClientHandlerDeps::new`] parameter: seeding it empty and
1024 /// relying on the runtime to overwrite it was itself a silent fail-open — a
1025 /// construction site that forgot the wiring was indistinguishable from an
1026 /// operator who denies nothing. Callers with no deny-set (tests) pass
1027 /// `ContentDenylist::empty()` explicitly.
1028 pub content_deny: Arc<crate::content_deny::ContentDenylist>,
1029 /// Liveness of the node's chain reads (ADR 011 § Serving while chain-stale).
1030 /// `Some` only when the blacklist watcher is wired; the admit path refuses a
1031 /// serve once this reads stale. `None` (dev/test, no chain) disables the
1032 /// gate — the same fail-open shape as an unwired [`Self::pool_view`].
1033 pub chain_freshness: Option<crate::chain_freshness::ChainFreshness>,
1034 // Optional wiring — `None` unless the deployment enables the feature.
1035 /// Best-effort nudge to the settlement service that a lane's accrued
1036 /// claim advanced; sent on every accepted voucher, against no threshold.
1037 /// `None` when no settlement service is wired.
1038 pub redeem_hint: Option<mpsc::Sender<LaneKey>>,
1039 /// Deadline for the node-to-node cache-miss pull, so a slow upstream
1040 /// cannot pin the delivery path. `None` disables the node-to-node leg
1041 /// only; a miss can still fill from the local origin via
1042 /// [`Self::local_populate`].
1043 pub pull_through: Option<Duration>,
1044 /// Deadline for filling a miss from this node's OWN configured fs/http/s3
1045 /// origin, tried ahead of any node-to-node path and independent of
1046 /// [`Self::pull_through`]. `None` skips the local-origin tier.
1047 pub local_populate: Option<Duration>,
1048 /// Selects the window-paced node-to-node pull leg, which bounds
1049 /// speculative spend to the ramped credit window. `None` falls back to the
1050 /// buffered `populate` path when [`Self::pull_through`] is set.
1051 pub pull_through_origin: Option<Arc<NodeOrigin>>,
1052 /// Downstream credit-window ceiling in bytes (ADR 003 §Credit window): the
1053 /// per-stream window ramps toward this cap as the stream pays. Defaults to
1054 /// `DEFAULT_CREDIT_MAX` (64 MiB); the runtime sets it from
1055 /// `payment.credit_max`. The SAME ceiling paces the pull leg's upstream
1056 /// speculative spend on a cache-miss pull (`RampPacer`, #1669), so the
1057 /// upstream and downstream ramps never diverge. Floored at one chunk so
1058 /// the serve loop can always make progress.
1059 pub credit_max: u64,
1060 /// Ramp divisor for the credit window (ADR 003 §Credit window): the window is
1061 /// `paid / credit_ramp_divisor`, floored at one chunk and capped at
1062 /// `credit_max`. Defaults to `DEFAULT_CREDIT_RAMP_DIVISOR` (2); the runtime
1063 /// sets it from `payment.credit_ramp_divisor`. `0` opens the full ceiling
1064 /// immediately.
1065 pub credit_ramp_divisor: u64,
1066 /// Target wire-frame size in bytes for the serve path (ADR 005
1067 /// §`cdn/client/v1`). Node-local policy, never negotiated: the payer accepts
1068 /// any non-empty frame, and neither payment nor bao verification is defined
1069 /// over frame boundaries. Defaults to `DEFAULT_FRAME_TARGET_BYTES` (1 MiB);
1070 /// the runtime sets it from `payment.frame_target_bytes`. The serve loop
1071 /// clamps each request to the credit window's remaining room, so this is a
1072 /// ceiling on frame size rather than an exact size.
1073 pub frame_target_bytes: u64,
1074 /// Application-layer idle-close ceiling for a whole connection (ADR 005
1075 /// §Connection lifetime). `None` — the production path — reads as
1076 /// `APP_IDLE_TIMEOUT` (30s); tests set a shorter one.
1077 pub idle_timeout: Option<Duration>,
1078 /// Wall-clock cadence for the mid-stream pool-solvency re-check (ADR 003
1079 /// §Pool solvency). `None` (the default and production path) reads as
1080 /// [`crate::pool_view::POOL_RECHECK_INTERVAL`]; a shorter value is set at
1081 /// construction only by tests, so a drain case need not wait a real interval.
1082 pub pool_recheck_interval: Option<Duration>,
1083 /// Per-signer LIVE concurrency cap `k`, in credit windows (ADR 003 §Pool solvency,
1084 /// per-signer floor isolation): the most live un-vouchered reservation one signer
1085 /// may hold, `k · one_window`. The runtime sets it from
1086 /// `blockchain.pool_floor_signer_live_windows`; the default of `u64::MAX` leaves it
1087 /// effectively unbounded, which is what unit tests that only exercise the pool-wide
1088 /// bound want. Lower-clamped to one window at use.
1089 pub pool_floor_signer_live_windows: u64,
1090 /// ADR 041 serve-credit boundary in front of the per-source warming
1091 /// allowance — the ledger the buy loop
1092 /// ([`crate::node_origin::NodeOriginConfig`]) debits and eviction forgets. On
1093 /// each clean serve the handler enqueues the realized operator margin for the
1094 /// source that speculatively warmed the blob (a no-op for an untagged /
1095 /// non-speculative hash), so the stream task never waits on the ledger lock.
1096 /// Defaults to the inert [`crate::warming_allowance::NoopWarmingCreditSink`];
1097 /// the runtime overwrites it with the channel sink from
1098 /// [`crate::warming_allowance::spawn_warming_creditor`], and a handler left
1099 /// with the default simply applies no warming credit.
1100 pub warming_credit: Arc<dyn crate::warming_allowance::WarmingCreditSink>,
1101 /// Live operator fee-share (basis points) cell (`FeeRouter.getShares()[0]`), the
1102 /// `(1 − f)` numerator the ADR 041 serve credit realizes. Defaults to zero
1103 /// (tests): a zero share credits nothing.
1104 pub operator_shares: crate::fee_shares::OperatorShares,
1105 /// Origin-only policy (#1759). When `false`, `serve_stream` declines any
1106 /// hash its own backend does not hold — including a cache HIT for a
1107 /// foreign hash — before any discovery, lane accounting, or spend. `true`
1108 /// (the default) serves and relays foreign hashes as well.
1109 pub relay_foreign_namespaces: bool,
1110 /// Coarse wall clock for the two reads the voucher-accept path takes under
1111 /// the per-lane lock — the capability-expiry gate and the `last_voucher_at`
1112 /// stamp (issue #1792 item 4). `None` (the default and every test) means the
1113 /// handler builds its own unrefreshed clock, which reads the live wall clock
1114 /// on every call. The runtime sets `Some` with a refresher running, so each
1115 /// of those two reads becomes a relaxed atomic load instead of a
1116 /// `SystemTime::now()` syscall in the critical section.
1117 pub coarse_clock: Option<Arc<crate::coarse_clock::CoarseClock>>,
1118 /// The redeemer's self-tick interval in seconds
1119 /// (`blockchain.redeem_interval_secs`). The voucher-accept path stops
1120 /// accepting vouchers this interval plus the redeemer's landing slack before
1121 /// a capability expires ([`decdn_common::config::capability_expiry_margin_secs`],
1122 /// ADR 003 §Revocation). Defaults to the config default.
1123 pub redeem_interval_secs: u64,
1124}
1125
1126impl std::fmt::Debug for ClientHandlerDeps {
1127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1128 f.debug_struct("ClientHandlerDeps")
1129 .field("node_id", &self.node_id)
1130 .field("max_concurrent_streams", &self.max_concurrent_streams)
1131 .finish_non_exhaustive()
1132 }
1133}
1134
1135impl ClientHandlerDeps {
1136 /// The required runtime deps; every optional wiring hook defaults to `None`.
1137 #[allow(clippy::too_many_arguments)] // required runtime state; optionals set on the returned value.
1138 pub fn new(
1139 node_id: PublicKey,
1140 metrics: Arc<Metrics>,
1141 limiter: Arc<ConnectionLimiter>,
1142 cache: CacheEngine,
1143 eth_signer: Arc<PrivateKeySigner>,
1144 slash_domain: Eip712Domain,
1145 voucher_domain: Eip712Domain,
1146 bind_domain: Eip712Domain,
1147 channel_state_store: Arc<dyn PoolStateStore>,
1148 receipt_sink: Arc<dyn ReceiptSink>,
1149 rate_per_mb: u64,
1150 max_concurrent_streams: usize,
1151 content_deny: Arc<crate::content_deny::ContentDenylist>,
1152 pool_min_remaining_deposit: U256,
1153 shed: Arc<crate::load_shed::LoadShedController>,
1154 ) -> Self {
1155 Self {
1156 node_id,
1157 metrics,
1158 limiter,
1159 shed,
1160 cache,
1161 eth_signer,
1162 slash_domain,
1163 voucher_domain,
1164 bind_domain,
1165 channel_state_store,
1166 receipt_sink,
1167 pool_view: None,
1168 pool_min_remaining_deposit,
1169 rate_per_mb,
1170 max_concurrent_streams,
1171 content_deny,
1172 chain_freshness: None,
1173 redeem_hint: None,
1174 pull_through: None,
1175 local_populate: None,
1176 pull_through_origin: None,
1177 credit_max: decdn_common::config::DEFAULT_CREDIT_MAX,
1178 credit_ramp_divisor: decdn_common::config::DEFAULT_CREDIT_RAMP_DIVISOR,
1179 frame_target_bytes: decdn_common::config::DEFAULT_FRAME_TARGET_BYTES,
1180 idle_timeout: None,
1181 pool_recheck_interval: None,
1182 // No-op per-signer live cap by default: the runtime overrides it from
1183 // config, and a caller that never sets it keeps the pool-wide ceiling as
1184 // the only floor bound (an unbounded per-signer live cap).
1185 pool_floor_signer_live_windows: u64::MAX,
1186 warming_credit: Arc::new(crate::warming_allowance::NoopWarmingCreditSink),
1187 operator_shares: crate::fee_shares::OperatorShares::new(0),
1188 relay_foreign_namespaces: decdn_common::config::DEFAULT_RELAY_FOREIGN_NAMESPACES,
1189 coarse_clock: None,
1190 redeem_interval_secs: decdn_common::config::DEFAULT_REDEEM_INTERVAL_SECS,
1191 }
1192 }
1193}
1194
1195/// Capacity of the capability-verification cache (#1789 item 2). Bounded so a
1196/// flood of distinct capability sends cannot grow it without limit; `ecrecover`
1197/// is expensive enough that a 1024-entry cache still pays for itself across a
1198/// client that re-sends the same capability on every request.
1199///
1200/// The bound is on memory, not on attacker CPU: a flood of distinct garbage
1201/// signatures thrashes the entries and still pays one `ecrecover` per miss, so
1202/// the cache is a hit-path saving rather than an admission control.
1203const CAPABILITY_VERIFY_CACHE_CAPACITY: usize = 1024;
1204
1205/// Cached outcome of a capability owner recovery: the recovered owner, or
1206/// `Invalid` for a signature that does not recover (high-`s`, bad recovery
1207/// id). Both are deterministic per `(digest, signature)`.
1208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1209enum CapabilityVerifyOutcome {
1210 /// `ecrecover` succeeded and recovered this owner.
1211 Owner(Address),
1212 /// The signature is malformed and recovers nothing (deterministic).
1213 Invalid,
1214}
1215
1216/// Bounded LRU cache of capability owner-recovery outcomes, keyed by the full
1217/// signed material `(EIP-712 signing hash, signature bytes)`.
1218///
1219/// Ownership verification in [`ClientHandler::intake_capability`] runs
1220/// `ecrecover` on every capability-carrying request; `ecrecover` is a pure
1221/// function of `(digest, signature)`, so the same signed material always
1222/// recovers the same owner and the result can be cached deterministically.
1223/// Entry is only ever made for the canonical 65-byte EOA signature shape the
1224/// intake path already accepts; an `Invalid` value means "this signature is
1225/// malformed" — also deterministic, also cached, so a repeated malformed
1226/// capability is not re-recovered either.
1227///
1228/// Holds no I/O, and a hit is one hash lookup under one short lock, so the
1229/// cache never lengthens the open path it exists to shorten. It carries no TTL — unlike the TTL-anchored `dht::negative_cache`, an entry
1230/// lives until the capacity evicts it. That is safe because the value is the
1231/// recovered ADDRESS, not a verdict: [`ClientHandler::intake_capability`]
1232/// re-compares it against the live pool owner on every call, so an on-chain
1233/// owner transfer takes effect immediately.
1234#[derive(Debug)]
1235struct CapabilityVerifyCache {
1236 /// `(signing_hash, signature bytes)` → recovered outcome and the tick at
1237 /// which it was last used. Recency lives in the value rather than in the
1238 /// map's order, so a hit is one hash lookup and a field write — no
1239 /// reordering, no memmove.
1240 entries: HashMap<(B256, [u8; 65]), (CapabilityVerifyOutcome, u64)>,
1241 /// Monotonic use counter. Only the ORDER of these values matters, and
1242 /// `u64` at one tick per capability verification does not wrap.
1243 tick: u64,
1244 cap: usize,
1245}
1246
1247impl Default for CapabilityVerifyCache {
1248 fn default() -> Self {
1249 Self::with_capacity(CAPABILITY_VERIFY_CACHE_CAPACITY)
1250 }
1251}
1252
1253impl CapabilityVerifyCache {
1254 fn with_capacity(cap: usize) -> Self {
1255 Self {
1256 entries: HashMap::with_capacity(cap),
1257 tick: 0,
1258 cap: cap.max(1),
1259 }
1260 }
1261
1262 /// The cached recovery outcome for `(signing_hash, signature)`, or `None`
1263 /// on a miss. A hit re-stamps the entry as most-recently-used.
1264 fn get(&mut self, signing_hash: B256, signature: [u8; 65]) -> Option<CapabilityVerifyOutcome> {
1265 self.tick = self.tick.wrapping_add(1);
1266 let tick = self.tick;
1267 let (outcome, last_used) = self.entries.get_mut(&(signing_hash, signature))?;
1268 *last_used = tick;
1269 Some(*outcome)
1270 }
1271
1272 /// Record `outcome` for `(signing_hash, signature)`, evicting the
1273 /// least-recently-used entry when full.
1274 ///
1275 /// The eviction scan is linear in `cap`, but it runs only on an insert that
1276 /// would overflow — i.e. on a path that has just paid an `ecrecover`, which
1277 /// costs orders of magnitude more than a 1024-entry scan. The hit path,
1278 /// which is the one this cache exists to shorten, stays O(1).
1279 fn insert(
1280 &mut self,
1281 signing_hash: B256,
1282 signature: [u8; 65],
1283 outcome: CapabilityVerifyOutcome,
1284 ) {
1285 self.tick = self.tick.wrapping_add(1);
1286 let tick = self.tick;
1287 let key = (signing_hash, signature);
1288 if self.entries.insert(key, (outcome, tick)).is_some() {
1289 return;
1290 }
1291 while self.entries.len() > self.cap {
1292 let Some(least_recent) = self
1293 .entries
1294 .iter()
1295 .min_by_key(|(_, (_, last_used))| *last_used)
1296 .map(|(k, _)| *k)
1297 else {
1298 break;
1299 };
1300 self.entries.remove(&least_recent);
1301 }
1302 }
1303
1304 #[cfg(test)]
1305 fn len(&self) -> usize {
1306 self.entries.len()
1307 }
1308}
1309
1310/// `cdn/client/v1` paid-delivery handler.
1311pub struct ClientHandler {
1312 node_id: PublicKey,
1313 metrics: Arc<Metrics>,
1314 limiter: Arc<ConnectionLimiter>,
1315 /// Overload-protection gate: sheds new serves under resource pressure.
1316 shed: Arc<crate::load_shed::LoadShedController>,
1317 cache: CacheEngine,
1318 eth_signer: Arc<PrivateKeySigner>,
1319 /// `SlashJudge` EIP-712 domain for `StreamResponse.slash_sig`.
1320 slash_domain: Eip712Domain,
1321 /// `PaymentPool` EIP-712 domain for voucher verification.
1322 voucher_domain: Eip712Domain,
1323 /// `CapacityBond` EIP-712 domain for ephemeral `BindNodeId` verification.
1324 bind_domain: Eip712Domain,
1325 channel_state_store: Arc<dyn PoolStateStore>,
1326 /// Refundable minimum-remaining-deposit floor `M` for the floor-M serving
1327 /// guard (see [`ClientHandlerDeps::pool_min_remaining_deposit`]).
1328 pool_min_remaining_deposit: U256,
1329 /// Non-blocking sink for the served-and-paid audit log (issues #248, #803).
1330 /// The voucher-accept path enqueues one receipt here as each voucher is
1331 /// accepted; the actual disk write happens off the hot path in the background
1332 /// receipt writer, so receipt-log I/O can never back-pressure paid delivery.
1333 /// A dropped receipt (queue full) is non-fatal — the payment already advanced
1334 /// the lane watermark.
1335 receipt_sink: Arc<dyn ReceiptSink>,
1336 /// Cached `getPool` view for the floor-`M` and ADR 011 funder gates. `None`
1337 /// (tests) makes both gates fail open.
1338 pool_view: Option<Arc<dyn crate::pool_view::PoolView>>,
1339 /// Liveness of the node's chain reads (ADR 011 § Serving while chain-stale).
1340 /// `Some` only when the blacklist watcher is wired; the admit path refuses
1341 /// once it reads stale. `None` (tests) disables the gate.
1342 chain_freshness: Option<crate::chain_freshness::ChainFreshness>,
1343 /// Per-lane state, hydrated from the store at construction. The sharded map
1344 /// resolves independent lanes concurrently — a lookup keyed by [`LaneKey`]
1345 /// is a point read that only locks that key's shard; each inner mutex
1346 /// serializes voucher application for one lane across its concurrent streams
1347 /// (ADR 003 §concurrent streams). No call site holds a map entry across an
1348 /// `.await`, so lane lookup never blocks an unrelated lane's admission.
1349 lanes: Arc<DashMap<LaneKey, Arc<Mutex<LaneDeliveryState>>>>,
1350 /// Live-lane count backing `decdn_lanes_open` (#1789 item 3): `fetch_add`
1351 /// on a real insert, `fetch_sub` on a real remove, seeded once at
1352 /// construction from the hydrated map. Holding the count in an atomic keeps
1353 /// the gauge off an ordered walk of the (possibly large) lane map on the
1354 /// registration path.
1355 lane_count: AtomicUsize,
1356 /// Test-only: a lane [`ClientHandler::register_lane_after_next_resolution`]
1357 /// armed, registered right after the next request resolves its lane.
1358 #[cfg(any(test, feature = "test-support"))]
1359 lane_after_resolution: std::sync::Mutex<Option<LaneState>>,
1360 /// Serializes the read-and-publish half of [`Self::tune_lane_gauge`], so
1361 /// two concurrent lane-lifecycle calls cannot publish `decdn_lanes_open`
1362 /// out of order. The atomic alone fixes the count, not the publication:
1363 /// without this the slower task's `set_lanes_open` overwrites a
1364 /// fresher lane count with its own staler one, and the gauge stays wrong
1365 /// until the next lifecycle event. Held across one atomic RMW and one gauge
1366 /// store, never across an `.await` or a map walk.
1367 lane_gauge_publish: std::sync::Mutex<()>,
1368 /// Bounded cache of capability owner-recovery outcomes keyed by the full
1369 /// signed material (#1789 item 2), so a client that re-sends the same
1370 /// capability on every request skips the per-request `ecrecover` in
1371 /// [`ClientHandler::intake_capability`].
1372 capability_verify_cache: std::sync::Mutex<CapabilityVerifyCache>,
1373 /// Floor accounting (ADR 003 §Pool solvency): per pool `live_reservation` and, per
1374 /// signer within each pool, that signer's live slice. Guards an O(1) map only and
1375 /// is never held across `.await` — a plain `std::sync::Mutex`, so a
1376 /// [`FloorReservation`]'s `Drop` can reconcile under it (a tokio mutex cannot be
1377 /// locked in `Drop`). Purely in-memory: no stream is live at restart, so it clears.
1378 pool_floor: Arc<std::sync::Mutex<HashMap<B256, PoolFloorState>>>,
1379 /// Per-signer LIVE concurrency cap `k`, in credit windows
1380 /// (see [`ClientHandlerDeps::pool_floor_signer_live_windows`]). Read by
1381 /// [`Self::signer_floor_cap`] on every admission
1382 /// ([`Self::try_reserve_floor`]). The mid-stream and direct-serve re-checks
1383 /// leave it out — see [`Self::pool_budget_covers_reserve`].
1384 pool_floor_signer_live_windows: u64,
1385 /// Redeem-hint sender to the on-chain settlement service (#327), set at
1386 /// construction via [`ClientHandlerDeps`]. `None` when no settlement service
1387 /// is wired (e.g. tests) — a hint is best-effort, so an absent sender or a
1388 /// full channel just skips it. Keyed by [`LaneKey`]: redemption is per-lane.
1389 redeem_hint: Option<mpsc::Sender<LaneKey>>,
1390 /// Node-to-node cache-miss pull-through deadline (#831), set at construction
1391 /// via [`ClientHandlerDeps`]. `None` (the default — feature off, and in
1392 /// tests) disables pull-through: a cache miss returns `NotFound`. When
1393 /// `Some`, a miss *from a request that proves ownership of the named channel*
1394 /// (see [`Self::pull_authorized`]) triggers `cache.populate` (the engine's
1395 /// `NodeOrigin` discovers, pays, pulls, and fills the store), bounded by this
1396 /// deadline so a slow upstream can't pin the delivery path. Proven channel
1397 /// ownership — not mere channel existence, which is public — is the
1398 /// anti-proxy-abuse gate: a client without an owned channel cannot make this
1399 /// node front upstream egress.
1400 pull_through: Option<Duration>,
1401 /// Reactive LOCAL-origin pull-through deadline (#1116), set at construction
1402 /// via [`ClientHandlerDeps`] whenever `[cache.origin]` is configured —
1403 /// INDEPENDENT of `node_to_node_pull_through_enabled`. When `Some`, a cache
1404 /// miss on a proven-owned channel first tries to fill from the node's OWN
1405 /// fs/http/s3 origin (`CacheEngine::populate_local`, which never touches the
1406 /// paid `Peer` origin), so a cache-only operator can reactively serve its own
1407 /// content and a local origin is preferred over the paid peer window path.
1408 /// `None` disables local populate (miss ⇒ node→node path or a plain
1409 /// `NotFound`).
1410 local_populate: Option<Duration>,
1411 /// Window-paced node→node pull-through provider (#856), set at construction
1412 /// via [`ClientHandlerDeps`]. When `Some` (alongside `pull_through`), a cache
1413 /// miss for an offset-0 request that proves channel ownership is served by
1414 /// fusing a progressive upstream pull with downstream delivery — forwarding
1415 /// each chunk to the paying client and teeing it into the cache — so
1416 /// per-request speculative exposure is bounded to the ramped credit window
1417 /// (#1669) instead of the whole blob. `None` keeps the buffered `populate`
1418 /// path (`pull_through`) or a plain `NotFound`.
1419 pull_through_origin: Option<Arc<NodeOrigin>>,
1420 /// Downstream credit-window ceiling in bytes (ADR 003 §Credit window), set at
1421 /// construction via [`ClientHandlerDeps`]. The serve loop keeps streaming
1422 /// while `delivered − paid ≤ credit_window`, collecting cumulative vouchers as
1423 /// they arrive instead of stalling a full round trip at every interval. Read
1424 /// through [`Self::credit_window`], which ramps from one interval toward this
1425 /// ceiling as `paid` grows.
1426 credit_max: u64,
1427 /// Ramp divisor for the credit window (ADR 003 §Credit window), set at
1428 /// construction via [`ClientHandlerDeps`]. Read through
1429 /// [`Self::credit_window`].
1430 credit_ramp_divisor: u64,
1431 /// Target wire-frame size in bytes, set at construction via
1432 /// [`ClientHandlerDeps`]. Read through [`Self::frame_target`], which clamps it
1433 /// to the credit window's remaining room.
1434 frame_target_bytes: u64,
1435 /// Live content deny-set (ADR 011), keyed on the pool FUNDER. Consulted at
1436 /// two points, both of which must gate or the check is bypassable: the
1437 /// open-time funder gate in `serve_stream`, right after the pool status
1438 /// resolves, and [`Self::takedown_landed`], the per-interval in-flight
1439 /// re-check every serve leg runs. The open-time gate precedes every fill
1440 /// tier, so omitting it would have this node front upstream USDC or origin
1441 /// egress on a blacklisted funder's behalf. The hash half of ADR 011 is the
1442 /// cache's own deny state (`CacheEngine::is_denied` / `refuses`).
1443 pub(crate) content_deny: Arc<crate::content_deny::ContentDenylist>,
1444 /// Served per-MB price, fixed at startup (see
1445 /// [`ClientHandlerDeps::rate_per_mb`]).
1446 rate_per_mb: u64,
1447 max_concurrent_streams: usize,
1448 /// Throttle for the insufficient-deposit refusal `warn!` (#1520). Unkeyed:
1449 /// the aggregate answers the triage question — "one client ran dry" versus
1450 /// "I am refusing everyone" — and the per-channel detail lives in the
1451 /// `debug!` beside it and in the counter.
1452 deposit_refusal_warn: WarnThrottle,
1453 /// The same window for the per-signer LIVE-cap arm. Kept separate from the
1454 /// pool arm because the two carry different remedies — a pool-wide shortfall
1455 /// clears with a top-up, a signer at its share does not — so one must not
1456 /// starve the other's line or pollute its `suppressed` count.
1457 signer_cap_refusal_warn: WarnThrottle,
1458 /// Throttle for the `warn!` on a client binding whose signature is invalid
1459 /// or recovers a different address. A remote peer triggers it at will. The
1460 /// two causes share one window on purpose: both are the same client fault
1461 /// with the same remedy (the client signs its binding wrongly), and each
1462 /// event also counts into `decdn_serve_stream_rejected_bad_binding_total`.
1463 binding_warn: WarnThrottle,
1464 /// Throttle for the `warn!` on a stream request that carries no verified
1465 /// binding. A remote peer triggers it at will.
1466 unbound_request_warn: WarnThrottle,
1467 /// Throttle for the `warn!` on a stream request that names no known lane. A
1468 /// remote peer triggers it at will.
1469 unknown_lane_warn: WarnThrottle,
1470 /// Throttle for the serve-miss refusal line on a request with no lane here
1471 /// ([`MissPath::NoLane`]). A remote peer triggers it at will.
1472 no_lane_miss_log: WarnThrottle,
1473 /// Application-layer idle-close ceiling (ADR 005 §Connection lifetime).
1474 /// `None` (the default and production path) reads as [`APP_IDLE_TIMEOUT`]
1475 /// (30s); a shorter value is set at construction via [`ClientHandlerDeps`]
1476 /// only by tests, so an idle-close case need not wait a real 30s.
1477 idle_timeout: Option<Duration>,
1478 /// Wall-clock cadence for the mid-stream pool-solvency re-check (ADR 003
1479 /// §Pool solvency), read through [`Self::pool_recheck_interval`]. `None` (the
1480 /// default and production path) reads as
1481 /// [`crate::pool_view::POOL_RECHECK_INTERVAL`]; a shorter value is set at
1482 /// construction via [`ClientHandlerDeps`] only by tests.
1483 pool_recheck_interval: Option<Duration>,
1484 /// ADR 041 serve-credit sink in front of the per-source warming allowance the
1485 /// buy loop and eviction share. Enqueues the realized operator margin on each
1486 /// clean serve (a no-op for an untagged hash) without taking the ledger lock.
1487 warming_credit: Arc<dyn crate::warming_allowance::WarmingCreditSink>,
1488 /// Live operator fee-share (basis points), the ADR 041 serve-credit numerator.
1489 operator_shares: crate::fee_shares::OperatorShares,
1490 /// Origin-only policy (#1759), set at construction via
1491 /// [`ClientHandlerDeps::relay_foreign_namespaces`]. Read by the gate at the
1492 /// top of `serve_stream`.
1493 relay_foreign_namespaces: bool,
1494 /// Coarse wall clock for the voucher-accept path's two under-lock reads —
1495 /// the capability-expiry gate and the `last_voucher_at` stamp (issue #1792
1496 /// item 4). The runtime supplies one with a background refresher; a handler
1497 /// built without one (tests) gets a fresh [`CoarseClock`](crate::coarse_clock::CoarseClock)
1498 /// that reads the live wall clock on every call, so behavior is unchanged
1499 /// there.
1500 coarse_clock: Arc<crate::coarse_clock::CoarseClock>,
1501 /// How long before a capability's expiry the voucher-accept path stops
1502 /// accepting vouchers, in seconds: one redeem interval plus the redeemer's
1503 /// landing slack ([`decdn_common::config::capability_expiry_margin_secs`]).
1504 capability_expiry_margin_secs: u64,
1505}
1506
1507impl std::fmt::Debug for ClientHandler {
1508 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1509 f.debug_struct("ClientHandler")
1510 .field("node_id", &self.node_id)
1511 .field("max_concurrent_streams", &self.max_concurrent_streams)
1512 .finish_non_exhaustive()
1513 }
1514}
1515
1516impl ClientHandler {
1517 /// The ALPN this handler answers on: `cdn/client/v1`, the single paid
1518 /// delivery protocol for both client-to-node and node-to-node transfers.
1519 pub const ALPN: &'static [u8] = ALPN_CLIENT;
1520
1521 /// Construct the handler from [`ClientHandlerDeps`], hydrating per-channel
1522 /// state from the deps' `channel_state_store`.
1523 ///
1524 /// All optional runtime wiring (settlement redeem hints, pull-through
1525 /// deadlines, the window/leech providers, …) is supplied on `deps` as
1526 /// `Some`/`None` at construction — there is no post-construction attach step,
1527 /// so a handler's full wiring is one reviewable literal at its call site.
1528 ///
1529 /// # Errors
1530 ///
1531 /// Propagates a [`decdn_incentive::StoreError`] if the persisted channel
1532 /// state cannot be loaded — the node must not serve paid delivery without
1533 /// knowing prior voucher state (the #527 replay guard).
1534 pub fn new(deps: ClientHandlerDeps) -> anyhow::Result<Self> {
1535 let map: DashMap<LaneKey, Arc<Mutex<LaneDeliveryState>>> = DashMap::new();
1536 for state in deps.channel_state_store.load_all()? {
1537 map.insert(
1538 state.key(),
1539 Arc::new(Mutex::new(LaneDeliveryState::hydrated(state))),
1540 );
1541 }
1542 // Seed the atomic lane counter once at hydrate; `register_lane` /
1543 // `forget_lane` tune it from then on (#1789 item 3). The pool deposit
1544 // behind these lanes is published separately, by the redeemer tick that
1545 // can actually see it (#2072).
1546 let open_lanes = map.len();
1547 let lane_count = AtomicUsize::new(open_lanes);
1548 deps.metrics.set_lanes_open(open_lanes);
1549 // The floor accumulator is purely in-memory: no stream is live at boot, so
1550 // every `live_reservation` starts at zero and nothing is loaded from disk.
1551 let pool_floor: HashMap<B256, PoolFloorState> = HashMap::new();
1552 Ok(Self {
1553 node_id: deps.node_id,
1554 metrics: deps.metrics,
1555 limiter: deps.limiter,
1556 shed: deps.shed,
1557 cache: deps.cache,
1558 eth_signer: deps.eth_signer,
1559 slash_domain: deps.slash_domain,
1560 voucher_domain: deps.voucher_domain,
1561 bind_domain: deps.bind_domain,
1562 channel_state_store: deps.channel_state_store,
1563 pool_min_remaining_deposit: deps.pool_min_remaining_deposit,
1564 receipt_sink: deps.receipt_sink,
1565 pool_view: deps.pool_view,
1566 chain_freshness: deps.chain_freshness,
1567 lanes: Arc::new(map),
1568 lane_count,
1569 #[cfg(any(test, feature = "test-support"))]
1570 lane_after_resolution: std::sync::Mutex::new(None),
1571 lane_gauge_publish: std::sync::Mutex::new(()),
1572 capability_verify_cache: std::sync::Mutex::new(CapabilityVerifyCache::default()),
1573 pool_floor: Arc::new(std::sync::Mutex::new(pool_floor)),
1574 pool_floor_signer_live_windows: deps.pool_floor_signer_live_windows,
1575 redeem_hint: deps.redeem_hint,
1576 pull_through: deps.pull_through,
1577 local_populate: deps.local_populate,
1578 pull_through_origin: deps.pull_through_origin,
1579 credit_max: deps.credit_max,
1580 credit_ramp_divisor: deps.credit_ramp_divisor,
1581 frame_target_bytes: deps.frame_target_bytes,
1582 content_deny: deps.content_deny,
1583 rate_per_mb: deps.rate_per_mb,
1584 max_concurrent_streams: deps.max_concurrent_streams,
1585 deposit_refusal_warn: WarnThrottle::new(Self::DEPOSIT_REFUSAL_WARN_INTERVAL),
1586 signer_cap_refusal_warn: WarnThrottle::new(Self::DEPOSIT_REFUSAL_WARN_INTERVAL),
1587 binding_warn: WarnThrottle::new(Self::PEER_FAULT_WARN_INTERVAL),
1588 unbound_request_warn: WarnThrottle::new(Self::PEER_FAULT_WARN_INTERVAL),
1589 unknown_lane_warn: WarnThrottle::new(Self::PEER_FAULT_WARN_INTERVAL),
1590 no_lane_miss_log: WarnThrottle::new(Self::PEER_FAULT_WARN_INTERVAL),
1591 idle_timeout: deps.idle_timeout,
1592 pool_recheck_interval: deps.pool_recheck_interval,
1593 warming_credit: deps.warming_credit,
1594 operator_shares: deps.operator_shares,
1595 relay_foreign_namespaces: deps.relay_foreign_namespaces,
1596 // A handler with no clock wired (tests) reads the live wall clock on
1597 // every call, exactly as before #1792 item 4.
1598 coarse_clock: deps
1599 .coarse_clock
1600 .unwrap_or_else(|| Arc::new(crate::coarse_clock::CoarseClock::new())),
1601 capability_expiry_margin_secs: decdn_common::config::capability_expiry_margin_secs(
1602 deps.redeem_interval_secs,
1603 ),
1604 })
1605 }
1606
1607 /// ADR 041 serve credit: return the realized operator margin to the source that
1608 /// speculatively warmed `hash`, for a clean serve of `served_bytes`. The credit
1609 /// is `(operator_bps / 10_000) · P_sell · MB(served_bytes)`, matching the
1610 /// buy-loop debit's units. A no-op for an untagged hash (own-namespace or
1611 /// non-speculative), so calling it unconditionally on every serve is correct.
1612 ///
1613 /// The arithmetic runs here and the ledger update is handed to the wired
1614 /// [`crate::warming_allowance::WarmingCreditSink`], so this — the stream
1615 /// task's last act on a clean completion — costs whatever that sink costs.
1616 /// The runtime's channel sink is one tag-map shard read and one bounded
1617 /// `try_send`, and never waits on the bucket lock the buy loop takes, which
1618 /// is what the sink contract requires of anything on this path.
1619 fn credit_warming_serve(&self, hash: Hash, served_bytes: u64) {
1620 let sell_rate = self.rate_per_mb;
1621 let margin_per_mb = sell_rate
1622 .saturating_mul(u64::from(self.operator_shares.bps()))
1623 .saturating_div(10_000);
1624 let mb = served_bytes.div_ceil(decdn_protocol::MB_BYTES);
1625 self.warming_credit
1626 .credit(hash, mb.saturating_mul(margin_per_mb));
1627 }
1628
1629 /// The wall-clock cadence for the mid-stream pool-solvency re-check (ADR 003
1630 /// §Pool solvency): the configured test override, else
1631 /// [`crate::pool_view::POOL_RECHECK_INTERVAL`].
1632 pub(super) fn pool_recheck_interval(&self) -> Duration {
1633 self.pool_recheck_interval
1634 .unwrap_or(crate::pool_view::POOL_RECHECK_INTERVAL)
1635 }
1636
1637 /// The pool's cached `getPool` status (owner + `remaining`), or `None` when no
1638 /// pool-view is wired or the read faulted. Both the takedown funder resolution
1639 /// ([`Self::pool_funder`]) reads through this at the START of a serve leg; a
1640 /// `None` result makes the caller fail open — a transient RPC blip must not stop
1641 /// a paying stream, and the on-chain `redeem` is the backstop. MAY block on a
1642 /// `getPool` fetch on a cache miss, so it is NOT for the per-voucher-boundary
1643 /// path — the mid-stream re-check uses [`Self::pool_view_status_cached`].
1644 pub(super) async fn pool_view_status(
1645 &self,
1646 pool_id: B256,
1647 ) -> Option<crate::pool_view::PoolStatus> {
1648 self.pool_view.as_ref()?.status(pool_id).await
1649 }
1650
1651 /// CACHE-ONLY pool status for the per-voucher-boundary mid-stream solvency
1652 /// re-check: never triggers a `getPool` `eth_call`, so it cannot stall the serve
1653 /// loop when the RPC is slow. Returns `None` when nothing fresh is cached (the
1654 /// re-check then fails open, exactly as on a fetch fault) — a long stream whose
1655 /// admission read has aged past the cache TTL simply stops re-checking rather
1656 /// than blocking delivery on a fresh read. The on-chain `redeem` remains the
1657 /// backstop, and a wider serve-path RPC reduction is tracked separately.
1658 pub(super) async fn pool_view_status_cached(
1659 &self,
1660 pool_id: B256,
1661 ) -> Option<crate::pool_view::PoolStatus> {
1662 self.pool_view.as_ref()?.cached_status(pool_id).await
1663 }
1664
1665 /// The signer's total on-chain `spent` across every provider in `pool_id`, read
1666 /// from the event-fed projection ONLY (never a `getAuthorization`), for the
1667 /// mid-stream signer cap-headroom re-check. `None` when no pool-view is wired
1668 /// (dev/test, no chain) or the view holds no projection — the re-check then skips
1669 /// and delivery continues, matching how [`Self::pool_view_status_cached`] fails
1670 /// open. See [`crate::pool_view::PoolView::signer_spent_cached`].
1671 pub(super) async fn signer_spent_cached(&self, pool_id: B256, signer: Address) -> Option<u64> {
1672 self.pool_view
1673 .as_ref()?
1674 .signer_spent_cached(pool_id, signer)
1675 .await
1676 }
1677
1678 /// Whether a live stream must stop because its voucher `signer` has drained its
1679 /// shared on-chain `cap` at other nodes since admission (ADR 003 §Pool solvency,
1680 /// mid-stream re-check). A signer's `cap` is shared across every provider, so a
1681 /// signer that spends it elsewhere leaves `held_cap − spent` unable to cover a
1682 /// serve floor here, and further vouchers redeem `min(desired, cap − spent) ≈ 0`
1683 /// — the node would eat the delivered bytes, unbounded for a large blob.
1684 ///
1685 /// `held_cap` is the `cap` the node already holds on the lane (the admit-time
1686 /// capability), and `spent` is read from the event-fed projection. The floor is
1687 /// one ramp-start credit window, the SAME quantity the admit-time gate confirms
1688 /// (`ClientHandler::serve_stream`), so the mid-stream threshold matches admission.
1689 ///
1690 /// Fails toward SERVING: `signer_spent_cached` returning `None` (no projection)
1691 /// skips the stop, and a projection that has not yet folded the signer's
1692 /// pre-admission spend only UNDER-counts `spent`, over-stating headroom. That is
1693 /// acceptable — the admit-time `getAuthorization` (#1958) already caught an
1694 /// already-exhausted signer authoritatively, so this only catches drain SINCE
1695 /// admit, and the on-chain `redeemMany` `min(desired, cap − spent)` is the
1696 /// backstop; this re-check only BOUNDS over-delivery, it is not a correctness
1697 /// gate. The caller ends the stream as `Stopped { SignerCapExhausted }`, which
1698 /// the dispatch sink counts on the mid-stream metric.
1699 pub(super) async fn signer_cap_drained_midstream(
1700 &self,
1701 pool_id: B256,
1702 signer: Address,
1703 held_cap: U256,
1704 rate_per_mb: u64,
1705 ) -> bool {
1706 let Some(spent) = self.signer_spent_cached(pool_id, signer).await else {
1707 return false;
1708 };
1709 let headroom = held_cap.saturating_sub(U256::from(spent));
1710 let floor_micro = U256::from(decdn_incentive::min_payment(
1711 self.credit_window(CHUNK_BYTES, 0),
1712 rate_per_mb,
1713 ));
1714 headroom < floor_micro
1715 }
1716
1717 /// The pool's funder (`getPool.owner`) for the ADR 011 mid-stream takedown
1718 /// re-check, or `None` when no pool-view is wired or the read faulted (the
1719 /// re-check then falls back to the open-time gates and the hash-denylist
1720 /// re-check). Cached, so a per-MB call is cheap.
1721 pub(super) async fn pool_funder(&self, pool_id: B256) -> Option<Address> {
1722 self.pool_view_status(pool_id).await.map(|s| s.owner)
1723 }
1724
1725 /// Recover an owner-signed capability's EIP-712 signer (ADR 003
1726 /// §Capability delegation), short-circuiting on the capability-verification
1727 /// cache (#1789 item 2). The caller compares the result against the pool
1728 /// owner; recovery says who signed, not whether the grant is accepted.
1729 ///
1730 /// `ecrecover` is a pure function of `(digest, signature)`, so the
1731 /// recovered owner for a given signed material is deterministic and can be
1732 /// cached safely: [`Self::capability_verify_cache`] maps the full
1733 /// `(signing hash, signature bytes)` to the recovered owner, and a client
1734 /// that re-sends the same capability (the documented recovery path) hits
1735 /// the cache instead of paying a fresh `ecrecover` per request. A
1736 /// tampered signature is a different key, so it can never be served a
1737 /// stale cached owner. Caching the recovered ADDRESS rather than an
1738 /// accept/reject verdict is what keeps this sound across an on-chain owner
1739 /// transfer: the comparison is re-made against the live owner every call.
1740 fn recover_capability_owner(&self, grant: &SignedCapability) -> CapabilityVerifyOutcome {
1741 let domain = &self.voucher_domain;
1742 let signing_hash = grant.capability.signing_hash(domain);
1743 let signature = grant.signature.as_bytes();
1744 // The lock is held only for the cache lookup/insert — a hash lookup plus
1745 // an `IndexMap` shift. The expensive `recover_owner` (secp256k1
1746 // ecrecover) runs OUTSIDE the lock, so a burst of distinct
1747 // capabilities at session open does not serialize on the cache mutex
1748 // and block a Tokio worker for the crypto. Concurrent races on the
1749 // same key re-run the recovery and overwrite the entry — duplicate
1750 // work under races is acceptable for a deterministic result.
1751 let recovered = {
1752 let mut cache = self
1753 .capability_verify_cache
1754 .lock()
1755 .unwrap_or_else(std::sync::PoisonError::into_inner);
1756 cache.get(signing_hash, signature)
1757 };
1758 if let Some(outcome) = recovered {
1759 return outcome;
1760 }
1761 let outcome = match grant.recover_owner(domain) {
1762 Ok(owner) => CapabilityVerifyOutcome::Owner(owner),
1763 Err(_) => CapabilityVerifyOutcome::Invalid,
1764 };
1765 self.capability_verify_cache
1766 .lock()
1767 .unwrap_or_else(std::sync::PoisonError::into_inner)
1768 .insert(signing_hash, signature, outcome);
1769 outcome
1770 }
1771
1772 /// Accept an owner-signed capability presented at session start (ADR 003
1773 /// §Capability delegation): verify the owner signature against the on-chain
1774 /// pool owner, register the lane so the voucher path serves it, and persist
1775 /// the grant so the redeemer can register the signer on its first on-chain
1776 /// redemption. `signer` is the request's bound Ethereum address; `pool_id`
1777 /// is [`StreamRequest::pool_id`]; `pool_owner` is `getPool.owner` from the
1778 /// cached pool-view.
1779 ///
1780 /// The owner check is authoritative here, not deferred: the on-chain
1781 /// `PaymentPool.redeemMany` verifies every capability's owner signature
1782 /// against `pools[poolId].owner` and reverts the WHOLE batch on one bad
1783 /// grant, which would strand every other lane's redemption in an
1784 /// indefinite-retry tick. So a grant whose owner signature does not verify
1785 /// against the pool owner is DROPPED — never persisted, never lane-registered.
1786 ///
1787 /// The verification recovers the 65-byte EOA signature via
1788 /// [`Self::recover_capability_owner`] (canonical-`s` ecrecover, matching the
1789 /// contract's verifiable set) and compares it to `pool_owner` — the confirmed
1790 /// on-chain owner the admit path already resolved (the dispatcher calls this
1791 /// only past the refuse-on-unknown-pool gate). A non-65-byte (contract-wallet /
1792 /// ERC-1271-shaped) owner signature drops rather than persists: it cannot be
1793 /// recovered off-chain, and this design does not accept contract wallets as
1794 /// pool owners.
1795 ///
1796 /// Best-effort and off the durability path otherwise: a persist failure never
1797 /// fails the stream. Lane registration is idempotent — a re-observed grant for
1798 /// an already-tracked lane does NOT reset the accepted-voucher watermark
1799 /// (#527); the lane's `cap`/`expiry` are set once at first registration.
1800 #[allow(clippy::cognitive_complexity)] // linear verify → register → persist sequence.
1801 fn intake_capability(
1802 &self,
1803 pool_id: B256,
1804 signer: Address,
1805 pool_owner: Address,
1806 capability: &decdn_protocol::client::WireCapability,
1807 ) {
1808 let spending_cap = capability.spending_cap;
1809 let expiry = capability.expiry;
1810 // Only the common EOA (65-byte) owner signature is recoverable off-chain;
1811 // a contract-wallet signature is dropped rather than persisted unverified.
1812 let Ok(sig_bytes) = <[u8; 65]>::try_from(capability.owner_signature.as_slice()) else {
1813 tracing::debug!(%pool_id, %signer, "dropping capability: owner signature is not a recoverable EOA signature");
1814 return;
1815 };
1816 let Ok(signature) = alloy::primitives::Signature::from_raw(&sig_bytes) else {
1817 tracing::debug!(%pool_id, %signer, "dropping capability: malformed owner signature");
1818 return;
1819 };
1820 let grant = SignedCapability {
1821 capability: Capability {
1822 signer,
1823 spending_cap,
1824 pool_id,
1825 expiry,
1826 },
1827 signature,
1828 };
1829 // #1789 item 2: the ecrecover is cached keyed by the full signed
1830 // material, so a client that re-sends the same capability on every
1831 // request (the documented recovery path for a lost lane) skips it. The
1832 // two drop reasons stay distinct in the log: a malformed signature is a
1833 // client-side signing bug, while a well-formed signature recovering to
1834 // the wrong address is operator-actionable — usually a client still
1835 // signing against an owner the pool has since transferred away.
1836 match self.recover_capability_owner(&grant) {
1837 CapabilityVerifyOutcome::Owner(owner) if owner == pool_owner => {}
1838 CapabilityVerifyOutcome::Owner(recovered) => {
1839 tracing::warn!(
1840 %pool_id,
1841 %signer,
1842 error = %decdn_incentive::capability::CapabilityError::WrongOwner {
1843 expected: pool_owner,
1844 recovered,
1845 },
1846 "dropping capability: owner verification failed"
1847 );
1848 return;
1849 }
1850 CapabilityVerifyOutcome::Invalid => {
1851 tracing::debug!(
1852 %pool_id,
1853 %signer,
1854 error = %decdn_incentive::capability::CapabilityError::InvalidSignature,
1855 "dropping capability: owner verification failed"
1856 );
1857 return;
1858 }
1859 }
1860
1861 // The grant is authentic. Register the lane so the voucher path accepts
1862 // vouchers for `(pool_id, signer, this operator)` — without this a
1863 // brand-new lane's first request is never served, since the serve gate
1864 // admits only known lanes. Idempotent for an already-tracked lane.
1865 //
1866 // The verified owner signature rides the lane record itself, alongside
1867 // the `cap`/`expiry` it authorizes: it is the `ownerSig` the redeemer
1868 // submits as a `CapabilityReg` on the signer's first on-chain redemption.
1869 // Kept ON the lane — not in a side table — so it is written in the same
1870 // durable transaction as the voucher frontier and can never be lost while
1871 // the frontier survives (#1906). A buffered in-memory insert like every
1872 // lane `record`; the row lands on disk in the periodic lane flush's
1873 // fsynced commit, not a per-request fsync on the intake path.
1874 let mut lane = LaneState::hydrate(
1875 pool_id,
1876 signer,
1877 self.eth_signer.address(),
1878 // `LaneState.cap` stays a `U256`; the capability's `u64` cap
1879 // zero-extends into it losslessly.
1880 U256::from(spending_cap),
1881 expiry,
1882 U256::ZERO,
1883 U256::ZERO,
1884 None,
1885 decdn_incentive::LaneChain::NONE,
1886 );
1887 lane.owner_sig = Some(sig_bytes);
1888 if let Err(e) = self.register_lane(lane) {
1889 tracing::warn!(%pool_id, %signer, error = %e, "lane registration failed; the request refuses as an unknown lane and the client retries");
1890 }
1891 }
1892
1893 /// Register a lane so the voucher path accepts vouchers for it — its
1894 /// capability handle is registered on-chain and its [`LaneState`] recorded
1895 /// in the lane store, then inserted into the live map. Called both from the
1896 /// on-chain capability-registration consumer (#327) and from the
1897 /// seller-side capability intake on the first voucher of a new lane.
1898 ///
1899 /// **Idempotent:** a re-observed registration for an already-tracked lane is
1900 /// a no-op — it MUST NOT reset the accepted-voucher watermark and reopen the
1901 /// #527 replay window. The live map (hydrated from the store at
1902 /// construction, updated here) is the authority.
1903 ///
1904 /// # Errors
1905 ///
1906 /// Propagates a [`StoreError`] if the store `record` fails.
1907 pub fn register_lane(&self, state: LaneState) -> Result<(), StoreError> {
1908 let key = state.key();
1909 if self.lanes.contains_key(&key) {
1910 return Ok(());
1911 }
1912 // #1789 item 4: call the store's `record` inline. `register_lane` sits
1913 // ON the per-stream open path (the first capability of every stream
1914 // reaches it), so it cannot afford a `spawn_blocking` hop — and does
1915 // not need one: `PoolStateStore::record` is contractually non-blocking,
1916 // a buffered insert into the store's in-memory working set, with the
1917 // periodic lane flush doing the disk work.
1918 self.channel_state_store.record(&state)?;
1919
1920 // Only a REAL insertion (a vacant slot) tunes the lane gauge — the
1921 // entry guard is atomic, so racing first-streams on one lane still
1922 // count it once.
1923 if let dashmap::mapref::entry::Entry::Vacant(entry) = self.lanes.entry(key) {
1924 entry.insert(Arc::new(Mutex::new(LaneDeliveryState::hydrated(state))));
1925 self.tune_lane_gauge(1);
1926 }
1927 Ok(())
1928 }
1929
1930 /// Test-only: register `state` right after the next `serve_stream` resolves
1931 /// its lane. It stands in for a sibling stream whose capability intake lands
1932 /// while this request is past its lane resolution, the window in which the
1933 /// request must keep the answer it resolved.
1934 #[cfg(any(test, feature = "test-support"))]
1935 pub fn register_lane_after_next_resolution(&self, state: LaneState) {
1936 if let Ok(mut armed) = self.lane_after_resolution.lock() {
1937 *armed = Some(state);
1938 }
1939 }
1940
1941 /// Test-only: register the lane [`Self::register_lane_after_next_resolution`]
1942 /// armed, if any. `serve_stream` calls it once its lane is resolved.
1943 #[cfg(any(test, feature = "test-support"))]
1944 pub(super) fn fire_lane_after_resolution(&self) {
1945 let armed = self
1946 .lane_after_resolution
1947 .lock()
1948 .ok()
1949 .and_then(|mut armed| armed.take());
1950 if let Some(state) = armed
1951 && let Err(e) = self.register_lane(state)
1952 {
1953 tracing::warn!(error = %e, "test seam: armed lane registration failed");
1954 }
1955 }
1956
1957 /// Clamp a live lane to its signer's on-chain registration (ADR 003
1958 /// §Capability delegation): the lane holds `min(held, registered)` for `cap`
1959 /// and `expiry` and records the registered expiry as `registered_until`. On-chain
1960 /// registration is write-once, so the clamp is final for this `(pool, signer)`
1961 /// and the lane store persists it with the lane. A change is recorded to the
1962 /// buffered store under the per-lane lock, the same path as a voucher advance.
1963 /// A store fault is logged and leaves the in-memory clamp in place: the live
1964 /// lane is what the voucher path reads.
1965 async fn clamp_lane_to_registration(
1966 &self,
1967 lane: &Arc<Mutex<LaneDeliveryState>>,
1968 cap: u64,
1969 expiry: u64,
1970 ) {
1971 let mut guard = lane.lock().await;
1972 if !guard.state.clamp_to_registration(cap, expiry) {
1973 return;
1974 }
1975 tracing::debug!(
1976 pool_id = %guard.state.pool_id,
1977 signer = %guard.state.signer,
1978 held_cap = %guard.state.cap,
1979 held_expiry = guard.state.expiry,
1980 "lane clamped to the signer's on-chain registration"
1981 );
1982 if let Err(error) = self.channel_state_store.record(&guard.state) {
1983 tracing::warn!(
1984 pool_id = %guard.state.pool_id,
1985 signer = %guard.state.signer,
1986 %error,
1987 "failed to record a lane clamped to its signer's registration"
1988 );
1989 }
1990 }
1991
1992 /// Drop a settled lane from the live map and the lane store, where the removal
1993 /// is a buffered tombstone that the next flush applies to disk. Idempotent —
1994 /// forgetting an unknown lane is a no-op.
1995 ///
1996 /// # Errors
1997 ///
1998 /// Propagates a [`StoreError`] if the store cannot take the tombstone (its
1999 /// buffer mutex is poisoned), or if the blocking store task fails to join —
2000 /// a panic inside the store, or the runtime shutting down mid-call.
2001 pub async fn forget_lane(&self, key: LaneKey) -> Result<(), StoreError> {
2002 // Removing the lane row drops its `last_voucher_at` stamp with it (issue
2003 // #1733): the timestamp lives on the lane's delivery state, so its
2004 // lifecycle follows the lane automatically — no separate activity-map
2005 // eviction. Only a real removal tunes the lane gauge down.
2006 if self.lanes.remove(&key).is_some() {
2007 self.tune_lane_gauge(-1);
2008 }
2009 let store = Arc::clone(&self.channel_state_store);
2010 tokio::task::spawn_blocking(move || store.forget(key))
2011 .await
2012 .map_err(|e| StoreError::Backend(format!("forget_lane join: {e}")))?
2013 }
2014
2015 /// Minimum gap between insufficient-deposit `warn!` lines (#1520).
2016 ///
2017 /// A module const, not a config field: a log cadence does not justify the five
2018 /// config sites (schema, resolver, validate summary, template, docs) a
2019 /// `[payment]` knob costs, and no operator needs to tune it.
2020 pub(super) const DEPOSIT_REFUSAL_WARN_INTERVAL: Duration = Duration::from_mins(5);
2021
2022 /// Minimum gap between `warn!` lines a remote peer can trigger at will: a bad
2023 /// binding, a request with no binding, a request on an unknown lane. Each
2024 /// line carries the offending `peer` and the `suppressed` count.
2025 pub(super) const PEER_FAULT_WARN_INTERVAL: Duration = Duration::from_mins(1);
2026
2027 /// Emit the observable side of a floor-admission refusal, picking the message
2028 /// the refusing gate actually justifies.
2029 ///
2030 /// The gates need different words and different remedies.
2031 /// [`FloorRefusal::PoolExhausted`] is the pool running dry, which
2032 /// [`Self::log_deposit_refusal`] already describes. The per-signer live cap is the
2033 /// opposite situation: `try_reserve_floor` tests the pool ceiling FIRST, so
2034 /// reaching it proves the pool can pay. Reporting it as a deposit shortfall
2035 /// would print a headroom that visibly exceeds the ceiling beside a sentence
2036 /// denying it, and would send the operator to check RPC health while the real cause
2037 /// is one signer's node-local live cap.
2038 fn log_floor_refusal(&self, refusal: FloorRefusal, at: FloorRefusalSite) {
2039 let FloorRefusalSite {
2040 pool_id,
2041 signer,
2042 hash,
2043 remaining,
2044 ceiling,
2045 } = at;
2046 let headroom = remaining.saturating_sub(self.pool_min_remaining_deposit);
2047 match refusal {
2048 FloorRefusal::PoolExhausted => {
2049 self.log_deposit_refusal(pool_id, hash, headroom, ceiling);
2050 }
2051 FloorRefusal::SignerAtCap { signer_cap } => {
2052 self.log_signer_at_cap(pool_id, signer, hash, signer_cap, ceiling, headroom);
2053 }
2054 }
2055 }
2056
2057 /// The observable side of a per-signer LIVE-cap refusal
2058 /// ([`FloorRefusal::SignerAtCap`]).
2059 fn log_signer_at_cap(
2060 &self,
2061 pool_id: B256,
2062 signer: Address,
2063 hash: Hash,
2064 signer_cap: U256,
2065 ceiling: U256,
2066 headroom: U256,
2067 ) {
2068 tracing::debug!(
2069 %pool_id, %signer, %hash, %signer_cap, %ceiling, %headroom,
2070 "refusing delivery: this capability signer already holds its live concurrency cap \
2071 of un-vouchered floor reservation; the pool itself can still pay"
2072 );
2073 if let Some(suppressed) = self.signer_cap_refusal_warn.admit() {
2074 tracing::warn!(
2075 %pool_id, %signer, %signer_cap, %headroom, suppressed,
2076 interval = ?self.signer_cap_refusal_warn.interval(),
2077 "refusing a paying client: one capability signer is running its whole live \
2078 concurrency cap of un-vouchered streams at once while the pool is solvent. This \
2079 clears as those streams pay; a sustained rate means that signer runs more \
2080 concurrent un-vouchered streams than its cap covers. Rotate the session key or \
2081 widen pool_floor_signer_live_windows — see docs/runbook.md, which covers why \
2082 widening it needs a restart"
2083 );
2084 }
2085 }
2086
2087 /// Emit the observable side of an insufficient-deposit refusal (#1520).
2088 ///
2089 /// Unconditional `debug!` so a support ticket is answerable at all, plus a
2090 /// throttled `warn!` ([`WarnThrottle`]). The wire class is deliberately
2091 /// lossy — it carries no reason and no amount — so without these the only
2092 /// trace of a refusal's numbers is a counter.
2093 ///
2094 /// `headroom` and `ceiling` are both in payment-token base units.
2095 pub(super) fn log_deposit_refusal(
2096 &self,
2097 pool_id: B256,
2098 hash: Hash,
2099 headroom: U256,
2100 ceiling: U256,
2101 ) {
2102 tracing::debug!(
2103 %pool_id, %hash, %headroom, %ceiling,
2104 "refusing delivery: pool's refundable remaining deposit cannot cover the reserved cost"
2105 );
2106 if let Some(suppressed) = self.deposit_refusal_warn.admit() {
2107 tracing::warn!(
2108 %pool_id, %headroom, %ceiling, suppressed,
2109 interval = ?self.deposit_refusal_warn.interval(),
2110 "refusing paying clients: pool's refundable remaining deposit below the reserved cost. \
2111 A sustained rate here is either a client running dry (no action) or this \
2112 node's chain watcher lagging behind an on-chain top-up (check RPC health) \
2113 — see docs/runbook.md"
2114 );
2115 }
2116 }
2117
2118 /// The frame size to request from a frame producer, given `unvouchered` — the
2119 /// bytes delivered since the last payment-chunk boundary — and `interval_bytes`,
2120 /// the payment quantum.
2121 ///
2122 /// **A frame never crosses a payment-chunk boundary.** Both sides meter the same
2123 /// byte stream over the same frame sequence, and each waits for the other at
2124 /// every `interval_bytes`: the node accumulates `unvouchered` until it reaches a
2125 /// full interval and then demands a proof, while the payer releases one
2126 /// hash-chain preimage per interval. A frame that straddled a boundary would
2127 /// land the payer's counter PAST it, which is settled with a signed voucher for
2128 /// the residual instead of a preimage — a per-frame signature on the hot path,
2129 /// and a cadence the two sides no longer agree on. Cutting at the boundary keeps
2130 /// the preimage path exact, whatever the target.
2131 ///
2132 /// This also bounds the node's credit exposure. The serve loop checks the window
2133 /// BEFORE each send, so `delivered - paid` overshoots by at most one frame; with
2134 /// frames capped at one interval the overshoot is at most one payment chunk —
2135 /// the same granularity the node bills at, and the bound ADR 003 §Credit window
2136 /// states as "the window, plus at most one chunk".
2137 ///
2138 /// `room` — the window's unused remainder, `window - (delivered - paid)` — caps
2139 /// the request as well, and that cap is load-bearing on the **cache-miss** leg
2140 /// rather than merely tidy. Both loops prefetch one frame ahead of the window
2141 /// check, and on a miss the producer is fed by an upstream pull paced against
2142 /// this stream's own served-and-paid frontier. Asking a closed window for a full
2143 /// frame parks the prefetch on bytes that cannot arrive until the loop exits to
2144 /// recoup — which it cannot do while parked. Sizing the request to the room that
2145 /// actually remains keeps the prefetch satisfiable from what is already buffered.
2146 ///
2147 /// The room cap is floored at one bao chunk group so a shut window yields a
2148 /// short frame rather than a single byte; that runt goes out once the recoup
2149 /// reopens the window. A stream sitting at the one-chunk credit floor therefore
2150 /// spends about two frames per payment chunk — a full frame, then the runt the
2151 /// shut window produced — while a ramped stream keeps `room` far above the floor
2152 /// and spends one.
2153 ///
2154 /// **The floor is exactly one group because the pull side reserves exactly one.**
2155 /// On a cache miss the bytes this prefetch asks for can only come from the
2156 /// upstream pull, and `decdn_client::PULL_WINDOW_FLOOR` carries a third
2157 /// chunk group for precisely this request. Raising the floor here without
2158 /// raising it there parks the serve leg on bytes the pull may not draw —
2159 /// a hang, not a failed assertion. `frame_target_room_floor_matches_the_pull_reservation`
2160 /// pins the pair.
2161 pub(super) fn frame_target(&self, unvouchered: u64, interval_bytes: u64, room: u64) -> usize {
2162 // `unvouchered < interval_bytes` at every call site — both loops reset it to
2163 // zero the moment it reaches a full interval. Were that ever to break, a
2164 // saturating `0` would ask for one-byte frames forever: a stream that still
2165 // "progresses" at six orders of magnitude below line rate, with no error and
2166 // no metric. Fall back to a whole interval instead, which is wrong in the
2167 // same direction as the rest of the clamp rather than catastrophically.
2168 debug_assert!(
2169 unvouchered < interval_bytes,
2170 "unvouchered {unvouchered} must stay below the {interval_bytes}-byte interval"
2171 );
2172 let to_boundary = interval_bytes
2173 .checked_sub(unvouchered)
2174 .filter(|remaining| *remaining > 0)
2175 .unwrap_or(interval_bytes);
2176 let want = self
2177 .frame_target_bytes
2178 .min(to_boundary)
2179 .min(room.max(decdn_bao_range::CHUNK_GROUP_BYTES));
2180 // Saturating toward `usize::MAX` would tell a producer to buffer without
2181 // bound — the O(blob size) behaviour the framers exist to avoid. Every term
2182 // above is already `<= CHUNK_BYTES`, so this only ever runs on a 16-bit
2183 // target, where one chunk group is the safe answer.
2184 usize::try_from(want)
2185 .unwrap_or_else(|_| usize::try_from(decdn_bao_range::CHUNK_GROUP_BYTES).unwrap_or(1))
2186 }
2187
2188 /// The target size of a stream's first frame: [`Self::frame_target`] at the
2189 /// start of an interval, capped at [`FIRST_FRAME_BYTES`]. A serve writes no
2190 /// byte until its first frame is full, so the cap is what the first byte
2191 /// waits for: a few chunk groups of store read on a hit, or of upstream pull
2192 /// on a miss, rather than a whole interval. Every later frame keeps the
2193 /// full target, so the frames still land on the interval boundary.
2194 pub(super) fn first_frame_target(&self, interval_bytes: u64, opening_window: u64) -> usize {
2195 let cap = usize::try_from(FIRST_FRAME_BYTES).unwrap_or(usize::MAX);
2196 self.frame_target(0, interval_bytes, opening_window)
2197 .min(cap)
2198 }
2199
2200 /// The effective downstream credit window in bytes for a stream whose voucher
2201 /// interval is `interval_bytes` and whose ramp input is `paid` (ADR 003
2202 /// §Credit window): the stream's own confirmed payment plus the credit it
2203 /// carries from its lane ([`RampCarry::ramp_paid`](ramp::RampCarry::ramp_paid)).
2204 /// The window ramps from its floor toward `credit_max` as `paid` grows:
2205 /// `paid / credit_ramp_divisor` once that clears the floor, the floor itself
2206 /// below that point (including at `paid == 0`), and the full `credit_max`
2207 /// when `credit_ramp_divisor` is `0`. The floor is one interval plus
2208 /// [`decdn_client::VERIFY_LAG_BYTES`]: the payer pays only for bytes it has
2209 /// verified, so it must receive the leaf that straddles an interval boundary
2210 /// before it can pay for that interval, and the loop always makes progress.
2211 pub(super) fn credit_window(&self, interval_bytes: u64, paid: u64) -> u64 {
2212 decdn_incentive::ramped_credit_window(
2213 self.credit_ramp_divisor,
2214 interval_bytes.saturating_add(decdn_client::VERIFY_LAG_BYTES),
2215 self.credit_max,
2216 paid,
2217 )
2218 }
2219
2220 /// The most ramp credit a lane banks: the paid bytes that open one window at
2221 /// `credit_max`. `0` when the ramp is disabled, because the window then starts
2222 /// at the ceiling.
2223 const fn ramp_credit_cap(&self) -> u64 {
2224 self.credit_max.saturating_mul(self.credit_ramp_divisor)
2225 }
2226
2227 /// Take `lane`'s banked ramp credit for one new stream (ADR 003 §Credit
2228 /// window). A stream with no tracked lane carries nothing.
2229 async fn take_ramp_carry(
2230 &self,
2231 lane: Option<&Arc<Mutex<LaneDeliveryState>>>,
2232 ) -> ramp::RampCarry {
2233 let Some(lane) = lane else {
2234 return ramp::RampCarry::detached();
2235 };
2236 let pool = Arc::clone(&lane.lock().await.ramp_pool);
2237 ramp::RampCarry::take(pool, self.ramp_credit_cap())
2238 }
2239
2240 /// The refundable floor-`M` serving guard (shared-payment-pool model): the
2241 /// seller keeps serving a pool only while its on-chain **remaining**
2242 /// (`getPool.deposit − getPool.totalRedeemed`) minus the configured
2243 /// minimum-remaining-deposit floor `M` can still cover the reserved credit
2244 /// window, i.e. `remaining − M ≥ min_payment(reserved_bytes, rate)`. `M` is
2245 /// the refundable minimum the pool owner is guaranteed to keep, so the node
2246 /// refuses to serve into it. Pure and total (saturating), so it is testable
2247 /// without any chain access.
2248 ///
2249 /// `remaining` is a chain quantity read from `getPool`; the handler does not
2250 /// hold an RPC client, so **E4 threads `remaining` to every call site** (from
2251 /// the redemption/pool watcher's cached `getPool` view, cached like
2252 /// `withdrawn_cache`). This method owns only the policy arithmetic.
2253 pub(super) fn pool_remaining_covers_window(
2254 &self,
2255 remaining: U256,
2256 reserved_bytes: u64,
2257 rate_per_mb: u64,
2258 ) -> bool {
2259 let refundable_headroom = remaining.saturating_sub(self.pool_min_remaining_deposit);
2260 refundable_headroom >= min_payment(reserved_bytes, rate_per_mb)
2261 }
2262
2263 /// Open a span-capped [`FloorReservation`] against `pool_id`'s budget for one
2264 /// stream. The serve loop holds the returned guard for the stream's lifetime and
2265 /// releases the reservation once a floor is repaid; on any other drop the guard
2266 /// releases the live reservation, freeing the pool's floor headroom.
2267 // Test-only: the serve path admits through `try_reserve_floor`, which checks
2268 // every floor gate and reserves under one lock hold.
2269 #[cfg(test)]
2270 pub(super) fn reserve_floor(
2271 &self,
2272 pool_id: B256,
2273 signer: Address,
2274 reserved: U256,
2275 ) -> FloorReservation {
2276 FloorReservation::reserve(Arc::clone(&self.pool_floor), pool_id, signer, reserved)
2277 }
2278
2279 /// One credit window in `µUSDC` priced at `rate_per_mb`: the ramp-start credit
2280 /// window (one chunk normally, the full `credit_max` when
2281 /// `credit_ramp_divisor == 0`), which is exactly what a fresh stream reserves. It
2282 /// is the unit the per-signer live cap is denominated in.
2283 pub(super) fn one_window(&self, rate_per_mb: u64) -> U256 {
2284 min_payment(self.credit_window(CHUNK_BYTES, 0), rate_per_mb)
2285 }
2286
2287 /// The per-signer LIVE concurrency cap on un-vouchered floor reservation (ADR 003
2288 /// §Pool solvency, per-signer floor isolation): the most `live_reservation` any
2289 /// ONE capability signer may hold against this pool, `k · one_window`.
2290 ///
2291 /// **The window is the unit being rationed.** A signer's honest need for
2292 /// un-vouchered floor credit does not scale with the pool's size: every admission
2293 /// reserves at most one window, and a paying stream releases its reservation as
2294 /// soon as it covers it, so honest need is `concurrent un-vouchered streams × one
2295 /// window` whether the pool holds ten dollars or a hundred thousand. An ABSOLUTE
2296 /// `k`-window cap therefore fits the need directly, rather than a fraction of the
2297 /// deposit that would be loose in both directions on a large pool.
2298 ///
2299 /// `k` is lower-clamped to one window, so a lone signer's first stream is always
2300 /// admissible on any solvent pool. This carries no permanent memory: it is pure
2301 /// concurrency, released as each stream pays, so it never penalizes a signer for
2302 /// quitting.
2303 ///
2304 /// Pure and total (saturating), so it is testable without any chain access.
2305 pub(super) fn signer_floor_cap(&self, rate_per_mb: u64) -> U256 {
2306 let one_window = self.one_window(rate_per_mb);
2307 // Lower-clamp `k` to one window: `k = 0` (or an unset field) still admits a
2308 // lone signer's first stream rather than wedging it.
2309 let k = self.pool_floor_signer_live_windows.max(1);
2310 one_window.saturating_mul(U256::from(k))
2311 }
2312
2313 /// POOL solvency: does the pool's `remaining − M` cover its already-committed LIVE
2314 /// floor reservation across every signer, plus `new_reserve`? Reads the floor
2315 /// accumulator; pure arithmetic otherwise. A poisoned
2316 /// accumulator lock recovers the guard rather than panicking: every critical
2317 /// section over this mutex is panic-free by construction — saturating `U256`
2318 /// arithmetic and infallible map operations only, no indexing and no `unwrap` — so
2319 /// a poison cannot originate from a holder of this lock, and a recovered guard
2320 /// cannot observe a torn pool/signer split. Do NOT add a fallible or panicking
2321 /// call under this lock; that argument is what the recovery rests on, because the
2322 /// pool total and the signer entries must agree.
2323 ///
2324 /// (The recovery is not justified by the accumulator being "best-effort": it
2325 /// gates whether the node fronts upstream USDC.)
2326 ///
2327 /// Deliberately the pool level ONLY, which is what makes it the right check for a
2328 /// stream already in flight. The per-signer gates are ADMISSION controls; an
2329 /// admitted stream's reservation is already counted in the pool total, so leaving
2330 /// them out of the mid-stream re-check bounds nothing less.
2331 /// [`Self::try_reserve_floor`] is where every gate applies.
2332 pub(super) fn pool_budget_covers_reserve(
2333 &self,
2334 pool_id: B256,
2335 remaining: U256,
2336 new_reserve: U256,
2337 ) -> bool {
2338 let committed = {
2339 let guard = self
2340 .pool_floor
2341 .lock()
2342 .unwrap_or_else(std::sync::PoisonError::into_inner);
2343 guard
2344 .get(&pool_id)
2345 .map_or(U256::ZERO, PoolFloorState::live_committed)
2346 };
2347 decdn_incentive::pool_budget_covers(
2348 remaining,
2349 self.pool_min_remaining_deposit,
2350 committed,
2351 new_reserve,
2352 )
2353 }
2354
2355 /// Atomically check both floor gates and reserve one `floor` against the pool's
2356 /// budget, returning the [`FloorReservation`] guard on success or the gate that
2357 /// refused it.
2358 ///
2359 /// Two gates apply, both under ONE `pool_floor` lock hold so two concurrent
2360 /// admissions cannot both pass a check and then both reserve (the TOCTOU
2361 /// over-commit a separate check-then-reserve would allow):
2362 /// 1. **Pool solvency** — `remaining − M` must cover the pool's committed LIVE
2363 /// reservation plus this floor. The hard money envelope.
2364 /// 2. **Per-signer live cap** — this signer's live reservation plus this floor must
2365 /// stay within `k · one_window`.
2366 ///
2367 /// Only on success is the row inserted and the reservation charged, so probing a
2368 /// full pool with fresh signer keys cannot grow the accumulator. The guard is built
2369 /// from the already-charged state ([`FloorReservation::new_charged`]) so the
2370 /// reserved amount is charged exactly once. A poisoned lock recovers the guard
2371 /// rather than panicking (best-effort accounting, never a safety gate).
2372 pub(super) fn try_reserve_floor(
2373 &self,
2374 pool_id: B256,
2375 signer: Address,
2376 remaining: U256,
2377 rate_per_mb: u64,
2378 reserved: U256,
2379 ) -> Result<FloorReservation, FloorRefusal> {
2380 let signer_cap = self.signer_floor_cap(rate_per_mb);
2381 let epoch;
2382 {
2383 let mut guard = self
2384 .pool_floor
2385 .lock()
2386 .unwrap_or_else(std::sync::PoisonError::into_inner);
2387 // Read the pool's live total and this signer's live slice, without creating
2388 // a row: a refused admission leaves no new pool or signer row behind, so a
2389 // client probing a full pool with fresh signer keys cannot grow the map.
2390 let (pool_live, signer_live) = match guard.get(&pool_id) {
2391 Some(entry) => {
2392 let signer_live = entry
2393 .signers
2394 .get(&signer)
2395 .map_or(U256::ZERO, |lane| lane.live_reservation);
2396 (entry.live_reservation, signer_live)
2397 }
2398 None => (U256::ZERO, U256::ZERO),
2399 };
2400 // Pool ceiling first: it is the solvency bound, and it is what an
2401 // operator reads as "this pool cannot pay".
2402 if !decdn_incentive::pool_budget_covers(
2403 remaining,
2404 self.pool_min_remaining_deposit,
2405 pool_live,
2406 reserved,
2407 ) {
2408 return Err(FloorRefusal::PoolExhausted);
2409 }
2410 // Per-signer live concurrency cap: one signer cannot hold more than `k`
2411 // windows of live un-vouchered reservation at once.
2412 if signer_live.saturating_add(reserved) > signer_cap {
2413 return Err(FloorRefusal::SignerAtCap { signer_cap });
2414 }
2415 let entry = guard.entry(pool_id).or_default();
2416 entry.live_reservation = entry.live_reservation.saturating_add(reserved);
2417 let lane = entry.signers.entry(signer).or_default();
2418 lane.live_reservation = lane.live_reservation.saturating_add(reserved);
2419 epoch = entry.epoch;
2420 }
2421 Ok(FloorReservation::new_charged(
2422 Arc::clone(&self.pool_floor),
2423 pool_id,
2424 signer,
2425 reserved,
2426 epoch,
2427 ))
2428 }
2429
2430 /// Drop a reclaimed pool's floor accounting: remove its in-memory
2431 /// `PoolFloorState`, which carries every signer's live slice with it. Called once
2432 /// when a pool is reclaimed on-chain; a reclaimed `pool_id` never recurs (monotonic
2433 /// open nonce), so the entry is permanently moot.
2434 pub(crate) fn forget_pool_floor(&self, pool_id: B256) {
2435 let mut guard = self
2436 .pool_floor
2437 .lock()
2438 .unwrap_or_else(std::sync::PoisonError::into_inner);
2439 guard.remove(&pool_id);
2440 }
2441
2442 /// Has a takedown landed on this stream since it opened (ADR 011 §On
2443 /// Blacklist Event: "In-flight streams for a blacklisted hash are terminated
2444 /// at the next MB boundary")?
2445 ///
2446 /// The open-time gates in `dispatch.rs` are not enough on their own: a
2447 /// multi-GB blob can still be streaming minutes after a one-hour removal
2448 /// order took effect, and serving past the compliance window is slashable
2449 /// (ADR 026 §Slashing and burn). Both halves are re-checked because both can
2450 /// land mid-stream — a hash via the local denylist reload, the governance
2451 /// blacklist, or an eviction; a funder via either origin list.
2452 ///
2453 /// Cheap enough for a per-MB call: three atomic loads and a hash-set probe
2454 /// each, against a boundary that already takes a channel lock and a network
2455 /// round trip to collect a voucher.
2456 pub(super) fn takedown_landed(&self, hash: Hash, funder: Option<Address>) -> bool {
2457 self.cache.refuses(hash)
2458 || funder.is_some_and(|addr| self.content_deny.is_origin_denied(&addr))
2459 }
2460
2461 /// Cut off an in-flight delivery whose hash or funder was taken down
2462 /// mid-stream, by resetting both directions.
2463 ///
2464 /// A reset, not a `StreamError` frame: ADR 005's stream-error domain split
2465 /// makes `VoucherRejected` the only code that travels mid-stream, and the
2466 /// client's cue here is the absence of the `StreamEnd` sentinel — the same
2467 /// convention every other mid-stream fault uses. The QUIC code is
2468 /// [`APP_ERR_NO_ERROR`] because this is a compliance action, not a protocol
2469 /// fault by either party, and ADR 013 §Application Error Codes reserves
2470 /// `0x03` for peers that misbehaved; coding it as a fault would have the
2471 /// client penalise this node's reputation for discharging a takedown. A
2472 /// client that re-requests the hash gets the signed `Declined`
2473 /// refusal from the open-time gate, which is where the reason belongs. The
2474 /// caller returns `Stopped { Takedown }`, which the dispatch sink counts on
2475 /// `decdn_serve_stream_terminated_takedown_total`.
2476 pub(super) fn terminate_for_takedown(send: &mut SendStream, recv: &mut RecvStream, hash: Hash) {
2477 tracing::warn!(
2478 %hash,
2479 "terminating an in-flight delivery: a takedown landed after the stream opened"
2480 );
2481 reset_stream(send, recv, APP_ERR_NO_ERROR);
2482 }
2483
2484 /// Apply one lane-lifecycle `delta` to the live-lane count and publish the
2485 /// resulting `decdn_lanes_open` gauge.
2486 ///
2487 /// The count comes from the atomic RMW's own result rather than a re-read,
2488 /// and [`Self::lane_gauge_publish`] orders the publish, so the gauge always
2489 /// reflects the most recent lifecycle event. The pool deposit behind these
2490 /// lanes is a pool-level on-chain quantity no lane carries, so it is
2491 /// published by the redeemer tick instead (#2072).
2492 fn tune_lane_gauge(&self, delta: isize) {
2493 let _publish = self
2494 .lane_gauge_publish
2495 .lock()
2496 .unwrap_or_else(std::sync::PoisonError::into_inner);
2497 let open = if delta >= 0 {
2498 self.lane_count
2499 .fetch_add(1, Ordering::Relaxed)
2500 .saturating_add(1)
2501 } else {
2502 self.lane_count
2503 .fetch_sub(1, Ordering::Relaxed)
2504 .saturating_sub(1)
2505 };
2506 self.metrics.set_lanes_open(open);
2507 }
2508}
2509
2510/// Terminal disposition of one voucher collected by
2511/// [`ClientHandler::commit_one_proof`].
2512enum VoucherStop {
2513 /// Verified and advanced in memory; keep serving. `credited_bytes` is the
2514 /// watermark-capped wire bytes to advance the serve loop's `paid` by (rule
2515 /// #1) — at most the amount the lane watermark advanced, so a benign
2516 /// already-satisfied voucher contributes zero.
2517 Continue { credited_bytes: u64 },
2518 /// Rejected — the reject frame was written and the stream finishes cleanly;
2519 /// the loop returns `Ok(())`.
2520 Rejected,
2521}
2522
2523/// Router-facing `ProtocolHandler` for `cdn/client/v1`.
2524///
2525/// Wraps the shared `Arc<ClientHandler>` so the serve loop can hand every
2526/// per-stream task a `'static` clone of the handler and `tokio::spawn` it
2527/// (#1788). The iroh [`ProtocolHandler::accept`] signature borrows `&self`, so
2528/// the handler itself cannot spawn tasks that outlive the borrow; owning the
2529/// `Arc` here and cloning it into `ClientHandler::serve` bridges that gap. The
2530/// wrapper is cheap to clone (one `Arc` bump) and the router holds one for the
2531/// process lifetime.
2532#[derive(Clone, Debug)]
2533pub struct ClientProtocol(Arc<ClientHandler>);
2534
2535impl ClientProtocol {
2536 /// The `cdn/client/v1` ALPN this handler answers.
2537 pub const ALPN: &'static [u8] = ALPN_CLIENT;
2538
2539 /// Wrap a shared handler for registration on the iroh `Router`.
2540 #[must_use]
2541 pub const fn new(handler: Arc<ClientHandler>) -> Self {
2542 Self(handler)
2543 }
2544}
2545
2546impl ProtocolHandler for ClientProtocol {
2547 async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
2548 Arc::clone(&self.0)
2549 .serve(connection)
2550 .await
2551 .map_err(|e| AcceptError::from_boxed(e.into()))
2552 }
2553}
2554
2555/// Error from the request read path carrying the ADR 013 app error code to
2556/// propagate to the peer.
2557struct StreamReadError {
2558 err: anyhow::Error,
2559 app_code: u32,
2560}
2561
2562const fn frame_err_code(e: &FrameError) -> u32 {
2563 match e {
2564 FrameError::TooLarge(_) => 0x02,
2565 FrameError::Io(_) => APP_ERR_NO_ERROR,
2566 FrameError::Varint | FrameError::Decode(_) => APP_ERR_MALFORMED_MESSAGE,
2567 }
2568}
2569
2570fn reset_stream(send: &mut SendStream, recv: &mut RecvStream, code: u32) {
2571 let v = VarInt::from_u32(code);
2572 let _ = send.reset(v);
2573 let _ = recv.stop(v);
2574}
2575
2576/// After a clean completion (`StreamEnd` written, send half finished), read the
2577/// client's send half to its FIN — bounded by [`POST_END_DRAIN_TIMEOUT`] —
2578/// discarding whatever is left, then return so `recv` drops cleanly.
2579///
2580/// Dropping an un-finished [`RecvStream`] issues an implicit `STOP_SENDING(0)` to
2581/// the peer. `StreamEnd` is written only once every interval, the closing voucher
2582/// included, is credited, but the client finishes its send half after that. Without
2583/// this drain the node's drop can stop the client's send before its FIN — surfacing
2584/// to the client as an opaque write failure at the very end of an otherwise
2585/// complete, fully-paid fetch. Draining to FIN first lets both halves close cleanly.
2586/// The bound keeps a client that finishes delivery but never FINs from pinning the
2587/// serve task here.
2588pub(super) async fn drain_recv_to_fin(recv: &mut RecvStream) {
2589 // Read to FIN and discard. Every proof is already read, so only the FIN is
2590 // expected; the cap leaves room for a few stray frames. A client that keeps
2591 // sending past it makes
2592 // `read_to_end` error, which — like the timeout — just ends the drain and lets
2593 // `recv` drop. Buffering onto the heap keeps this future small (no large stack
2594 // scratch array to inflate the serve future — `clippy::large_futures`).
2595 let _ = tokio::time::timeout(POST_END_DRAIN_TIMEOUT, recv.read_to_end(64 * 1024)).await;
2596}
2597
2598/// The first message on a fresh `cdn/client/v1` stream: a paid delivery
2599/// request. It opens a bidirectional stream and leads with one
2600/// [`ClientMessage::StreamRequest`].
2601enum FirstMessage {
2602 /// A paid delivery: [`StreamRequest`] plus its [`StreamRequestExt`].
2603 Delivery(StreamRequest, StreamRequestExt),
2604}
2605
2606/// Read the first framed [`ClientMessage`] on a stream with a timeout. A
2607/// [`ClientMessage::StreamRequest`] yields [`FirstMessage::Delivery`] (with its
2608/// [`StreamRequestExt`] parsed from the trailing bytes — the ADR 005 two-phase
2609/// pattern; an absent extension yields `StreamRequestExt::default()`). Any other
2610/// variant is a protocol fault.
2611async fn read_first_message(recv: &mut RecvStream) -> Result<FirstMessage, StreamReadError> {
2612 let frame = match tokio::time::timeout(REQUEST_READ_TIMEOUT, read_frame(recv)).await {
2613 Err(_) => {
2614 return Err(StreamReadError {
2615 err: anyhow::anyhow!("stream request timed out after {REQUEST_READ_TIMEOUT:?}"),
2616 app_code: APP_ERR_NO_ERROR,
2617 });
2618 }
2619 Ok(Err(e)) => {
2620 let app_code = frame_err_code(&e);
2621 return Err(StreamReadError {
2622 err: anyhow::anyhow!("stream request frame read failed: {e}"),
2623 app_code,
2624 });
2625 }
2626 Ok(Ok(frame)) => frame,
2627 };
2628 match decode_message::<ClientMessage>(&frame) {
2629 Ok((ClientMessage::StreamRequest(req), remainder)) => {
2630 let ext = decdn_protocol::parse_stream_request_ext(remainder).map_err(|e| {
2631 StreamReadError {
2632 err: anyhow::anyhow!("stream request ext decode failed: {e}"),
2633 app_code: APP_ERR_MALFORMED_MESSAGE,
2634 }
2635 })?;
2636 // Value checks are kept out of the parse so forward-compatible
2637 // trailing bytes don't couple to them (ADR 005 two-phase). Gate here
2638 // rather than at each use: a malformed client binding or capability
2639 // signature is a protocol error, and this wire boundary is its only
2640 // enforcement point.
2641 ext.validate().map_err(|e| StreamReadError {
2642 err: anyhow::anyhow!("stream request ext rejected: {e}"),
2643 app_code: APP_ERR_MALFORMED_MESSAGE,
2644 })?;
2645 Ok(FirstMessage::Delivery(req, ext))
2646 }
2647 Ok((_, _)) => Err(StreamReadError {
2648 err: anyhow::anyhow!("expected StreamRequest"),
2649 app_code: APP_ERR_UNSUPPORTED_MESSAGE,
2650 }),
2651 Err(e) => {
2652 // ADR 013: an unknown enum discriminant closes with
2653 // UNSUPPORTED_MESSAGE (0x01), not MALFORMED_MESSAGE (0x03). A
2654 // genuine parse fault (in-range discriminant, bad payload) stays
2655 // MALFORMED.
2656 let app_code = if is_unknown_variant::<ClientMessage>(&frame) {
2657 APP_ERR_UNSUPPORTED_MESSAGE
2658 } else {
2659 APP_ERR_MALFORMED_MESSAGE
2660 };
2661 Err(StreamReadError {
2662 err: anyhow::anyhow!("stream request decode failed: {e}"),
2663 app_code,
2664 })
2665 }
2666 }
2667}
2668
2669/// How many proofs the recoup phase will read for ONE outstanding chunk before
2670/// it gives up.
2671///
2672/// The budget counts every proof that leaves the chunk unsettled. That includes
2673/// a proof that credits nothing and a proof that pays only part of the chunk.
2674/// A proof pays part of a chunk when the lane headroom it finds is short — for
2675/// example, a sealed voucher that moves the watermark by less than the chunk. The
2676/// remainder stays owed, and the next proof answers it.
2677///
2678/// A whole chunk is paid by exactly one reveal, but the payer may legitimately
2679/// send metering vouchers ahead of it — the epoch's root voucher when this
2680/// stream has not carried it yet, and a rollover voucher when the chain is
2681/// spent. A metering voucher never credits a whole chunk, even when its
2682/// rollover advances the lane's watermark, so the outstanding chunk stays
2683/// outstanding until its reveal lands (see `voucher_credit_delta`). Three
2684/// messages is the real worst case for a well-behaved payer: two zero-credit
2685/// vouchers (re-anchor, roll) and then the reveal. The rest of the budget of 8
2686/// is slack for a duplicate or out-of-order reveal, which is ordinary once
2687/// several streams share one lane and which credits nothing when the lane holds
2688/// no headroom for it.
2689///
2690/// The bound matters because without it a payer could hold a stream open
2691/// indefinitely with a run of zero-credit vouchers, each one refreshing the
2692/// no-progress timeout while the delivered-but-unpaid balance never moves — the
2693/// same shape of stall the non-empty-`ChunkData` floor closes on the delivery
2694/// side. This budget bounds the number of proofs for one owed chunk, not the
2695/// time. [`proof_wait::ChunkProofs`] counts them, and it also holds the chunk's
2696/// time bound: every proof wait for the chunk ends at
2697/// [`proof_wait::PROOF_WAIT_CEILING`] after the chunk's first wait. So one owed
2698/// chunk holds the stream for at most that long, however many proofs it takes.
2699/// A stream that owes several chunks waits up to that long for each of them.
2700const MAX_PROOFS_PER_CHUNK: u32 = 8;
2701
2702/// One payment proof off the wire: a signed voucher, or a released hash-chain
2703/// preimage (ADR 003 §Two payment resolutions).
2704///
2705/// The two resolve different things and cost differently, which is why the
2706/// protocol keeps both rather than making either do the other's work. A
2707/// **voucher** settles any residual exactly, down to one token base unit, and
2708/// costs one signature. A **preimage** settles whole chunks past the anchor and
2709/// costs one keccak — no signature, no acknowledgement, nothing to persist
2710/// before the node sends the next chunk.
2711#[derive(Debug, Clone, PartialEq, Eq)]
2712pub(super) enum Proof {
2713 Voucher(decdn_protocol::client::Voucher),
2714 Preimage(decdn_protocol::client::ChunkPreimage),
2715}
2716
2717/// Cancellation-safe, buffered reader for `cdn/client/v1` payment-proof frames. Owns
2718/// a byte buffer that PERSISTS across [`Self::read`] calls, so a `read` future
2719/// dropped mid-frame loses no bytes: any partial frame stays buffered for the
2720/// next call.
2721///
2722/// [`read_frame`] is built on `read_exact` and is NOT cancellation-safe — a
2723/// `timeout` firing mid-frame would drop already-consumed bytes and desync the
2724/// stream. Reading instead via the cancel-safe [`tokio::io::AsyncReadExt::read`]
2725/// into an owned buffer, then splitting whole frames off it with
2726/// [`decdn_protocol::framing::parse_frame`], keeps every byte. Borrows the
2727/// `RecvStream` per call so the caller retains it for stream teardown.
2728///
2729/// All proof reads on a given stream MUST go through ONE instance: it may read
2730/// ahead (buffering the next pipelined proof, #1486) while the current one is
2731/// verified and recorded, and a second reader on the same `RecvStream` would
2732/// lose those buffered bytes.
2733#[derive(Default)]
2734pub(super) struct BufferedProofReader {
2735 /// Unconsumed bytes read from the stream, at a frame boundary or partway
2736 /// into the next frame's header/body.
2737 buf: Vec<u8>,
2738}
2739
2740impl BufferedProofReader {
2741 /// Read one framed payment proof — a [`ClientMessage::Voucher`] or a
2742 /// [`ClientMessage::ChunkPreimage`] — filling the buffer incrementally.
2743 /// **Cancellation-safe:** if the returned future is dropped (the proof wait
2744 /// faulted, or the stream is torn down), bytes already read stay in
2745 /// `self.buf` for the next call — no frame is torn.
2746 ///
2747 /// These two variants are the entire payer→node vocabulary after the
2748 /// opening `StreamRequest`, so this is the one place every proof passes
2749 /// through. Anything else on this stream is a protocol error.
2750 ///
2751 /// # Errors
2752 ///
2753 /// Everything the peer controls — a reset stream, a hang-up mid-frame, a
2754 /// malformed frame, an undecodable or unexpected message — carries
2755 /// [`PeerFault`](wire::PeerFault), so the dispatch sink logs it at `debug!`.
2756 /// The two bounds checks do not: those are this node's own invariants.
2757 pub(super) async fn read(&mut self, recv: &mut RecvStream) -> anyhow::Result<Proof> {
2758 loop {
2759 if let Some(result) = self.take_buffered()? {
2760 return result;
2761 }
2762 // Need more bytes. Use tokio's `AsyncReadExt::read` (explicitly, since
2763 // iroh's inherent Quinn `read` shadows it) — it is documented
2764 // cancel-safe: a dropped future consumes nothing, and on `Ready(n)` we
2765 // append to `self.buf` before the next await, so no bytes are ever lost
2766 // when the caller drops the read. `0` is EOF.
2767 let mut scratch = [0u8; 4096];
2768 let n = AsyncReadExt::read(recv, &mut scratch).await.map_err(|e| {
2769 anyhow::Error::new(wire::PeerFault)
2770 .context(format!("proof stream read failed: {e}"))
2771 })?;
2772 if n == 0 {
2773 // The dominant abandon shape: the client finished its send side
2774 // and walked away between intervals.
2775 return Err(
2776 anyhow::Error::new(wire::PeerFault).context("proof stream closed mid-frame")
2777 );
2778 }
2779 let chunk = scratch
2780 .get(..n)
2781 .ok_or_else(|| anyhow::anyhow!("short read length out of range"))?;
2782 self.buf.extend_from_slice(chunk);
2783 }
2784 }
2785
2786 /// Take the next whole frame out of the buffer, if one is there.
2787 ///
2788 /// `Ok(None)` means the buffer holds less than a frame and the caller must
2789 /// read more. The inner `Result` is the frame's own outcome: its bytes are
2790 /// consumed either way, so one bad frame cannot wedge the buffer.
2791 ///
2792 /// A malformed length prefix, an undecodable body, and a message that is
2793 /// neither a `Voucher` nor a `ChunkPreimage` are all the peer's doing and
2794 /// carry [`PeerFault`](wire::PeerFault). The bounds check between them is this
2795 /// node's own invariant and carries no marker.
2796 fn take_buffered(&mut self) -> anyhow::Result<Option<anyhow::Result<Proof>>> {
2797 let Some((header_len, payload_len)) = decdn_protocol::framing::parse_frame(&self.buf)
2798 .map_err(|e| {
2799 anyhow::Error::new(wire::PeerFault)
2800 .context(format!("proof frame parse failed: {e}"))
2801 })?
2802 else {
2803 return Ok(None);
2804 };
2805 let total = header_len.saturating_add(payload_len);
2806 let payload = self
2807 .buf
2808 .get(header_len..total)
2809 .ok_or_else(|| anyhow::anyhow!("proof frame bounds out of range"))?;
2810 let decoded = decode_message::<ClientMessage>(payload);
2811 let result =
2812 match decoded {
2813 Ok((ClientMessage::Voucher(v), _)) => Ok(Proof::Voucher(v)),
2814 Ok((ClientMessage::ChunkPreimage(p), _)) => Ok(Proof::Preimage(p)),
2815 Ok((_, _)) => Err(anyhow::Error::new(wire::PeerFault)
2816 .context("expected ClientMessage::Voucher or ClientMessage::ChunkPreimage")),
2817 Err(e) => Err(anyhow::Error::new(wire::PeerFault)
2818 .context(format!("proof decode failed: {e}"))),
2819 };
2820 self.buf.drain(..total);
2821 Ok(Some(result))
2822 }
2823}
2824
2825/// A load-shed controller that never sheds, for handler-layer tests that are
2826/// not exercising the load-shed gate itself. Keeps every existing serve test
2827/// admitting exactly as it did before the gate was wired in.
2828#[cfg(test)]
2829fn always_admit_shed() -> Arc<crate::load_shed::LoadShedController> {
2830 crate::load_shed::LoadShedController::from_config(&decdn_common::config::ResolvedLoadShed {
2831 policy: decdn_common::config::LoadShedPolicyKind::AlwaysAdmit,
2832 ..Default::default()
2833 })
2834}
2835
2836/// Build a `ClientHandler` over an arbitrary [`PoolStateStore`] for the
2837/// sibling-module tests (e.g. `voucher.rs`'s #527 durability tests need a
2838/// fault-injecting store). Kept at module level (not inside `mod tests`) so a
2839/// child module's `#[cfg(test)]` can reach it as `super::handler_over_store`.
2840#[cfg(test)]
2841#[allow(clippy::expect_used)]
2842pub(super) async fn handler_over_store(
2843 metrics: &Arc<Metrics>,
2844 store: Arc<dyn PoolStateStore>,
2845) -> (Arc<ClientHandler>, tempfile::TempDir) {
2846 let dir = tempfile::tempdir().expect("tempdir");
2847 let cache = CacheEngine::open(dir.path(), Vec::new(), 16)
2848 .await
2849 .expect("cache");
2850 let domain = alloy::sol_types::eip712_domain! { name: "t", version: "1", };
2851 let deps = ClientHandlerDeps::new(
2852 iroh::SecretKey::generate().public(),
2853 Arc::clone(metrics),
2854 Arc::new(ConnectionLimiter::new(
2855 &decdn_common::config::ResolvedSecurity {
2856 max_concurrent_handlers: u32::MAX,
2857 per_source_rate_per_sec: 1e9,
2858 per_source_burst: u32::MAX,
2859 max_tracked_sources: 16,
2860 },
2861 Arc::clone(metrics),
2862 )),
2863 cache,
2864 Arc::new(alloy::signers::local::PrivateKeySigner::random()),
2865 domain.clone(),
2866 domain.clone(),
2867 domain,
2868 store,
2869 Arc::new(crate::receipt_log::DirectReceiptSink::new(Arc::new(
2870 crate::receipt_log::NoopReceiptLog,
2871 ))) as Arc<dyn ReceiptSink>,
2872 1,
2873 16,
2874 Arc::new(crate::content_deny::ContentDenylist::empty()),
2875 U256::ZERO,
2876 always_admit_shed(),
2877 );
2878 let handler = ClientHandler::new(deps).expect("handler");
2879 (Arc::new(handler), dir)
2880}
2881
2882#[cfg(test)]
2883#[allow(clippy::expect_used)]
2884mod tests;