decdn_client/lib.rs
1//! The deCDN client: fetch content-addressed blobs from deCDN nodes, pay for
2//! them per megabyte from a USDC payment pool, and verify every byte against
3//! its BLAKE3 hash as it arrives.
4//!
5//! The `decdn` CLI's `fetch` and `bundle pull` are built on this crate. So is
6//! the node's own cache-miss pull: a node that misses in its cache is a paying
7//! client of the node upstream of it.
8//!
9//! # Choose an entry point
10//!
11//! | You want to | Use |
12//! |---|---|
13//! | Save one blob, or a set of blobs, to files as fast as possible, and resume a partial download | [`Downloader`] |
14//! | Read one blob in order, and pay only for what your reader reaches | [`Streamer`] |
15//!
16//! The [`Downloader`] stripes a blob
17//! across every holder at once, and writes each verified range at its offset in
18//! a `.partial` file beside the destination. A rerun fetches only the ranges
19//! that file does not hold yet. The [`Streamer`] fetches at most one read-ahead
20//! window ([`PullConfig::read_ahead_bytes`]) ahead of its reader, so a reader
21//! that stops early stops the spend within that window.
22//!
23//! # Before you fetch
24//!
25//! A fetch needs a funded payment pool, an iroh endpoint, and one paid lane per
26//! node that holds the blob. The `download` and `stream` examples in this
27//! crate's `examples/` directory run the whole sequence:
28//!
29//! 1. **Load the buyer key.** Vouchers and pool transactions are signed with a
30//! [`PrivateKeySigner`].
31//! 2. **Reuse or open a pool.** Read the pool the buyer store already records
32//! ([`decdn_incentive::BuyerPoolStore::get_by_owner`]). If there is none,
33//! approve the deposit ([`buyer_pool::ensure_allowance`]), open a pool
34//! ([`buyer_pool::open_pool`]), and record the returned state at once: the
35//! deposit is escrowed from the moment `open_pool` returns.
36//! 3. **Bring up an endpoint** with [`endpoint::client_endpoint`].
37//! 4. **Find the holders.** Read the registered nodes
38//! ([`discovery::bootstrap_nodes`]), pick a sample
39//! ([`discovery::select_candidates`]), and probe each one
40//! ([`probe::probe_once`]). Verify every answer
41//! ([`probe::verify_probe_response`]) before it counts, and drop an answer
42//! whose `has_blob` and coverage disagree. A probe answer's size
43//! ([`discovery::Probed::total_bytes`]) is an unsigned hint: pass it as the
44//! fetch's first size claim, which the fetch grows or shrinks as verified
45//! bytes land.
46//! 5. **Build one lane per holder.** Pin a [`PoolContext`] to the holder's
47//! provider address with [`buyer_pool::self_owned_lane_ctx`], resuming at
48//! what the lane has already paid, and bind it to your endpoint
49//! ([`PoolContext::bind_endpoint`]). A node that misses the blob in its
50//! cache serves an unbound lane nothing. Create its ledger with
51//! [`PoolContext::new_ledger`], and wrap both in a [`PeerSource`] inside a
52//! [`StreamCandidate::new`]. Cap the lane's rate at the rate the node signed
53//! in its probe answer ([`effective_rate_ceiling`]).
54//! 6. **Fetch** with [`Downloader::fetch_to_paths`] or [`Streamer::open`], over
55//! a [`StaticSources`] of the lanes. Every lane streams at once unless
56//! [`Downloader::max_lanes`] caps it.
57//! 7. **Record what each lane paid**, whatever the outcome: build a
58//! [`VoucherProgress`] from the ledger's settlement, and apply the
59//! [`buyer_pool::ProgressWrite`] it calls for to the buyer store.
60//!
61//! # What the crate guarantees
62//!
63//! - **Every byte is verified.** Delivery is BLAKE3/bao verified streaming.
64//! The decoder checks each chunk group against the hash as it lands, so a
65//! corrupt range aborts the pull at that group. Nothing unverified reaches a
66//! [`VerifiedReader`] or a finished file.
67//! - **You pay only for verified bytes.** You pay per chunk
68//! ([`decdn_protocol::CHUNK_BYTES`]) once the decoder has verified it. A
69//! lying node is paid for the bytes before its first bad group and for
70//! nothing after, never for the size it claimed.
71//! - **Quotes are checked against your ceiling.** A stream response whose
72//! signed rate exceeds the `max_rate_per_mb` given to [`PeerSource::new`] is
73//! refused before any payment ([`RateAboveCeiling`]).
74//! - **Recovery is free.** Every lane draws on one shared pool. A source that
75//! faults cools and returns, and its range goes to another holder meanwhile.
76//! A fetch ends on done, a fatal fault, or the stop policy
77//! ([`StopPolicy`]). No delivered byte is fetched or paid for twice.
78//!
79//! # What stays yours
80//!
81//! - **Persistence.** The faces never write the buyer store. Record every
82//! lane's payment after each fetch (step 7). A lane you do not record resumes
83//! from a stale watermark next time, and its provider rejects the vouchers.
84//! - **Funding policy.** A source whose next voucher the deposit cannot cover,
85//! or that refuses the pool as `Unfunded`, waits for the deposit to rise.
86//! Once no other source serves and a fresh discovery finds nothing new, the
87//! fetch runs one funding recovery step through your [`Funder`]
88//! ([`Funder::recover`]): it may top the pool up, open a new pool
89//! ([`PoolReplaced`]), or report that it has no way to add funds. A further
90//! step needs a newly verified byte since the last one. When no step is
91//! allowed or possible, the fetch fails with a [`NoAffordableSource`],
92//! "funding needed".
93//! - **Delegated credentials.** A delegate that pays under someone else's
94//! pool hands the face a [`CredentialSlot`] and watches its
95//! [`FundingEvent`]s. Swap a new key and capability into the slot when the
96//! capability runs low: the fetch retires the old lanes at a voucher
97//! boundary and goes on under the new key. Without a swap, a delegated
98//! fetch ends with a typed [`FundingNeeded`].
99//! - **Which holders to use.** Discovery gives candidates; ordering and
100//! admission ([`discovery::admit_sources`]) are the caller's choice.
101//!
102//! # Mistakes to avoid
103//!
104//! - **One candidate per provider.** Each [`StreamCandidate`] pays one
105//! `(signer, provider)` lane. Two candidates for one provider sign competing
106//! vouchers on the same lane.
107//! - **Always set a rate ceiling.** A `max_rate_per_mb` of `0` means no
108//! ceiling, so a node can quote low on the probe and high on the stream.
109//! Pass [`effective_rate_ceiling`] of the probed rate and any absolute cap you
110//! hold.
111//! - **Never resume a lane below its on-chain watermark.** A voucher at or below
112//! the watermark redeems nothing, so the provider streams bytes it can never
113//! cash. A lane the buyer store has not seen resumes from the chain's
114//! `getWatermark`.
115//! - **Keep the stream drive running.** [`Streamer::open`] returns a reader and
116//! a [`StreamDrive`]. Run your read inside [`StreamDrive::alongside`]: the
117//! drive pays and drains open legs, and it must not wait behind a blocked
118//! consumer.
119//! - **Progress is in content bytes.** A [`ProgressCallback`] on the faces
120//! reports verified content bytes against the blob's size.
121//!
122//! # How delivery works
123//!
124//! Each lane sends a [`StreamRequest`], verifies the signed [`StreamResponse`],
125//! and then reads `ChunkData` and pays as it goes. It releases one hash-chain
126//! preimage per delivered `CHUNK_BYTES` chunk, and signs a voucher to open a
127//! chain, to roll one, and to settle a residual shorter than a chunk.
128//!
129//! The `ChunkData` payload is bao's interleaved verified-stream encoding, not
130//! raw bytes. Every consumer feeds it to a `bao-tree` verifying decoder as it
131//! arrives. The decoder checks every chunk group against the requested root. A
132//! range fetched from any offset therefore verifies on its own, with no
133//! dependency on earlier bytes (ADR 038).
134//!
135//! # Stability
136//!
137//! The documented items are the SDK, and the public-API snapshot in
138//! `tests/public_api.rs` guards them. Their enums and the error types a fetch
139//! returns are `#[non_exhaustive]`, so a new variant or field is not a
140//! breaking change. Items hidden from these docs are the pull engine's seams
141//! that the node and the `decdn` CLI drive directly. They carry no stability
142//! promise.
143
144/// Buyer-side `PaymentPool` open kernel (#940), shared by the node service
145/// and the CLI.
146pub mod buyer_pool;
147/// Zero-config tunables for the consumption faces over the paid pull engine
148/// ([`config::PullConfig`], #1848): every knob defaults, so a caller overrides
149/// only what it must, and construction needs no network or chain access.
150pub mod config;
151/// A caller-owned QUIC connection kept warm across many hash fetches
152/// ([`connection::WarmConnection`]): one dial amortized over every hash, one
153/// bi-stream per hash (no wire change), closed once on the handle's own `Drop`;
154/// and the command-wide map of one such connection per node
155/// ([`connection::Connections`]).
156pub mod connection;
157/// Range-keyed discovery coverage-map primitive plus the two
158/// objective-specific planners over it (#1506): [`coverage_plan::plan_covered_runs`]
159/// (node — concentrate + sticky) and `coverage_plan::spread_segments` (client —
160/// spread for parallelism). Pure, no I/O.
161pub(crate) mod coverage_plan;
162/// A delegated fetch's voucher credential, which the application swaps when
163/// the capability runs low, and the funding signal that says when.
164pub mod credential;
165/// Client-side node discovery (#936): read + select the active node set from
166/// `CapacityBond.getRegisteredNodes`, then rank probed blob-holders.
167pub mod discovery;
168/// The `Downloader` consumption face (#1848): fetch a set of content-addressed
169/// blobs (a bundle, or a single blob) to files in a directory, out-of-order and
170/// at full throughput, so every byte is bao-verified and a resumed fetch
171/// re-pulls only the missing ranges.
172pub mod downloader;
173/// The #1608 gap-driven fetch driver: [`driver::drive`] fills only the
174/// [`missing_ranges`](decdn_bao_range::RangedStore::missing_ranges) of a request,
175/// paying the minimum, by folding the resume / reseed loop behind the
176/// [`pacer::Pacer`] axis.
177pub mod driver;
178/// One-shot client `Endpoint` construction: relay + discovery resolution for
179/// the `cdn/client/v1` and `cdn/probe/v1` dial paths (#935/#936).
180pub mod endpoint;
181/// What a failed lane, lane build, or discovery means for the acquire loop
182/// (ADR 039 § Failure handling: reassign-only tail): a pure classifier over
183/// `anyhow::Error`.
184#[doc(hidden)]
185pub mod fault;
186mod first_open;
187/// Command-wide health of each provider (ADR 039 § Failure handling:
188/// reassign-only tail): cooling backoff on a delivery fault, parking on an
189/// unaffordable price.
190#[doc(hidden)]
191pub mod health;
192mod ledger;
193/// Run-scoped registry of live per-lane voucher ledgers:
194/// [`ledgers::LaneLedgers`] maps each `(pool_id, signer, provider)` lane to
195/// the one [`ledgers::LaneHandle`] every concurrent fetch on that lane shares.
196#[doc(hidden)]
197pub mod ledgers;
198/// The pure pacing axis (#1608): [`pacer::Pacer`] / [`pacer::BudgetPacer`] decide
199/// draw / wait / done / refuse for the gap-driven driver, with no I/O.
200#[doc(hidden)]
201pub mod pacer;
202/// Persisted per-peer knowledge base: registry-fed identity plus interaction-fed
203/// latency and price, keyed by iroh [`iroh::PublicKey`], one JSON file per peer.
204#[doc(hidden)]
205pub mod peer_store;
206/// Reusable `cdn/probe/v1` client.
207pub mod probe;
208/// Sub-frame byte-progress observation (#1797): a `ProgressReader` that tallies bytes
209/// off the QUIC stream beneath the message decode, and the `ThroughputFloor` that judges
210/// those bytes against a minimum rate over a trailing window.
211mod progress;
212/// Wallet-filled HTTP provider builder for opening/settling payment channels.
213pub mod provider;
214/// Client-side [`decdn_bao_range::RangedStore`] backend (#1621): a
215/// `.partial` + sidecar store built on `bao-tree`/`decdn-bao-range` only.
216#[doc(hidden)]
217pub mod ranged_store;
218/// The `APP_ERR_RATE_LIMITED` (`0x10`) transport shed, typed for the pull
219/// orchestrator (ADR 013 §Application Error Codes).
220pub(crate) mod rate_limited;
221/// The funding recovery step at the exhausted candidate set (ADR 003 § Funding
222/// recovery): the per-fetch [`recovery::RecoveryGate`] and its progress rule.
223pub mod recovery;
224mod scheduler;
225/// Pure segmentation and tail-steal helpers for the multi-source scheduler
226/// (ADR 039 § Dynamic segmentation and tail-stealing): no I/O, no async.
227mod segment;
228/// When a command gives up (ADR 039 § Failure handling: reassign-only tail):
229/// the progress clock and the stop policy that watches it.
230pub mod stop;
231/// The `Streamer` consumption face (#1848): stream one blob's verified,
232/// contiguous front to a consumer as it arrives, paced by consumption and bounded
233/// to one read-ahead window ahead of the read cursor. A fetch-like single-blob
234/// face over the same engine, with no chunk dedup.
235pub mod streamer;
236// Docs live in `sink.rs` as `//!`. Deliberately NOT documented here as well:
237// rustdoc resolves intra-doc links on a `mod` item in THIS file's scope, so the
238// module's own links (`content_paid_frontier`, …) would go unresolved
239// and fail the doc gate (`CARGO_BUILD_WARNINGS=deny`).
240pub mod sink;
241/// The sourcing and funding seams of a fetch (#1608): [`source::BlobSource`]
242/// (raw-bao byte source for a range) and [`source::Funder`] (the injected
243/// funding recovery seam), plus scripted test doubles.
244pub mod source;
245/// The sources of one blob (ADR 039 § Source set and selection):
246/// [`source_set::SourceSet`] tracks
247/// each holder's health, lane, and retry timing behind the injected
248/// [`source_set::SourceProvider`] seam.
249pub mod source_set;
250
251// The SDK surface: the two faces, the setup sequence the crate guide walks
252// through, and every type their signatures name.
253pub use config::PullConfig;
254pub use connection::Connections;
255pub use credential::{
256 CREDENTIAL_SWAP_WAIT, CapabilityCause, Credential, CredentialMismatch, CredentialSlot,
257 FundingEvent, FundingNeeded,
258};
259pub use downloader::{DownloadTarget, Downloader};
260pub use driver::PoolExhausted;
261pub use ledger::{Cumulative, PoolLedger};
262pub use recovery::RecoveryGate;
263pub use sink::{BlobCache, NoCache, SinkFuture};
264pub use source::{Funder, PeerSource, PoolReplaced, Recovery, SourceFuture};
265pub use source_set::{Holder, NoAffordableSource, NoNodeWillServe, NoSourceHasBlob, StaticSources};
266pub use stop::{ClockHold, GaveUp, ProgressClock, StopPolicy};
267pub use streamer::{LiveReader, StreamCandidate, StreamDrive, Streamer, VerifiedReader};
268
269// The engine seams the node and the `decdn` CLI drive directly. They are
270// public so those crates can reach them, hidden from the docs and the
271// public-API snapshot, and carry no stability promise.
272#[doc(hidden)]
273pub use connection::WarmConnection;
274#[doc(hidden)]
275pub use coverage_plan::{CoveredRun, SourceCoverage, plan_covered_runs};
276#[doc(hidden)]
277pub use credential::QuoteMax;
278#[doc(hidden)]
279pub use decdn_bao_range::RangedStore;
280#[doc(hidden)]
281pub use driver::{LegNoProgress, PacingWait, SharedPool, WaitReason, drive, first_leg};
282#[doc(hidden)]
283pub use fault::{FatalScope, Fault, HealExhausted, LaneBuildFault, classify};
284#[doc(hidden)]
285pub use first_open::first_open;
286#[doc(hidden)]
287pub use health::{Health, PeerHealth};
288#[doc(hidden)]
289pub use ledger::{ChainCommit, EpochAction, Metered, Rebase, Released};
290#[doc(hidden)]
291pub use ledgers::{LaneHandle, LaneLedgers};
292#[doc(hidden)]
293pub use pacer::{
294 BudgetPacer, DownstreamFrontier, PULL_WINDOW_FLOOR, PaceDecision, PaceState, Pacer, RampPacer,
295 VERIFY_LAG_BYTES, WindowPacer,
296};
297#[doc(hidden)]
298pub use peer_store::{PeerRecord, PeerStore, StoreConfig};
299#[doc(hidden)]
300pub use ranged_store::ClientRangedStore;
301#[doc(hidden)]
302pub use rate_limited::UpstreamRateLimited;
303#[doc(hidden)]
304pub use recovery::{RECOVERY_SETTLE, Stepped};
305#[doc(hidden)]
306pub use scheduler::{
307 AcquireEnv, AcquireTarget, ConsumptionPacing, GROWTH_RETRY, GrowFor, LANE_WATCHDOG, LaneLease,
308 LaneWiden, acquire,
309};
310#[doc(hidden)]
311pub use source::{
312 BaoRangeReader, BlobSource, IngestEnd, IngestFuture, IngestStore, PrimedSource, SourceStream,
313};
314#[doc(hidden)]
315pub use source_set::{LaneRange, SourceProvider, SourceSet};
316
317pub(crate) use ledger::StreamProof;
318
319#[cfg(any(test, feature = "test-util"))]
320pub use source::{FakeFunder, ScriptedReader, ScriptedSource};
321
322use std::sync::Arc;
323use std::time::Duration;
324
325use alloy::dyn_abi::Eip712Domain;
326use alloy::primitives::{Address, B256, Signature, U256};
327use alloy::signers::SignerSync;
328use alloy::signers::local::PrivateKeySigner;
329#[cfg(any(test, feature = "test-util"))]
330use bao_tree::BaoTree;
331#[cfg(any(test, feature = "test-util"))]
332use bao_tree::io::BaoContentItem;
333#[cfg(any(test, feature = "test-util"))]
334use bao_tree::io::fsm::{ResponseDecoder, ResponseDecoderNext};
335use bytes::Bytes;
336#[cfg(any(test, feature = "test-util"))]
337use decdn_bao_range::align_range;
338use decdn_bao_range::align_range_clamped;
339#[cfg(any(test, feature = "test-util"))]
340use decdn_bao_range::{AlignedRange, IROH_BLOCK_SIZE};
341use decdn_incentive::{
342 BuyerPoolState, EPHEMERAL_BINDING_NONCE, SignedCapability, SignedVoucher, StreamSlashData,
343 Voucher, binding_signing_hash, signed_to_wire_voucher,
344};
345use decdn_protocol::client::{
346 ClientBinding, ClientMessage, StreamError, StreamRequest, StreamRequestExt, StreamResponse,
347 StreamResponseExt, VoucherRejectReason, WatermarkBundle, WireCapability,
348};
349use decdn_protocol::{
350 CHUNK_BYTES, ChunkPreimage, decode_message, encode_message, read_frame, write_frame,
351};
352use iroh::endpoint::{RecvStream, SendStream};
353use iroh::{Endpoint, EndpointAddr};
354#[cfg(any(test, feature = "test-util"))]
355use sink::{PullReader, StashedFault};
356
357/// Per-lane context the requester needs to sign pool vouchers.
358///
359/// A voucher is scoped to a `(pool_id, signer, provider)` lane. `pool_id` is the
360/// on-chain deposit, the signer is `client_signer.address()` (the capability key
361/// the pool owner delegated spend to — the owner's own key in the single-user
362/// case), and `provider` is the delivering node this stream pays.
363///
364/// The `prior_*` fields capture the lane's cumulative state from earlier streams
365/// so a **reused** lane resumes correctly: vouchers are cumulative across the
366/// lane's lifetime, so the node's `last_bytes_delivered` / `last_amount` are
367/// non-zero after the first stream. Starting a fresh stream from zero would be
368/// rejected (`AmountRegression` / `BytesRegression`). For a brand-new lane pass
369/// `U256::ZERO` for both.
370///
371/// Build one with [`Self::new`] or [`Self::for_pool`], then pin it with
372/// [`Self::with_provider`] and bind it with [`Self::bind_endpoint`].
373#[derive(Clone)]
374#[cfg_attr(not(feature = "test-util"), non_exhaustive)]
375pub struct PoolContext {
376 /// On-chain `poolId` the vouchers draw from.
377 pub pool_id: B256,
378 /// The delivering node this stream pays — the voucher's `provider` field. A
379 /// capability voucher is scoped to one provider and is invalid if redeemed
380 /// against another.
381 pub provider: Address,
382 /// On-chain pool deposit (informational here; the node enforces it).
383 pub deposit: U256,
384 /// Client key that signs vouchers — its address is the lane's capability
385 /// `signer`.
386 pub client_signer: Arc<PrivateKeySigner>,
387 /// `PaymentPool` EIP-712 domain.
388 pub voucher_domain: Eip712Domain,
389 /// Cumulative bytes paid for on this lane before this stream.
390 pub prior_bytes_delivered: U256,
391 /// Cumulative amount paid on this lane before this stream.
392 pub prior_amount: U256,
393 /// Optional ADR 005 client identity binding (address + `BindNodeId`
394 /// signature over the requester's own iroh `NodeId`, see
395 /// [`sign_client_binding`]). Attached to every `cdn/client/v1` request's
396 /// `ext` so the serving node can recover the buyer address, confirm it owns
397 /// the pool (`pull_authorized`), and reactively populate from its configured
398 /// origin (#1115). `None` ⇒ no binding is sent (an unconfigured
399 /// `capacity_bond` on the client, or an on-chain/registered requester).
400 pub client_binding: Option<ClientBinding>,
401 /// Optional pool owner capability delegating spend to `client_signer`
402 /// (ADR 003 §Capability delegation, `issue_self_capability` /
403 /// `buyer_pool::open_pool`). Attached to every `cdn/client/v1` request's
404 /// `ext` alongside `client_binding` so the serving node can register the
405 /// signer on that signer's first on-chain redemption. Node-agnostic — the
406 /// same `SignedCapability` is valid at every node this context streams
407 /// from, since it grants spend against the pool rather than a specific
408 /// provider. `None` ⇒ no capability is sent (the signer is already
409 /// registered on-chain, or this stream reuses a lane a prior stream this
410 /// session already delivered the capability for).
411 pub capability: Option<SignedCapability>,
412}
413
414impl PoolContext {
415 /// A context that signs vouchers on `pool_id` with `client_signer` under
416 /// `voucher_domain`, against a deposit of `deposit`. It pays no provider
417 /// yet, starts its lane at zero, and carries no binding or capability:
418 /// [`Self::with_provider`], [`Self::bind_endpoint`] and
419 /// [`Self::with_capability`] add each. A delegated signer, which holds no
420 /// pool row of its own, starts here.
421 #[must_use]
422 pub const fn new(
423 pool_id: B256,
424 deposit: U256,
425 client_signer: Arc<PrivateKeySigner>,
426 voucher_domain: Eip712Domain,
427 ) -> Self {
428 Self {
429 pool_id,
430 // A pool fans out to many providers; the fetch target is pinned
431 // per-pull via [`Self::with_provider`], not stored in the pool.
432 provider: Address::ZERO,
433 deposit,
434 client_signer,
435 voucher_domain,
436 prior_bytes_delivered: U256::ZERO,
437 prior_amount: U256::ZERO,
438 client_binding: None,
439 capability: None,
440 }
441 }
442
443 /// [`Self::new`] for a pool the buyer store records: its `pool_id` and
444 /// committed deposit.
445 #[must_use]
446 pub const fn for_pool(
447 state: &BuyerPoolState,
448 client_signer: Arc<PrivateKeySigner>,
449 voucher_domain: Eip712Domain,
450 ) -> Self {
451 Self::new(state.pool_id, state.deposit, client_signer, voucher_domain)
452 }
453
454 /// Pin the delivering node this context pays (the voucher's `provider`) and
455 /// seed the lane's prior cumulative totals. A pool serves many providers, so
456 /// the target is chosen per-fetch rather than at pool open.
457 #[must_use]
458 pub const fn with_provider(
459 mut self,
460 provider: Address,
461 prior_bytes_delivered: U256,
462 prior_amount: U256,
463 ) -> Self {
464 self.provider = provider;
465 self.prior_bytes_delivered = prior_bytes_delivered;
466 self.prior_amount = prior_amount;
467 self
468 }
469
470 /// This context's `(pool_id, signer, provider)` lane — the scope of one
471 /// hash chain, and the key every seed is derived under.
472 #[must_use]
473 pub fn lane(&self) -> decdn_incentive::LaneKey {
474 decdn_incentive::LaneKey {
475 pool_id: self.pool_id,
476 signer: self.client_signer.address(),
477 provider: self.provider,
478 }
479 }
480
481 /// The ledger for this lane, seeded from its persisted cumulative and
482 /// nothing else.
483 ///
484 /// A chain is never resumed across a restart — the resume folds the
485 /// frontier the node proved into a signed amount and opens a fresh chain
486 /// (ADR 003 §Resumption folds) — so there is no chain secret to reproduce
487 /// here, no counter to read back, and nothing that could have been
488 /// persisted wrong. What carries across an invocation is the money owed.
489 #[must_use]
490 pub fn new_ledger(&self) -> PoolLedger {
491 PoolLedger::new(Cumulative {
492 bytes: self.prior_bytes_delivered,
493 amount: self.prior_amount,
494 })
495 }
496
497 /// Attach an ADR 005 client identity binding so this context's
498 /// `cdn/client/v1` requests prove pool ownership to the serving node,
499 /// enabling reactive cache-miss origin pull-through (#1115).
500 /// [`Self::bind_endpoint`] signs and attaches one in a single call. A
501 /// hand-built `ClientBinding` whose
502 /// `ethereum_address` and signature don't correspond (or that doesn't own the
503 /// pool) is rejected by the serving node, so this only ever hurts the caller
504 /// itself.
505 #[must_use]
506 pub fn with_client_binding(mut self, binding: ClientBinding) -> Self {
507 self.client_binding = Some(binding);
508 self
509 }
510
511 /// Bind `endpoint` to this context's signer, so a node that misses the blob
512 /// in its cache may pull it from its origin for this buyer. Without a
513 /// binding, such a node refuses the open as `NotFound`. `bind_domain` is
514 /// `decdn_incentive::bind_node_id_domain(chain_id, capacity_bond)`.
515 ///
516 /// # Errors
517 ///
518 /// A signing error from `client_signer`.
519 pub fn bind_endpoint(
520 self,
521 endpoint: &Endpoint,
522 bind_domain: &Eip712Domain,
523 ) -> anyhow::Result<Self> {
524 let own_node_id = B256::from(*endpoint.id().as_bytes());
525 let binding = sign_client_binding(&self.client_signer, own_node_id, bind_domain)?;
526 Ok(self.with_client_binding(binding))
527 }
528
529 /// Attach a pool owner capability so this context's `cdn/client/v1`
530 /// requests carry it to the serving node at session start, letting the
531 /// node register `client_signer` on that signer's first on-chain
532 /// redemption (ADR 003 §Capability delegation). Pass the
533 /// `SignedCapability` produced by `buyer_pool::open_pool` /
534 /// `issue_self_capability`; a capability whose `signer` doesn't match
535 /// `client_signer` or that fails owner-signature recovery only ever hurts
536 /// the caller itself, exactly like [`Self::with_client_binding`].
537 #[must_use]
538 pub const fn with_capability(mut self, capability: SignedCapability) -> Self {
539 self.capability = Some(capability);
540 self
541 }
542}
543
544/// Sign an ADR 005 ephemeral client identity binding: an EIP-712
545/// `BindNodeId(nodeId, nonce = 0)` attestation over the requester's OWN iroh
546/// `NodeId`, signed with the buyer key. The serving node recovers the signer via
547/// `ecrecover` (`verify_binding`) and checks it owns the named channel before
548/// honoring a cache-miss origin pull (`pull_authorized`, ADR 003 §Off-Chain
549/// Ephemeral Binding). Used by the CLI client fetch (#1115) and by node-to-node
550/// pulls, where `node_origin` binds its upstream requests so an upstream can
551/// chain a further reactive origin pull (#1117).
552///
553/// `own_node_id` MUST be the requester's own endpoint `NodeId` — what the peer
554/// authenticates as `conn.remote_id()` — NOT the target node's. `bind_domain` is
555/// `decdn_incentive::bind_node_id_domain(chain_id, capacity_bond)`.
556///
557/// # Errors
558///
559/// Propagates a signing error from the buyer signer.
560#[doc(hidden)]
561pub fn sign_client_binding(
562 signer: &PrivateKeySigner,
563 own_node_id: B256,
564 bind_domain: &Eip712Domain,
565) -> anyhow::Result<ClientBinding> {
566 let hash = binding_signing_hash(own_node_id, EPHEMERAL_BINDING_NONCE, bind_domain);
567 let binding_signature = signer
568 .sign_hash_sync(&hash)
569 .map_err(|e| anyhow::anyhow!("sign client binding: {e}").context(LocalPullFault))?
570 .as_bytes()
571 .to_vec();
572 Ok(ClientBinding {
573 ethereum_address: signer.address().into(),
574 binding_signature,
575 })
576}
577
578/// Build the trailing [`StreamRequestExt`] carrying the context's client
579/// identity binding and/or owner capability, or `None` when the context
580/// carries neither — in which case `encode_stream_request` appends no ext
581/// bytes. Applied by `open_stream`, the single request site, so the capability
582/// rides the same session-start request as the binding.
583fn client_binding_ext(ctx: &PoolContext) -> Option<StreamRequestExt> {
584 if ctx.client_binding.is_none() && ctx.capability.is_none() {
585 return None;
586 }
587 Some(StreamRequestExt {
588 binding: ctx.client_binding.clone(),
589 capability: ctx.capability.as_ref().map(|signed| WireCapability {
590 spending_cap: signed.capability.spending_cap,
591 expiry: signed.capability.expiry,
592 owner_signature: signed.signature.as_bytes().to_vec(),
593 }),
594 })
595}
596
597impl std::fmt::Debug for PoolContext {
598 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
599 f.debug_struct("PoolContext")
600 .field("pool_id", &self.pool_id)
601 .field("provider", &self.provider)
602 .field("deposit", &self.deposit)
603 .finish_non_exhaustive()
604 }
605}
606
607/// The lane's committed voucher watermark, returned by [`UpstreamPull::finish`] /
608/// [`UpstreamPull::abort`] / [`UpstreamPull::progress`] and threaded through the
609/// `test-util` `stream_fetch_tracked` wrapper as an out-param, so the caller can
610/// persist what it paid (#852).
611///
612/// A pull runs through a [`PoolLedger`] seeded from the lane's prior cumulative
613/// state; this watermark is that ledger read back
614/// ([`VoucherProgress::from_ledger`]) — so it
615/// always holds the **absolute** cumulative totals of the last presumed-accepted
616/// voucher (not per-stream deltas), exactly the `(bytes_delivered, amount)` pair
617/// the buyer-pool lane record expects. Because the
618/// copy-back runs on every return path (including the `Err`/timeout arms), the
619/// latest totals survive a mid-stream failure or a paid-but-corrupt delivery.
620///
621/// **Implicit acceptance.** The watermark tracks vouchers the upstream is
622/// presumed to have accepted — continued delivery is acceptance (ADR 005), so a
623/// sent voucher advances it and only an explicit `VoucherRejected` rewinds it.
624///
625/// Read the persistable totals via [`VoucherProgress::advanced`], which yields
626/// `None` when nothing was paid on this stream (so there is nothing to persist).
627#[derive(Clone, Copy, Debug, Default)]
628pub struct VoucherProgress {
629 /// Cumulative lane bytes paid for as of the last committed voucher.
630 bytes_delivered: U256,
631 /// Cumulative lane amount paid as of the last committed voucher.
632 amount: U256,
633 /// Whether the watermark moved past the lane's seed on this stream.
634 advanced: bool,
635 /// The node's watermark the lane's ledger rebased to, behind it on
636 /// `amount` ([`PoolLedger::rebase`]), and no persist has recorded yet. The caller
637 /// overwrites the lane record with it once, then advances to the totals
638 /// above.
639 rebase_anchor: Option<Cumulative>,
640}
641
642impl VoucherProgress {
643 /// Build the watermark from a ledger [`Cumulative`] plus the lane's seed
644 /// amount. `advanced` is set when the cumulative `amount` rose past the seed,
645 /// preserving the "something was paid iff the watermark advanced" contract
646 /// even when the ledger was shared across concurrent streams.
647 ///
648 /// Public because a caller that owns its ledger persists from it directly
649 /// rather than through the `&mut VoucherProgress` out-param — including from a
650 /// `Drop`, where nothing can be awaited and [`PoolLedger::committed`] is the
651 /// only readable source (#1145 review).
652 #[must_use]
653 pub fn from_cumulative(cum: Cumulative, prior_amount: U256) -> Self {
654 Self {
655 bytes_delivered: cum.bytes,
656 amount: cum.amount,
657 advanced: cum.amount > prior_amount,
658 rebase_anchor: None,
659 }
660 }
661
662 /// Attach the watermark the ledger rebased DOWN to and has not persisted
663 /// yet ([`PoolLedger::take_unsaved_rebase`]).
664 #[must_use]
665 pub const fn with_rebase_anchor(mut self, anchor: Option<Cumulative>) -> Self {
666 self.rebase_anchor = anchor;
667 self
668 }
669
670 /// The watermark to overwrite the lane record with before advancing, when
671 /// the ledger rebased down to the node's and no persist has recorded it
672 /// yet. A monotone advance refuses a lower watermark, so without the
673 /// overwrite the next run would sign from the anchor the node refused.
674 #[must_use]
675 pub const fn rebase_anchor(&self) -> Option<Cumulative> {
676 self.rebase_anchor
677 }
678
679 /// The same watermark, read from a live ledger.
680 ///
681 /// Prefer this wherever a ledger is in hand: it reads
682 /// [`PoolLedger::settlement`] rather than a copied-back cumulative, so a
683 /// voucher left armed by an ambiguous send is still reported as owed.
684 ///
685 /// It also TAKES the ledger's unsaved rebase anchor, so call it only where
686 /// the result is persisted: the anchor is handed out once.
687 #[must_use]
688 pub fn from_ledger(ledger: &PoolLedger, prior_amount: U256) -> Self {
689 // Take the anchor first: every settlement read after it is at or above it.
690 let anchor = ledger.take_unsaved_rebase();
691 Self::from_cumulative(ledger.settlement(), prior_amount).with_rebase_anchor(anchor)
692 }
693
694 /// The cumulative `(bytes_delivered, amount)` to persist via the lane record,
695 /// or `None` if nothing new was paid on this stream (nothing to record).
696 #[must_use]
697 pub fn advanced(&self) -> Option<(U256, U256)> {
698 self.advanced.then_some((self.bytes_delivered, self.amount))
699 }
700
701 /// The cumulative `(bytes_delivered, amount)` this watermark carries,
702 /// whether or not it advanced past the seed.
703 #[must_use]
704 pub const fn totals(&self) -> (U256, U256) {
705 (self.bytes_delivered, self.amount)
706 }
707}
708
709/// The upstream delivered bytes that failed bao verification against the
710/// requested content root — a paid-but-corrupt delivery (the content-addressing
711/// invariant, ADR 014/038). Under ADR 038 the verifier is the per-chunk-group
712/// `bao-tree` decoder fed as bytes arrive, not a whole-blob re-hash, so this
713/// fires the moment any group's proof mismatches. Returned (via `anyhow`) by
714/// this crate's decoding consumers — [`ClientRangedStore::ingest_stream`] and the
715/// `test-util` in-memory decoder — so callers can `downcast_ref` to classify corruption
716/// (e.g. a reputation `Corruption` outcome) without matching on the error
717/// message string. The `Display` text is kept stable for logs and the existing
718/// requester tests. The node's cache admit reports the same fault as
719/// `CacheError::VerifyFailed` / `CacheError::HashMismatch`, which `decdn-node`'s
720/// `is_bao_corruption` folds together with this type.
721#[derive(Debug)]
722pub struct HashMismatch;
723
724impl std::fmt::Display for HashMismatch {
725 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
726 f.write_str("received bytes do not match requested hash")
727 }
728}
729
730impl std::error::Error for HashMismatch {}
731
732/// Reject a `total_bytes == 0` claim for a non-empty root as the typed
733/// [`HashMismatch`] (#1054).
734///
735/// A 0-byte blob aligns to an empty range: the bao decoder has no chunk group to
736/// anchor and would accept the empty stream for ANY root, and a store sized from a
737/// `total_bytes == 0` claim has no gap to pull and is complete as created — so
738/// nothing downstream ever verifies the empty stream against the root. The only
739/// hash an empty blob can carry is the empty root; a source that claims `0` for any
740/// other hash is a paid-but-wrong delivery. This is the one explicit verify site
741/// the trivial-empty-range bypass has, shared by [`driver::drive`] (before the
742/// empty store is finalized) and the in-memory `fetch_in_memory_once` path so the
743/// two cannot drift. A `total_bytes > 0` claim is untouched: the streaming decoder
744/// verifies it group by group.
745pub(crate) fn reject_empty_claim_for_nonempty_root(
746 total_bytes: u64,
747 hash: [u8; 32],
748) -> anyhow::Result<()> {
749 if total_bytes == 0 && hash != *blake3::hash(&[]).as_bytes() {
750 return Err(anyhow::Error::new(HashMismatch));
751 }
752 Ok(())
753}
754
755/// Typed sentinel for a pull aborted because the bytes that ACTUALLY arrived
756/// crossed the buyer's `max_blob_size_bytes` ceiling (#1895). The peer's signed
757/// `total_bytes` claim never drives a refusal — it is peer-controlled and
758/// unverified (`StreamResponse::validate()` does not bound it) — so the ceiling
759/// binds on cumulative RECEIVED, BLAKE3-verified bytes instead. Returned (not a
760/// bare string) so the pull orchestrator can `downcast_ref` and classify it as a
761/// buyer-side policy rejection — distinct from a hash mismatch or an unreachable
762/// peer — rather than mis-attributing it to the provider's reputation. `Display`
763/// carries `BlobTooLarge` so logs and the requester tests can match on it.
764///
765/// **Units.** `reached` and `ceiling` are ALWAYS the same unit within one error —
766/// the comparison at each enforcement site is apples-to-apples — but that unit
767/// differs by site, so neither field is a raw `max_blob_size_bytes` config value
768/// across all call sites. The receive loop ([`UpstreamPull::next_chunk`]) meters bao
769/// WIRE bytes (content plus interleaved proof, ADR 038) and compares against the
770/// wire size of a ceiling-sized blob. The gap-driven driver (`fill_gap`) meters
771/// CONTENT bytes (the store's delivered frontier) and the resume-offset guard in
772/// [`open_progressive_pull`] meters a CONTENT offset, both against the configured
773/// `max_blob_size_bytes` directly. [`acquire`] meters CONTENT bytes against
774/// [`AcquireEnv::max_blob_bytes`]: it clamps a size claim to the cap and
775/// reports a blob whose bytes run past it once every byte below the cap is
776/// present and no leg proved a size within it. Every one is a faithful "the
777/// byte position crossed the ceiling" report.
778#[derive(Debug)]
779#[cfg_attr(not(feature = "test-util"), non_exhaustive)]
780pub struct BlobTooLarge {
781 /// The byte position that crossed `ceiling` — wire bytes taken off the stream
782 /// (receive loop), the store's content frontier (gap-driven driver), or a
783 /// content resume offset already past it. Same unit as `ceiling` (see the
784 /// type's **Units** note).
785 pub reached: u64,
786 /// The ceiling `reached` crossed, in the same unit as `reached`.
787 pub ceiling: u64,
788}
789
790/// The requested range starts at or past the end of the blob the node signed
791/// for, so no byte of it can be served (#1120). A node that signs a size of
792/// zero for a bounded open of `[0, len)` raises it too.
793///
794/// Typed rather than a bare string because it is the ONE signal that proves a
795/// caller's partial download does not belong to this blob — a stale `.partial`
796/// left under the same `--output` by a fetch of a different or larger blob, or
797/// one that completed but was killed before its rename. A resumable client keys
798/// its discard-and-refetch on this and nothing else: classifying by exclusion
799/// ("any error that is not a voucher rejection") would sweep in ordinary stalls
800/// and resets and destroy a perfectly good prefix the user has already paid for.
801///
802/// Note this is a statement about the *offset*, not about the peer: an honest
803/// node answers this way to a range cut from a wrong size. A node's reputation
804/// must not score it. The acquire loop classifies it as a
805/// [`Fault::Source`]: the source cools and its range
806/// moves to other sources.
807#[derive(Debug)]
808#[doc(hidden)]
809pub struct ResumeOffsetPastEnd {
810 /// Whole-blob size the node signed for.
811 pub total_bytes: u64,
812 /// The offset we asked to resume from.
813 pub byte_offset: u64,
814}
815
816impl std::fmt::Display for ResumeOffsetPastEnd {
817 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
818 write!(
819 f,
820 "cannot resume at byte {}: the blob is only {} bytes",
821 self.byte_offset, self.total_bytes
822 )
823 }
824}
825
826impl std::error::Error for ResumeOffsetPastEnd {}
827
828impl std::fmt::Display for BlobTooLarge {
829 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
830 write!(
831 f,
832 "reached byte {}, crossing the {}-byte size ceiling (BlobTooLarge)",
833 self.reached, self.ceiling
834 )
835 }
836}
837
838impl std::error::Error for BlobTooLarge {}
839
840/// Typed sentinel for a server that signed an open-stage `StreamResponse`
841/// (`ok == true`) quoting a per-MB `rate_per_mb` above the buyer's effective
842/// ceiling (#1375). The buyer aborts **before paying**, so the profitable
843/// "quote low on the probe, quote high on the stream, then deliver" bait-and-switch
844/// never gets paid.
845///
846/// The ceiling is the lower of two bounds the caller supplies: the probe rate the
847/// candidate was selected on (a stream quote may not exceed what the probe
848/// advertised) and an optional absolute buyer config (`max_rate_per_mb`). Because
849/// the probe-relative bound is always applied by the node pull path, a *completed*
850/// delivery is by construction priced at or below the probe rate — so it can never
851/// be rate-manipulation (`SlashJudge` requires `stream.ratePerMb > probe.ratePerMb`),
852/// and the challengeable case is exactly this abort.
853///
854/// Carries the operator's own signed `response` — already verified against
855/// `expected_signer` by `verify_response` before construction, so it still
856/// recovers to the delivering node and a caller could replay it (paired with the
857/// same node's `ProbeResponse`) to `SlashJudge` with no re-signing.
858///
859/// **Slashability caveat.** The evidence is only rate-*manipulation* evidence when
860/// the **probe-relative** bound was the one exceeded: `SlashJudge` requires
861/// `stream.ratePerMb > probe.ratePerMb`. The ceiling here is
862/// `min(probe_rate, config_absolute)` ([`effective_rate_ceiling`]), so when the
863/// buyer's absolute `config` bound is the binding one the quote may sit at or below
864/// the probe rate and be perfectly honest. A challenger must therefore still
865/// confirm `quote > probeRate` before submitting; this type does not assert it.
866///
867/// **Consumption.** The signed response is retained *on the error* so a caller can
868/// act on it, but the node's pull-failure handler does not itself submit a
869/// challenge — auto-slashing is deferred (the same footing as the daemon never
870/// auto-challenging an [`UpstreamRefused`], whose evidence is exercised only by the
871/// e2e harness today). `evidence()` is the only accessor; the field is private and
872/// the sole constructor derives it from the verified response, so the
873/// verified-signature invariant cannot be bypassed by a hand-built value.
874pub struct RateAboveCeiling {
875 quoted_rate_per_mb: u64,
876 ceiling_rate_per_mb: u64,
877 response: StreamResponse,
878}
879
880impl RateAboveCeiling {
881 /// Build the abort error for an open-stage quote above `ceiling`. Crate-private
882 /// and the SINGLE construction path (`open_progressive_pull` routes through
883 /// it), so `quoted_rate_per_mb` is always
884 /// *derived* from `response.body.rate_per_mb` and cannot desync from the
885 /// retained evidence — the same "derive, don't trust the caller to set it
886 /// consistently" discipline as [`UpstreamRefused::open`]. Callers MUST have run
887 /// `verify_response` on `response` first (the field docs' signature invariant).
888 fn over_ceiling(response: StreamResponse, ceiling_rate_per_mb: u64) -> anyhow::Error {
889 anyhow::Error::new(Self {
890 quoted_rate_per_mb: response.body.rate_per_mb,
891 ceiling_rate_per_mb,
892 response,
893 })
894 }
895
896 /// The per-MB rate the server quoted in the signed `StreamResponse`.
897 #[must_use]
898 pub const fn quoted_rate_per_mb(&self) -> u64 {
899 self.quoted_rate_per_mb
900 }
901
902 /// The effective ceiling the quote exceeded (min of probe rate and config).
903 #[must_use]
904 pub const fn ceiling_rate_per_mb(&self) -> u64 {
905 self.ceiling_rate_per_mb
906 }
907
908 /// The operator's own signed `StreamResponse`, verified against
909 /// `expected_signer` before this value was built. See the type docs for the
910 /// slashability caveat (only `quote > probeRate` is manipulation).
911 #[must_use]
912 pub const fn evidence(&self) -> &StreamResponse {
913 &self.response
914 }
915}
916
917impl std::fmt::Debug for RateAboveCeiling {
918 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
919 // Elide the signed body + 65-byte `slash_sig` to a length, as `UpstreamRefused`
920 // does, so a logged abort stays readable.
921 f.debug_struct("RateAboveCeiling")
922 .field("quoted_rate_per_mb", &self.quoted_rate_per_mb)
923 .field("ceiling_rate_per_mb", &self.ceiling_rate_per_mb)
924 .field("evidence_slash_sig_len", &self.response.slash_sig.len())
925 .finish()
926 }
927}
928
929impl std::fmt::Display for RateAboveCeiling {
930 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
931 write!(
932 f,
933 "server quoted {} per MB, exceeding the buyer ceiling {} per MB (RateAboveCeiling)",
934 self.quoted_rate_per_mb, self.ceiling_rate_per_mb
935 )
936 }
937}
938
939impl std::error::Error for RateAboveCeiling {}
940
941/// Combine the two buyer rate ceilings into one effective bound, treating `0` as
942/// "unbounded" on each input (the probe-relative bound and the absolute config
943/// bound are both optional). Returns `0` only when BOTH are unbounded.
944#[must_use]
945pub const fn effective_rate_ceiling(probe_relative: u64, config_absolute: u64) -> u64 {
946 match (probe_relative, config_absolute) {
947 (0, c) => c,
948 (p, 0) => p,
949 (p, c) if p < c => p,
950 (_, c) => c,
951 }
952}
953
954/// Typed sentinel for the buyer's own per-candidate pull deadline firing (#857).
955/// Returned (not a bare string) so the pull orchestrator can `downcast_ref` and
956/// recognize that the timeout is OUR local deadline — a possibly mis-sized
957/// configuration value — not evidence the provider is unreachable, and so must
958/// not tar the provider's local reputation score. `Display` keeps the
959/// stable `timed out` text for logs (and for the `!contains("timed out")`
960/// negative assertion in `node_to_node_pull_through`'s deadline test).
961///
962/// Since #1134 it is raised by OUR OWN wall clocks only, and the message is
963/// deliberately stage-NEUTRAL because there are THREE of them: the shared
964/// `open_stream` open bound (`PullDeadlines::open`), the optional overall
965/// `hard_cap` the `test-util` wrappers apply, and the stall clock elapsing before
966/// the FIRST byte (`cumulative == 0`) in the streaming loop — where it is
967/// measuring the server's time-to-first-byte, not mid-stream inactivity. `decdn-node`
968/// also wraps `open_progressive_pull` in its per-candidate budget as belt-and-braces,
969/// which raises the same sentinel.
970///
971/// The one thing it is NOT is a mid-stream STALL once bytes have flowed — that is
972/// [`PullStalled`], a fact about the PEER that scores its reputation, whereas this is a
973/// fact about our own possibly mis-sized budget (or an honest slow server's TTFB) and
974/// must not. Keeping the two apart is the whole point of the split, and the `cumulative
975/// == 0` carve-out is where the two loops draw the line (see [`PullStalled`]).
976///
977/// Callers that want to name the stage add a `.context(…)` layer; `downcast_ref`
978/// still recovers the sentinel through it — pinned by
979/// `buyer_side_sentinels_survive_anyhow_downcast` in `decdn-node`'s `node_origin.rs`,
980/// not in this crate.
981#[derive(Debug)]
982#[cfg_attr(not(feature = "test-util"), non_exhaustive)]
983pub struct PullTimeout {
984 /// How long the pull ran before the budget elapsed.
985 pub after: Duration,
986}
987
988impl std::fmt::Display for PullTimeout {
989 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
990 write!(f, "upstream pull timed out after {:?}", self.after)
991 }
992}
993
994impl std::error::Error for PullTimeout {}
995
996/// Typed sentinel for the upstream rejecting a voucher we presented mid-stream
997/// (#857) — e.g. a stale nonce (#852), deposit exhaustion, or a wrong-channel
998/// mismatch. This is OUR payment-side fault, not the provider's, so the pull
999/// orchestrator `downcast_ref`s it to skip the candidate WITHOUT recording a
1000/// reputation observation (mirroring the buyer channel-open-failure arm). Named
1001/// with the `Upstream` prefix to disambiguate from the protocol-level
1002/// `StreamError::VoucherRejected` reason enum, whose `reason` it carries verbatim
1003/// (the `Copy` `VoucherRejectReason`, not a lossy stringification) so a future
1004/// caller can branch on retry-vs-top-up-vs-abandon without re-parsing a message.
1005/// `Display` keeps the stable `voucher rejected` text for logs.
1006///
1007/// `bundle` mirrors the wire [`WatermarkBundle`] verbatim (issue #1481): `Some`
1008/// only for a watermark-gated reason ([`VoucherRejectReason::is_watermark_gated`]),
1009/// and only when the node verified the rejected voucher recovered to the pool
1010/// capability's pinned `signer` before attaching it. A caller that sees `Some`
1011/// can self-heal when the bundle advances its ledger — reseed to the bundle's
1012/// watermark ([`crate::ledger::Cumulative::from`]) and resume from
1013/// `bytes_delivered` — or, on `Underpaid`, rebase down to it, rather than
1014/// treating the rejection as terminal. `SpendingCapExhausted`
1015/// with `bundle: None` means there is no signer-verified watermark to resume
1016/// from (or, more commonly, that a wallet-less delegate simply has no local
1017/// means to add deposit) — the caller must surface that to the app rather
1018/// than loop. `CapabilityExpired` and `PoolExhausted` are never
1019/// watermark-gated (no bundle is ever attached) and are always terminal —
1020/// the fix is a fresh capability or an owner top-up, not a resync.
1021#[derive(Debug)]
1022#[cfg_attr(not(feature = "test-util"), non_exhaustive)]
1023pub struct UpstreamVoucherRejected {
1024 /// Why the seller refused the voucher.
1025 pub reason: VoucherRejectReason,
1026 /// The seller's signer-verified watermark, when it sent one, so the buyer
1027 /// can resync its cumulative state and retry. `None` means there is
1028 /// nothing to resync from and the caller must surface the failure.
1029 pub bundle: Option<WatermarkBundle>,
1030 /// The ledger generation the rejected voucher was signed under
1031 /// (`StreamProof::Voucher`), when the stream knows it. `None` for a
1032 /// rejected reveal, or a rejection read before this stream claimed
1033 /// anything; [`PoolLedger::rebase`] treats it as current.
1034 pub proof_generation: Option<u64>,
1035}
1036
1037impl std::fmt::Display for UpstreamVoucherRejected {
1038 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1039 // The `{:?}` rendering of the reason is load-bearing: the loopback tests
1040 // assert `.contains("CapExceeded")` / `.contains("Expired")`-style substrings
1041 // on this string. A custom `Display` for `VoucherRejectReason` would have to
1042 // reproduce the variant names verbatim, so keep the Debug rendering here.
1043 write!(f, "voucher rejected: {:?}", self.reason)
1044 }
1045}
1046
1047impl std::error::Error for UpstreamVoucherRejected {}
1048
1049/// Typed sentinel for an upstream *refusing* delivery up front — a
1050/// `StreamResponse` with `body.ok == false` (#1144). Carries the wire
1051/// [`StreamError`] verbatim (mirroring [`UpstreamVoucherRejected`]'s typed
1052/// `reason`, not a lossy stringification) so the pull orchestrator can
1053/// `downcast_ref` and give each refusal the verdict it deserves, rather than
1054/// folding every one into "unreachable".
1055///
1056/// That distinction is the whole point. A refusal is proof the peer is *reachable
1057/// and answering*, so no class is evidence that it is degraded (ADR 005 §Open-time
1058/// refusal classes):
1059///
1060/// - `NotFound` — "not now". It is NODE-scoped, not blob-scoped
1061/// (`ServeRejectReason::wire_error` collapses a miss, a load shed, and several
1062/// pool and signer states onto it), so it earns only a short suppression.
1063/// - `Declined` — "not this hash during this fetch", DURABLE for this (peer, hash):
1064/// asking again soon gets the same answer, so the consumer drops the peer for the
1065/// hash rather than burning a candidate slot on it.
1066/// - `Unfunded` — "not at the requester's current funding". It says nothing about
1067/// the peer; it counts toward the requester's funding recovery.
1068/// - `VoucherRejected` — a payment fault on this lane. Listed because it genuinely
1069/// arrives here: it is the code designated for MID-stream, and the mid-stream receive
1070/// arms wrap any `StreamError` into this sentinel (#1145 review).
1071///
1072/// `node_origin::classify_refusal` is the consumer and matches this exhaustively, so a new
1073/// wire code breaks that build rather than silently inheriting a verdict.
1074///
1075/// Only the wire class is recoverable here, never the finer server-side
1076/// `ServeRejectReason` — that collapse is intentional and must not be reversed.
1077/// `Display` keeps the stable `delivery refused: {code}` text that logs, the CLI's
1078/// cache-miss annotation, and the loopback tests match on.
1079/// The two refusal shapes are a private inner enum, not `pub` variants: enum
1080/// variant fields cannot be made private, so exposing them would let any caller
1081/// build an `Open { .. }` directly and bypass the crate-private `open`
1082/// constructor's error-from-response derivation — reintroducing exactly the
1083/// doc-comment-only invariant this type exists to remove (#1377). The newtype
1084/// keeps the only paths to a value the two constructors, so the "present response
1085/// ⇒ open-stage, verified" invariant is a property of the type rather than a
1086/// convention.
1087#[derive(Clone)]
1088pub struct UpstreamRefused {
1089 kind: Kind,
1090}
1091
1092#[derive(Clone)]
1093enum Kind {
1094 /// Open-stage refusal: the upstream signed a [`StreamResponse`] with
1095 /// `body.ok == false`, already verified against `expected_signer` by
1096 /// [`verify_response`] before the value is built, so `error` is derived
1097 /// *from* the response's trailing [`StreamResponseExt`] and `response`
1098 /// always recovers to `expected_signer`.
1099 Open {
1100 error: StreamError,
1101 // Boxed (issue #1481 review): `StreamError::VoucherRejected` grew an
1102 // optional `WatermarkBundle`, which pushed the unboxed variant past
1103 // clippy's `large_enum_variant` threshold relative to `MidStream`.
1104 response: Box<StreamResponse>,
1105 },
1106 /// Mid-stream refusal: a bare [`ClientMessage::StreamError`] frame that
1107 /// arrived after the open stage. It carries no signature (#1042), so there
1108 /// is never a signed `StreamResponse` to retain — `None` by construction.
1109 MidStream { error: StreamError },
1110}
1111
1112impl UpstreamRefused {
1113 /// Build the open-stage refusal for a `body.ok == false` response, deriving
1114 /// the wire `error` *from* the response's trailing [`StreamResponseExt`] so
1115 /// the two can never disagree (#1377 invariant 2). The one construction site
1116 /// [`open_progressive_pull`]'s open stage uses, so every caller classifies a
1117 /// refusal the same way.
1118 ///
1119 /// Returns `anyhow::Error` rather than `Self` because the `None`-error arm is
1120 /// a protocol violation, not a refusal: callers MUST have run
1121 /// [`verify_response`] first, whose `StreamResponseExt::validate` rejects
1122 /// `ok == false` with no error code (`MissingStreamError`). Reaching the
1123 /// `None` arm means that invariant was bypassed, so it is surfaced as the
1124 /// violation it is rather than defaulted to an invented code — which would
1125 /// launder a malformed refusal into a plausible-looking one.
1126 ///
1127 /// Takes the whole `response` rather than just its `error` so the operator's
1128 /// own signed refusal survives on the value ([`Self::evidence`], #1042) —
1129 /// `body` is exactly the field set the `SlashJudge` EIP-712 `StreamResponse`
1130 /// typehash covers and `slash_sig` is the operator's secp256k1 signature over
1131 /// it.
1132 ///
1133 /// A refusal (`ok == false`) is **not** on-chain slash evidence: rate
1134 /// manipulation requires `ok == true` and blacklist violation requires a
1135 /// served claim (ADR 014), so a node may sign refusals freely. The retention
1136 /// is kept on its own merits — it is an attributable, non-repudiable record
1137 /// of *why* a paid pull was declined, which the caller can log, surface, or
1138 /// present in a dispute without re-signing anything.
1139 fn open(response: StreamResponse, ext: &StreamResponseExt) -> anyhow::Error {
1140 // `ok == true` is not a refusal at all — building an `Open` from it would
1141 // mint an evidence-carrying refusal with `ok == true`, violating invariant
1142 // 3. `validate` already rejects `(ok: true, error: Some)` as
1143 // `StreamErrorWithOk`, so reaching here with `ok == true` means that
1144 // invariant was bypassed; surface it as the protocol violation it is rather
1145 // than fold it into a typed refusal. Makes invariant 3 a construction
1146 // property, not just a call-site precondition (#1377 review).
1147 if response.body.ok {
1148 return anyhow::anyhow!(
1149 "open-stage refusal constructed from an ok == true response \
1150 (StreamResponse::validate invariant bypassed)"
1151 );
1152 }
1153 match ext.error.clone() {
1154 Some(error) => anyhow::Error::new(Self {
1155 kind: Kind::Open {
1156 error,
1157 response: Box::new(response),
1158 },
1159 }),
1160 None => anyhow::anyhow!(
1161 "delivery refused but the validated response carried no error code \
1162 (StreamResponseExt::validate invariant bypassed)"
1163 ),
1164 }
1165 }
1166
1167 /// Build the refusal for a **mid-stream** [`ClientMessage::StreamError`]
1168 /// frame — the refusal that arrives *after* the open stage, in reply to a
1169 /// delivery chunk or a voucher.
1170 ///
1171 /// Such a frame carries no signature (#1042), so [`Self::evidence`] is `None`
1172 /// here **by construction** and this is the only place that decision is made.
1173 /// The four mid-stream receive sites route through it so none can drift into
1174 /// synthesising a `StreamResponse` — which would hand an observer an unsigned
1175 /// artifact the evidence contract promises always recovers to the delivering
1176 /// node (#1378). The open-stage counterpart is the crate-private `open`.
1177 ///
1178 /// Public because callers outside the crate (and their tests) legitimately
1179 /// build mid-stream refusals; it is safe to expose precisely because it
1180 /// *cannot* carry a signed response — the encapsulation that matters guards
1181 /// the attributable `open` ([`Self::evidence`]), which stays crate-private
1182 /// so a caller cannot mint a refusal attributed to an operator.
1183 #[must_use]
1184 pub const fn mid_stream(error: StreamError) -> Self {
1185 Self {
1186 kind: Kind::MidStream { error },
1187 }
1188 }
1189
1190 /// The wire code the upstream signed. Always a `StreamError` as it appeared
1191 /// on the wire — never a server-side `ServeRejectReason`, whose seven-way
1192 /// collapse onto `NotFound` is deliberate and one-way
1193 /// (`handlers::client::wire_error`). Total over both shapes.
1194 #[must_use]
1195 pub const fn error(&self) -> &StreamError {
1196 match &self.kind {
1197 Kind::Open { error, .. } | Kind::MidStream { error } => error,
1198 }
1199 }
1200
1201 /// The upstream's own signed [`StreamResponse`], present iff the refusal
1202 /// arrived at the **open stage**. `None` for a mid-stream frame.
1203 ///
1204 /// An attributable record, not loose diagnostics — already verified against
1205 /// `expected_signer` by `verify_response` before this value is built, so a
1206 /// present value always recovers to `expected_signer`: the operator address
1207 /// the caller bound this pull to, which is what `SlashJudge._checkRegistered`
1208 /// resolves `nodeId` against.
1209 ///
1210 /// Note it is not by itself *slash* evidence — no offense admits a refusal
1211 /// (`ok == false`) as its stream leg (ADR 014). What it gives the caller is a
1212 /// non-repudiable statement of why the pull was declined, signed by the
1213 /// operator and usable verbatim, with no re-signing.
1214 #[must_use]
1215 pub fn evidence(&self) -> Option<&StreamResponse> {
1216 match &self.kind {
1217 Kind::Open { response, .. } => Some(response.as_ref()),
1218 Kind::MidStream { .. } => None,
1219 }
1220 }
1221}
1222
1223impl std::fmt::Debug for UpstreamRefused {
1224 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1225 // Elide the 65-byte `slash_sig` (and the full signed body) to a length so
1226 // `tracing::error!(?err)` stays readable — the derived `Debug` would dump
1227 // the entire attestation on every logged refusal.
1228 let mut dbg = f.debug_struct("UpstreamRefused");
1229 dbg.field("error", self.error());
1230 match self.evidence() {
1231 Some(resp) => dbg.field("evidence_slash_sig_len", &resp.slash_sig.len()),
1232 None => dbg.field("evidence", &Option::<()>::None),
1233 };
1234 dbg.finish()
1235 }
1236}
1237
1238impl std::fmt::Display for UpstreamRefused {
1239 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1240 // The `{:?}` rendering is load-bearing, exactly as in
1241 // `UpstreamVoucherRejected`: the loopback tests assert
1242 // `.contains("NotFound")` on this string, and `StreamError` has no
1243 // `Display`. What renders here is always a WIRE code — never a node-side
1244 // `ServeRejectReason` such as `UnknownChannel`, which collapses to
1245 // `NotFound` before it leaves the server (see `wire_error`). Matching on a
1246 // reject-reason name would therefore never fire.
1247 write!(f, "delivery refused: {:?}", self.error())
1248 }
1249}
1250
1251impl std::error::Error for UpstreamRefused {}
1252
1253/// Typed sentinel for an upstream that went SILENT mid-stream — bytes were flowing, and
1254/// then no byte of progress for the stall budget (#1134). Distinct from [`PullTimeout`],
1255/// which is a bound of OURS elapsing, and the distinction is load-bearing:
1256///
1257/// A whole-transfer deadline cannot tell "the provider is dead" from "this blob is big" or
1258/// "this link is slow", so `PullTimeout` must NOT tar the provider — it fires on perfectly
1259/// healthy transfers. A stall deadline resets on every byte received, so it fires ONLY when
1260/// a provider stops delivering while we wait. That IS evidence the peer is unreachable, and
1261/// `classify_pull_failure` scores it as such in the local per-peer EWMA (ADR 008).
1262///
1263/// # What the bound rests on
1264///
1265/// Two things, and it is worth being precise about both, because scoring a peer on a bound
1266/// that does not hold is how an honest node's local reputation gets unfairly defamed.
1267///
1268/// **The non-empty-`ChunkData` invariant (#1088).** With empty frames banned, "a frame
1269/// arrived" and "bytes made progress" are the same statement, so a peer cannot hold the
1270/// floor open with padding. The receive loop ([`UpstreamPull::next_chunk`]) has no
1271/// independent progress check behind that floor: it is safe because no frame a peer can
1272/// send makes zero progress, not because it verifies that it did. The floor is
1273/// structural rather than advisory — `ChunkData`'s field is private, and its constructor
1274/// and decode gate both reject an empty payload — so it cannot be relaxed by forgetting
1275/// to call a validator (#1145 review).
1276///
1277/// **At least one byte having already arrived.** The reset is what makes a stall the peer's
1278/// fault, so before the FIRST byte there has been no reset and the argument does not apply:
1279/// the clock is measuring the server's time-to-first-byte, which scales with blob size
1280/// (the serve path materialises the whole bao wire via `export_bao_range` before it can emit
1281/// chunk #1). Both loops therefore raise [`PullTimeout`] — exonerating — when the budget
1282/// elapses at `cumulative == 0`, and this sentinel only once bytes have flowed (#1145
1283/// review). Without that split, a 1 GiB blob off a cold disk would score an honest server as
1284/// unreachable for the crime of being big.
1285#[derive(Debug)]
1286#[cfg_attr(not(feature = "test-util"), non_exhaustive)]
1287pub struct PullStalled {
1288 /// The inactivity budget that elapsed after bytes had been flowing.
1289 pub after: Duration,
1290}
1291
1292impl std::fmt::Display for PullStalled {
1293 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1294 write!(f, "upstream stalled: no progress for {:?}", self.after)
1295 }
1296}
1297
1298impl std::error::Error for PullStalled {}
1299
1300/// Marker for a pull failure that is OURS, not the upstream's — a broken local
1301/// signer, an encode fault, a bad range computation (#1145 review).
1302///
1303/// Everything else that reaches `classify_pull_failure`'s catch-all is attributable
1304/// to the peer: a failed dial, a dropped connection, a bad signature, an unexpected
1305/// frame. Scoring the peer `Unreachable` there is correct, and is in fact the
1306/// PRIMARY way a dead node is detected — so the catch-all must stay as it is.
1307///
1308/// The exception was this class. A node whose own signer is broken cannot pay
1309/// anybody, and would walk the candidate list tarring every honest
1310/// provider it met with an `Unreachable` — a local EWMA hit; ADR 008 scoring is
1311/// local-only — on the strength of its own fault. Attach this marker at a
1312/// local-fault site and the classifier exonerates the peer and warns about us instead.
1313///
1314/// # It also decides what a CLIENT is told (#1560)
1315///
1316/// Reputation is not the only consequence. A second consumer — the node crate's
1317/// `record_pool_open_failure` — reads this marker to choose between answering a
1318/// downstream client `StreamError::NotFound` ("we could not obtain this blob") and
1319/// `Declined` ("this node will not serve this hash during this fetch"). That path
1320/// involves no peer and no reputation at all. One marked failure still answers
1321/// `NotFound`: a wallet that cannot fund a deposit, because the funding problem is
1322/// this node's, not the requester's.
1323///
1324/// So attaching this marker at a NEW site changes serve-path refusals, not just scoring.
1325/// Attach it when the failure means *this node* cannot serve anyone — a broken signer, an
1326/// unreadable or unwritable store, a poisoned lock, an unfunded wallet. Do NOT attach it to
1327/// a condition that is specific to one peer, one channel, or one blob, however much it is
1328/// "our side" of the exchange: a wedged channel to a single provider is ours and is still a
1329/// clean miss, because the node can serve every other request perfectly well.
1330///
1331/// It is a marker, so it composes: `.context(LocalPullFault)` on any error.
1332#[derive(Debug)]
1333#[doc(hidden)]
1334pub struct LocalPullFault;
1335
1336impl std::fmt::Display for LocalPullFault {
1337 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1338 f.write_str("local buyer-side fault (not attributable to the upstream)")
1339 }
1340}
1341
1342impl std::error::Error for LocalPullFault {}
1343
1344/// The bounds on a pull, each matched to the stage it governs (#1134, #1797).
1345///
1346/// A single whole-transfer deadline is mostly useless as a health signal: it has to be
1347/// sized against `blob size × link speed`, so it kills legitimate large or slow-but-healthy
1348/// transfers while a value small enough to catch a dead peer quickly cannot serve a big blob
1349/// at all. The operator ends up tuning a number that has nothing to do with node health.
1350///
1351/// Split by stage instead:
1352///
1353/// - **`window` + `floor_bps`** bound the STREAMING stage by THROUGHPUT. The requester
1354/// counts bytes read off the QUIC stream (sub-frame, so the measure is frame-size
1355/// independent) and trips when the bytes across the trailing `window` fall below
1356/// `floor_bps · window`. This catches both a wedge (throughput drops to zero) and a
1357/// slow drip (throughput below the floor), and is indifferent to transfer size and link
1358/// speed. `floor_bps == 0` leaves pure idle detection: at least one byte per window.
1359/// This is the primary mechanism. The abort is requester-local policy and does not score
1360/// the peer.
1361/// - **`hard_cap`** is an optional overall wall-clock escape hatch, `None` by
1362/// default. It exists for a caller that must bound total runtime regardless; it
1363/// is not how a stalled peer is detected.
1364///
1365/// - **`open`** bounds the OPEN stage (dial → request → verified `StreamResponse`)
1366/// by WALL CLOCK. That stage is bounded work whose duration does not depend on
1367/// the blob, so a slow one really is a stall and a wall clock is the right tool.
1368///
1369/// Every stage must carry a bound of its own. It is not enough for a caller to
1370/// wrap the whole pull in a timeout and call the open "bounded": the handshake
1371/// happens *inside* [`open_progressive_pull`], and the production callers run
1372/// with no overall cap, so leaving the bound to the caller would leave the
1373/// `StreamResponse` read with no bound at all — a peer that accepts a connection
1374/// and then says nothing would hang the pull forever. `open` exists so that
1375/// cannot be expressed.
1376/// # The relational invariant
1377///
1378/// `hard_cap`, when set, must STRICTLY EXCEED `open + window`. Both clocks below run inside
1379/// the cap's, and in the worst case consecutively — the open can legitimately consume its
1380/// whole budget before the streaming stage begins, and the floor needs one full `window` to
1381/// fire — so a cap that does not outlast both means the cap always fires first and the
1382/// throughput floor can never trip under it. The pull then looks fully configured while its
1383/// peer-health signal is dead.
1384///
1385/// [`Self::capped`] is fallible and the fields are private BECAUSE of that (#1145 review).
1386/// The invariant belongs on this type: with `pub` fields, callers build it with a struct
1387/// literal and the only check is a hardcoded `timeout > 2 × window` in the CLI's
1388/// `ClientFetchArgs::validate`, correct only because those call sites set `open` from the same
1389/// knob as `window`. Adding an
1390/// `--open-timeout-ms` flag would have made it silently wrong, in the direction that reopens
1391/// the hole. The invariant belongs to the type that has the values.
1392#[derive(Debug, Clone, Copy)]
1393pub struct PullDeadlines {
1394 /// Wall-clock bound on the open stage: dial, request, and the signed
1395 /// `StreamResponse`. Bounded work — a slow one is a stall.
1396 open: Duration,
1397 /// Trailing window over which the streaming-stage throughput floor is measured.
1398 window: Duration,
1399 /// Minimum bytes-per-second the streaming stage must sustain over `window`. `0`
1400 /// disables the throughput test and leaves pure idle detection (one byte per window).
1401 floor_bps: u64,
1402 /// Optional overall wall-clock cap on the whole exchange. `None` = uncapped;
1403 /// `open` and `window` between them are what keep an uncapped pull from hanging.
1404 hard_cap: Option<Duration>,
1405}
1406
1407/// A [`PullDeadlines`] whose bounds cannot do their job. Carries the values so a CLI
1408/// can render the arithmetic back to the user rather than just saying "invalid".
1409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1410#[non_exhaustive]
1411pub enum DeadlineError {
1412 /// A zero budget elapses on its first poll, so the stage it bounds can never run.
1413 ZeroBudget,
1414 /// The cap does not outlast `open + window`, so it always fires first and the
1415 /// throughput floor — the signal that a healthy stream is still making progress —
1416 /// can never fire.
1417 CapCannotOutlastItsStages {
1418 /// The open-stage budget.
1419 open: Duration,
1420 /// The throughput-floor window that follows it.
1421 window: Duration,
1422 /// The whole-pull cap, which must exceed `open + window`.
1423 hard_cap: Duration,
1424 },
1425}
1426
1427impl std::fmt::Display for DeadlineError {
1428 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1429 match self {
1430 Self::ZeroBudget => write!(f, "a deadline of zero elapses before any work can run"),
1431 Self::CapCannotOutlastItsStages {
1432 open,
1433 window,
1434 hard_cap,
1435 } => write!(
1436 f,
1437 "the overall cap ({hard_cap:?}) must exceed the open bound ({open:?}) plus the \
1438 throughput-floor window ({window:?}): both run inside it and in the worst case \
1439 consecutively, so below that the cap always elapses first and a stalled \
1440 provider can never be detected"
1441 ),
1442 }
1443 }
1444}
1445
1446impl std::error::Error for DeadlineError {}
1447
1448impl PullDeadlines {
1449 /// The recommended shape: a wall clock on the open, inactivity on the stream,
1450 /// and no overall cap — so a pull of any size completes as long as the upstream
1451 /// keeps feeding it bytes.
1452 ///
1453 /// # Errors
1454 ///
1455 /// [`DeadlineError::ZeroBudget`] for a zero `open` or `window`. `floor_bps` may be zero
1456 /// (idle-detection mode: one byte per window).
1457 ///
1458 /// A zero `open` or `window` must fail here, not pass. One might argue it is a bound that
1459 /// fires too EAGERLY — loud, immediately obvious — rather than one that silently never
1460 /// fires. That is exactly backwards: a zero window makes the throughput floor demand
1461 /// progress over no time at all, so the streaming stage can never satisfy it.
1462 ///
1463 /// The only thing standing between config and that state was a `> 0` check in a resolver
1464 /// in another crate — the same advisory-invariant shape `ChunkData` had before #1088, and
1465 /// the reason this type owns its bounds at all.
1466 pub const fn new(
1467 open: Duration,
1468 window: Duration,
1469 floor_bps: u64,
1470 ) -> Result<Self, DeadlineError> {
1471 if open.is_zero() || window.is_zero() {
1472 return Err(DeadlineError::ZeroBudget);
1473 }
1474 Ok(Self {
1475 open,
1476 window,
1477 floor_bps,
1478 hard_cap: None,
1479 })
1480 }
1481
1482 /// As [`Self::new`], plus an overall wall-clock cap on the whole exchange.
1483 ///
1484 /// # Errors
1485 ///
1486 /// [`DeadlineError::CapCannotOutlastItsStages`] if `hard_cap` does not strictly exceed
1487 /// `open + window`, and [`DeadlineError::ZeroBudget`] for a zero `open` or `window`. See
1488 /// the type's own docs for why this is the one constructor that must be fallible.
1489 pub fn capped(
1490 open: Duration,
1491 window: Duration,
1492 floor_bps: u64,
1493 hard_cap: Duration,
1494 ) -> Result<Self, DeadlineError> {
1495 if open.is_zero() || window.is_zero() {
1496 return Err(DeadlineError::ZeroBudget);
1497 }
1498 if hard_cap <= open.saturating_add(window) {
1499 return Err(DeadlineError::CapCannotOutlastItsStages {
1500 open,
1501 window,
1502 hard_cap,
1503 });
1504 }
1505 Ok(Self {
1506 open,
1507 window,
1508 floor_bps,
1509 hard_cap: Some(hard_cap),
1510 })
1511 }
1512
1513 /// The open-stage wall clock.
1514 #[must_use]
1515 pub const fn open(&self) -> Duration {
1516 self.open
1517 }
1518
1519 /// The streaming-stage throughput-floor window.
1520 #[must_use]
1521 pub const fn window(&self) -> Duration {
1522 self.window
1523 }
1524
1525 /// The streaming-stage throughput floor, in bytes per second. `0` = idle detection only.
1526 #[must_use]
1527 pub const fn floor_bps(&self) -> u64 {
1528 self.floor_bps
1529 }
1530
1531 /// The overall cap, if any.
1532 #[must_use]
1533 pub const fn hard_cap(&self) -> Option<Duration> {
1534 self.hard_cap
1535 }
1536
1537 /// The single-deadline shape: one budget serving as the open bound, the
1538 /// streaming window, AND the overall cap, with the throughput floor disabled
1539 /// (`floor_bps == 0`, idle detection only). **Test-only — do not reach for this in
1540 /// production.** That conflation is exactly what #1134 set out to remove, and the
1541 /// name reads far more like a legitimate policy choice than it is.
1542 ///
1543 /// Note what it quietly costs, beyond re-introducing the size-coupled deadline:
1544 /// because `hard_cap == window`, and the cap's clock starts at the top of the whole
1545 /// exchange while the streaming window starts only once the open has completed, **the
1546 /// hard cap always elapses first — so [`PullStalled`] can never fire under it.** A pull
1547 /// built this way silently cannot detect a stalled peer, and so cannot score one.
1548 /// Every loopback test using this helper is exercising a pull with the stall
1549 /// signal disabled; the stall path is covered by
1550 /// `node_origin_mid_stream_silence_does_not_score_stalled_upstream`, which builds its
1551 /// deadlines explicitly.
1552 ///
1553 /// That is a statement about the `test-util` `stream_fetch*` wrappers, where
1554 /// `with_hard_cap` wraps the whole exchange. [`open_progressive_pull`] and
1555 /// [`driver::drive`] never consult `hard_cap` at all, so a `whole_transfer` used there
1556 /// would leave `PullStalled` perfectly able to fire — which is not a reprieve, just a
1557 /// different reason not to reach for this (#1145 review).
1558 ///
1559 /// It is also the reason this constructor stays infallible while [`Self::capped`] is not:
1560 /// it deliberately builds the very state `capped` refuses.
1561 ///
1562 /// TEST-ONLY, and now unrepresentable in production by construction: gated behind the
1563 /// `test-util` feature (#1145 review), so a production caller cannot name it and reach for
1564 /// the disabled-floor / uncapped state — it wants [`Self::new`] (floor-bounded) or
1565 /// [`Self::capped`] (floor-bounded with a leak guard). Used by the loopback helper
1566 /// `stream_fetch` and directly by the `client_loopback` suite, whose blobs are small
1567 /// enough that none of this matters.
1568 #[cfg(any(test, feature = "test-util"))]
1569 #[must_use]
1570 pub const fn whole_transfer(timeout: Duration) -> Self {
1571 Self {
1572 open: timeout,
1573 window: timeout,
1574 floor_bps: 0,
1575 hard_cap: Some(timeout),
1576 }
1577 }
1578}
1579
1580/// Fetch `hash` from `target` over `cdn/client/v1` into memory, paying as bytes
1581/// arrive and verifying each bao chunk group as it lands.
1582///
1583/// `expected_signer` is the delivering node's Ethereum address, used to verify
1584/// the response `slash_sig`. `byte_offset` resumes a partial fetch. Use
1585/// [`stream_fetch_tracked`] instead if you need to persist the voucher watermark
1586/// the channel reached (#852); this convenience wrapper discards it.
1587///
1588/// TEST-ONLY, like the whole `stream_fetch*` family: gated behind the `test-util`
1589/// feature alongside [`PullDeadlines::whole_transfer`], the single-deadline shape
1590/// it passes (#1145 review). The wrappers are thin in-memory drivers of the one
1591/// receive loop, [`UpstreamPull`], for suites that want the decoded bytes back;
1592/// production callers stream into a store through [`driver::drive`] /
1593/// [`PeerSource`].
1594///
1595/// # Errors
1596///
1597/// Fails on connect/transport errors, an invalid or zero-rate response, a
1598/// `slash_sig` that does not recover to `expected_signer`, a mismatched echoed
1599/// field, a mid-stream `VoucherRejected`, a hash mismatch, or a timeout.
1600#[cfg(any(test, feature = "test-util"))]
1601#[allow(clippy::too_many_arguments)]
1602pub async fn stream_fetch(
1603 endpoint: &Endpoint,
1604 target: EndpointAddr,
1605 ctx: &PoolContext,
1606 slash_domain: &Eip712Domain,
1607 expected_signer: Address,
1608 hash: [u8; 32],
1609 byte_offset: u64,
1610 timestamp_us: u64,
1611 timeout: Duration,
1612) -> anyhow::Result<Bytes> {
1613 stream_fetch_tracked(
1614 endpoint,
1615 target,
1616 ctx,
1617 slash_domain,
1618 expected_signer,
1619 hash,
1620 // `stream_fetch` models a node-to-node pull; a suite that routes on a
1621 // namespace calls `stream_fetch_tracked` directly.
1622 decdn_protocol::client::NO_NAMESPACE,
1623 byte_offset,
1624 timestamp_us,
1625 // The single-deadline shape (#1134): this helper's callers are
1626 // loopback tests moving tiny blobs, for which one budget serving as both
1627 // the overall cap and the stall bound is harmless. Production paths take
1628 // `PullDeadlines` directly and split the two.
1629 PullDeadlines::whole_transfer(timeout),
1630 // No buyer-side blob-size or rate ceiling on this loopback helper; a suite
1631 // that exercises either passes it through `stream_fetch_tracked`.
1632 0,
1633 0,
1634 &mut VoucherProgress::default(),
1635 )
1636 .await
1637}
1638
1639/// Like [`stream_fetch`], but drives the fetch over a caller-owned
1640/// [`WarmConnection`] instead of dialling a fresh connection (#1848). Two
1641/// calls with the same `warm` reuse one dialled connection — one bi-stream per
1642/// hash — which is exactly what the connection-reuse test asserts.
1643///
1644/// TEST-ONLY, like the rest of the `stream_fetch*` family. Runs a single attempt
1645/// against a one-shot [`PoolLedger`] seeded from `ctx.prior_*` (the loopback
1646/// blobs it serves never trigger the wallet-less resume loop).
1647///
1648/// # Errors
1649///
1650/// The same set as [`stream_fetch`].
1651#[cfg(any(test, feature = "test-util"))]
1652#[allow(clippy::too_many_arguments)]
1653pub async fn stream_fetch_on(
1654 warm: &WarmConnection,
1655 ctx: &PoolContext,
1656 slash_domain: &Eip712Domain,
1657 expected_signer: Address,
1658 hash: [u8; 32],
1659 byte_offset: u64,
1660 timestamp_us: u64,
1661 timeout: Duration,
1662) -> anyhow::Result<Bytes> {
1663 let ledger = Arc::new(ctx.new_ledger());
1664 fetch_in_memory_once_on(
1665 warm,
1666 ctx,
1667 ledger,
1668 slash_domain,
1669 expected_signer,
1670 hash,
1671 // Models a node-to-node pull, matching `stream_fetch`.
1672 decdn_protocol::client::NO_NAMESPACE,
1673 byte_offset,
1674 timestamp_us,
1675 0,
1676 0,
1677 PullDeadlines::whole_transfer(timeout),
1678 None,
1679 )
1680 .await
1681}
1682
1683/// Like `stream_fetch`, but reports the channel's acked voucher watermark via
1684/// the `progress` out-param so the caller can persist what it paid (#852).
1685///
1686/// `progress` is an out-param: on return it holds the cumulative
1687/// `(bytes_delivered, amount)` of the last *acked* voucher. Internally the pull
1688/// runs against a one-shot [`PoolLedger`] seeded from `ctx.prior_*`; the
1689/// ledger's snapshot is copied back into `progress` on every return path — `Ok`,
1690/// `Err`, or timeout — so the caller can record progress even for a mid-stream
1691/// failure or a paid-but-corrupt delivery. See [`VoucherProgress`].
1692///
1693/// # Errors
1694///
1695/// Same as `stream_fetch`.
1696#[cfg(any(test, feature = "test-util"))]
1697#[allow(clippy::too_many_arguments)]
1698pub async fn stream_fetch_tracked(
1699 endpoint: &Endpoint,
1700 target: EndpointAddr,
1701 ctx: &PoolContext,
1702 slash_domain: &Eip712Domain,
1703 expected_signer: Address,
1704 hash: [u8; 32],
1705 namespace_id: [u8; 32],
1706 byte_offset: u64,
1707 timestamp_us: u64,
1708 deadlines: PullDeadlines,
1709 max_blob_size_bytes: u64,
1710 max_rate_per_mb: u64,
1711 progress: &mut VoucherProgress,
1712) -> anyhow::Result<Bytes> {
1713 stream_fetch_tracked_with_progress(
1714 endpoint,
1715 target,
1716 ctx,
1717 slash_domain,
1718 expected_signer,
1719 hash,
1720 namespace_id,
1721 byte_offset,
1722 timestamp_us,
1723 deadlines,
1724 max_blob_size_bytes,
1725 max_rate_per_mb,
1726 progress,
1727 None,
1728 )
1729 .await
1730}
1731
1732/// Delivery-progress callback: invoked with `(received, expected)` after each
1733/// verified leaf lands, so a caller (e.g. `decdn fetch`) can render a progress
1734/// bar. Both slots are **content** bytes: `received` is the content position the
1735/// verifying decoder has reached and `expected` is the blob's content
1736/// `total_bytes`, constant across the pull — so a bar keyed on the two fills to
1737/// exactly 100%. `drive` / `acquire` (the resumable [`crate::driver`]
1738/// path the CLI `fetch` and `bundle pull` use) additionally emit the
1739/// already-present resume base (`base_present`) once before streaming begins; the
1740/// `test-util` `stream_fetch_tracked_with_progress` wrapper reports the same
1741/// unit from its in-memory decoder.
1742///
1743/// It must not panic (it runs inside the hot receive loop) and must be `Send +
1744/// Sync` so the pull future stays spawnable.
1745pub type ProgressCallback = dyn Fn(u64, u64) + Send + Sync;
1746
1747/// Like [`stream_fetch_tracked`], but also reports per-chunk delivery progress
1748/// through `on_progress` (see [`ProgressCallback`]) — the byte-progress hook the
1749/// watermark-only [`VoucherProgress`] out-param does not provide. `None` behaves
1750/// exactly like [`stream_fetch_tracked`].
1751///
1752/// # Errors
1753///
1754/// Same as `stream_fetch`.
1755#[cfg(any(test, feature = "test-util"))]
1756#[allow(clippy::too_many_arguments)]
1757pub async fn stream_fetch_tracked_with_progress(
1758 endpoint: &Endpoint,
1759 target: EndpointAddr,
1760 ctx: &PoolContext,
1761 slash_domain: &Eip712Domain,
1762 expected_signer: Address,
1763 hash: [u8; 32],
1764 namespace_id: [u8; 32],
1765 byte_offset: u64,
1766 timestamp_us: u64,
1767 deadlines: PullDeadlines,
1768 max_blob_size_bytes: u64,
1769 max_rate_per_mb: u64,
1770 progress: &mut VoucherProgress,
1771 on_progress: Option<&ProgressCallback>,
1772) -> anyhow::Result<Bytes> {
1773 // One-shot ledger seeded from the channel's prior cumulative state. A single
1774 // (non-shared) pull owns its ledger; concurrent shared-channel pulls use
1775 // `stream_fetch_shared` with a caller-owned ledger instead.
1776 let ledger = Arc::new(ctx.new_ledger());
1777 let result = with_hard_cap(
1778 deadlines.hard_cap,
1779 fetch_inner(
1780 endpoint,
1781 target,
1782 ctx,
1783 slash_domain,
1784 expected_signer,
1785 hash,
1786 namespace_id,
1787 byte_offset,
1788 timestamp_us,
1789 max_blob_size_bytes,
1790 max_rate_per_mb,
1791 deadlines,
1792 &ledger,
1793 on_progress,
1794 ),
1795 )
1796 .await;
1797 // Copy the watermark back into `progress` on EVERY return path (Ok, Err,
1798 // timeout) BEFORE returning, so the latest totals survive a mid-stream failure
1799 // or a paid-but-corrupt delivery (#852).
1800 //
1801 // Read it from the LEDGER rather than a copied-back cumulative, so the figure
1802 // is `settlement` and not `committed` — matching the two sibling persist paths
1803 // (`UpstreamPull::progress`, the `node_origin` drop guard). A voucher left
1804 // armed by an ambiguous send is owed, and the error arm here is exactly where
1805 // that happens. On the `Ok` arm nothing is armed, so the two agree.
1806 *progress = VoucherProgress::from_ledger(&ledger, ctx.prior_amount);
1807 result
1808}
1809
1810/// Apply the optional overall wall-clock cap of a [`PullDeadlines`] to `fut`.
1811///
1812/// `None` runs the pull uncapped — which is safe precisely because the `stall`
1813/// bound inside bounds every streaming read. A pull with no cap cannot hang; it
1814/// can only take as long as the upstream keeps feeding it bytes, which is the
1815/// point (#1134).
1816#[cfg(any(test, feature = "test-util"))]
1817async fn with_hard_cap<F>(hard_cap: Option<Duration>, fut: F) -> anyhow::Result<Bytes>
1818where
1819 F: std::future::Future<Output = anyhow::Result<Bytes>>,
1820{
1821 match hard_cap {
1822 Some(cap) => tokio::time::timeout(cap, fut)
1823 .await
1824 .map_err(|_| anyhow::Error::new(PullTimeout { after: cap }))?,
1825 None => fut.await,
1826 }
1827}
1828
1829/// Like `stream_fetch`, but issues vouchers through a caller-owned shared
1830/// [`PoolLedger`] so multiple concurrent pulls on ONE payment channel coordinate.
1831///
1832/// The bug this fixes: each `stream_fetch`/`stream_fetch_tracked` call seeds its
1833/// own voucher state from `ctx.prior_*`, so N concurrent pulls on the same channel
1834/// each compute the next cumulative `amount` independently and collide — the node
1835/// accepts exactly one and rejects the rest as `AmountRegression`. Passing every
1836/// concurrent caller the SAME `&Arc<PoolLedger>` (shared across
1837/// `tokio::spawn`/`join!`) serializes their voucher issuance through the
1838/// ledger's mutex: each issue advances the cumulative `amount`/`bytes_delivered`
1839/// in turn, the channel advances monotonically, and all pulls succeed.
1840///
1841/// The caller owns the ledger's lifetime and persists what the channel paid from it
1842/// directly (this entrypoint does not surface a [`VoucherProgress`] — the shared ledger
1843/// IS the watermark). Persist via [`PoolLedger::settlement`], NOT `snapshot`:
1844/// `snapshot`/`committed` report only ACKED vouchers, so a voucher left in the ack wait
1845/// (the drop the node's `SettleOnDrop` guard handles) is under-reported and its deposit
1846/// stranded — `settlement` adds the in-flight voucher back (#1122/#1145). `snapshot` is
1847/// also `async`, so a `Drop` guard cannot call it at all.
1848///
1849/// # Errors
1850///
1851/// Same as `stream_fetch`.
1852#[cfg(any(test, feature = "test-util"))]
1853#[allow(clippy::too_many_arguments)]
1854pub async fn stream_fetch_shared(
1855 endpoint: &Endpoint,
1856 target: EndpointAddr,
1857 ctx: &PoolContext,
1858 ledger: &Arc<PoolLedger>,
1859 slash_domain: &Eip712Domain,
1860 expected_signer: Address,
1861 hash: [u8; 32],
1862 byte_offset: u64,
1863 timestamp_us: u64,
1864 deadlines: PullDeadlines,
1865 max_blob_size_bytes: u64,
1866 max_rate_per_mb: u64,
1867) -> anyhow::Result<Bytes> {
1868 with_hard_cap(
1869 deadlines.hard_cap,
1870 fetch_inner(
1871 endpoint,
1872 target,
1873 ctx,
1874 slash_domain,
1875 expected_signer,
1876 hash,
1877 // Shared-channel pulls model the daemon as buyer on a node-to-node
1878 // fill; the requester already discovered the holder, so no namespace.
1879 decdn_protocol::client::NO_NAMESPACE,
1880 byte_offset,
1881 timestamp_us,
1882 max_blob_size_bytes,
1883 max_rate_per_mb,
1884 deadlines,
1885 ledger,
1886 // Shared concurrent pulls interleave many blobs on one channel; a
1887 // single unified byte-progress readout would be meaningless, so this
1888 // path never reports progress.
1889 None,
1890 ),
1891 )
1892 .await
1893}
1894
1895/// Where an [`open_stream`] gets its QUIC connection.
1896enum ConnSource<'a> {
1897 /// Dial a fresh one-shot connection to `target`. The pull owns it and closes
1898 /// it on its terminal method (`finish`/`abort`/drop).
1899 Dial {
1900 endpoint: &'a Endpoint,
1901 target: EndpointAddr,
1902 /// The runtime the dial and the connection's driver run on; `None` runs
1903 /// them on the caller's (see [`connection::dial`]).
1904 runtime: Option<&'a tokio::runtime::Handle>,
1905 },
1906 /// Reuse a caller-owned [`WarmConnection`]'s connection. The pull borrows it
1907 /// and leaves it open for the next hash; the [`WarmConnection`] closes it once.
1908 Reuse(&'a iroh::endpoint::Connection),
1909}
1910
1911/// The OPEN stage of a `cdn/client/v1` pull, the single request site behind
1912/// [`open_progressive_pull`]: get the connection from `source` (dial a fresh one,
1913/// or reuse a warm one), open the bi-stream, send the [`StreamRequest`], then read
1914/// and verify the signed [`StreamResponse`]. Returns the live connection, its
1915/// streams, and the verified response for the caller to stream from.
1916///
1917/// **Bounded as a whole by `open`** (#1134), and that bound lives HERE rather than
1918/// in the caller for a reason worth stating: the production pull paths run with no
1919/// overall wall-clock cap, so that a blob of any size can complete as long as bytes
1920/// keep arriving. Leaving the open to "whatever the caller wraps us in" therefore
1921/// means leaving it *unbounded* — and this is precisely the stage where a peer can
1922/// accept a connection, take our request, and then say nothing at all. Such a peer
1923/// would hang the pull until the QUIC idle timeout.
1924///
1925/// The stall bound cannot cover this stage: it measures inactivity BETWEEN bytes,
1926/// and here no byte has arrived yet. A wall clock is the right tool because the
1927/// open is bounded work whose duration does not scale with the blob.
1928///
1929/// A `slash_sig` that does not recover to `expected_signer`, a mismatched echoed
1930/// field, or a zero rate fails here (see [`verify_response`]) — before any byte is
1931/// paid for. A refusal (`body.ok == false`) is returned to the caller intact, since
1932/// only the caller knows how to classify it.
1933#[allow(clippy::too_many_arguments)]
1934async fn open_stream(
1935 source: ConnSource<'_>,
1936 ctx: &PoolContext,
1937 slash_domain: &Eip712Domain,
1938 expected_signer: Address,
1939 hash: [u8; 32],
1940 namespace_id: [u8; 32],
1941 byte_offset: u64,
1942 byte_len: u64,
1943 timestamp_us: u64,
1944 open: Duration,
1945) -> anyhow::Result<(
1946 iroh::endpoint::Connection,
1947 SendStream,
1948 RecvStream,
1949 StreamResponse,
1950 StreamResponseExt,
1951)> {
1952 tokio::time::timeout(open, async move {
1953 let conn = match source {
1954 ConnSource::Dial {
1955 endpoint,
1956 target,
1957 runtime,
1958 } => connection::dial(endpoint, target, runtime, "connect failed").await?,
1959 // Already dialled and warm — reuse the handle. A fresh `open_bi` below
1960 // gives this hash its own stream.
1961 ConnSource::Reuse(conn) => conn.clone(),
1962 };
1963 let (mut send, mut recv) = conn
1964 .open_bi()
1965 .await
1966 .map_err(|e| rate_limited::transport_error("open_bi failed", e))?;
1967
1968 let req = StreamRequest {
1969 hash,
1970 // The serving node routes on this for its pull-through authorized-origin
1971 // gate and origin-directory fallback (ADR 005 §Namespace routing). A
1972 // client fetch passes the namespace it published under; a node-to-node
1973 // pull to a DHT-discovered holder passes `NO_NAMESPACE` (0) — the holder
1974 // already has the bytes (ADR 002 §Retrieval by namespace) — while a pull
1975 // to a directory-discovered cold origin passes the served namespace so
1976 // that origin's gate resolves and it can fill from its own backend.
1977 namespace_id,
1978 pool_id: ctx.pool_id.into(),
1979 byte_offset,
1980 // `0` = whole-tail fetch; a caller requesting a bounded middle gap
1981 // (the gap-driven driver, #1608, via `PeerSource`) passes the gap's
1982 // length so the server scopes both the serve and the payment to it.
1983 byte_len,
1984 timestamp_us,
1985 };
1986 // Two-phase encode (ADR 005): attach the client identity binding when the
1987 // context carries one, so the serving node can prove channel ownership and
1988 // authorize a cache-miss origin pull (#1115). Absent ⇒ no ext bytes: the
1989 // base frame alone (unbound node-to-node / registered-client path).
1990 let ext = client_binding_ext(ctx);
1991 let payload = decdn_protocol::encode_stream_request(&req, ext.as_ref())
1992 .map_err(|e| anyhow::anyhow!("encode stream request: {e}").context(LocalPullFault))?;
1993 write_frame(&mut send, &payload)
1994 .await
1995 .map_err(|e| rate_limited::transport_error("write stream request", e))?;
1996
1997 let (resp, resp_ext) = read_stream_response(&mut recv).await?;
1998 verify_response(
1999 &resp,
2000 &resp_ext,
2001 slash_domain,
2002 expected_signer,
2003 hash,
2004 ctx.pool_id,
2005 timestamp_us,
2006 )?;
2007 Ok((conn, send, recv, resp, resp_ext))
2008 })
2009 .await
2010 .map_err(|_| anyhow::Error::new(PullTimeout { after: open }))?
2011}
2012
2013/// Wallet-less resume (issue #1481 §5): the maximum number of times a fetch will reopen a fresh
2014/// stream after a rejection carrying a watermark-gated bundle
2015/// ([`VoucherRejectReason::is_watermark_gated`]). Bounds a node that keeps rejecting (a buggy or
2016/// adversarial peer echoing a bundle that never lets the client catch up) to a handful of round
2017/// trips rather than looping forever; a healthy self-heal needs exactly one.
2018///
2019/// Public because the STREAMING fetch (#1120) drives its own reopen loop — it
2020/// owns the output file and must rewind it before each retry, which this crate
2021/// cannot do for it — and both loops must agree on the bound.
2022#[doc(hidden)]
2023pub const MAX_RESUME_ATTEMPTS: u32 = 3;
2024
2025/// In-memory fetch with wallet-less resume (issue #1481 §5): if a mid-stream
2026/// voucher rejection carries a signer-verified [`WatermarkBundle`] for one of
2027/// the four regression/exhaustion reasons, reseed `ledger` from it and reopen
2028/// the pull — at the **same** `byte_offset` the caller originally requested,
2029/// not `bundle.bytes_delivered` — instead of surfacing the rejection as
2030/// terminal.
2031///
2032/// `bundle.bytes_delivered` is deliberately NOT used as the retry's wire
2033/// `byte_offset`, even though that is what makes a `WatermarkBundle` look
2034/// resumable at a glance. The two are different axes: `bytes_delivered` is
2035/// the CHANNEL's cumulative payment counter (used to reconstruct each
2036/// voucher's EIP-712 `bytesDelivered`, ADR 005 — [`Voucher`] carries no such
2037/// field on the wire), not a position within THIS blob's byte range. A
2038/// channel can fund many blobs; jumping the wire offset ahead to the
2039/// channel's cumulative would, for a caller that requested `byte_offset == 0`,
2040/// silently return a TRUNCATED tail instead of the full blob the caller is
2041/// relying on getting back. The bytes already decoded in the failed attempt
2042/// were dropped with it, so there is nothing to legitimately splice a jump
2043/// with. Retrying at the caller's original offset is always safe and is the
2044/// only thing this bundle field is actually needed for: fixing the ledger's
2045/// amount/bytes BASELINE so the resumed stream's vouchers verify against what
2046/// the node now expects, not re-deriving where in the blob to resume.
2047///
2048/// This is the retry loop for this case on the in-memory path —
2049/// [`UpstreamPull::pay_one`] never retries itself, because the stream it holds
2050/// is already dead by the time a mid-stream `VoucherRejected` reaches it (the
2051/// node finishes its send side before replying,
2052/// `handlers/client/wire.rs::write_reject`); a fresh stream can only be opened
2053/// by whoever owns the connection, which is here.
2054///
2055/// Every `stream_fetch*` wrapper goes through here, so a resumable rejection is
2056/// retried transparently and the caller only ever sees the FINAL outcome
2057/// (success, or the original terminal error once [`MAX_RESUME_ATTEMPTS`] is
2058/// exhausted or the reason/bundle isn't eligible).
2059///
2060/// The production callers do not use this wrapper: the gap-driven
2061/// [`driver::drive`] reaches the same reseed loop around [`open_progressive_pull`].
2062///
2063/// A bundle-less rejection, a non-gated reason, or a bundle that fails
2064/// [`WatermarkBundle::validate`] (malformed `last_signature` length) is
2065/// never treated as resumable and is returned to the caller unchanged on
2066/// the first attempt.
2067#[cfg(any(test, feature = "test-util"))]
2068#[allow(clippy::too_many_arguments)]
2069async fn fetch_inner(
2070 endpoint: &Endpoint,
2071 target: EndpointAddr,
2072 ctx: &PoolContext,
2073 slash_domain: &Eip712Domain,
2074 expected_signer: Address,
2075 hash: [u8; 32],
2076 namespace_id: [u8; 32],
2077 byte_offset: u64,
2078 timestamp_us: u64,
2079 max_blob_size_bytes: u64,
2080 max_rate_per_mb: u64,
2081 deadlines: PullDeadlines,
2082 ledger: &Arc<PoolLedger>,
2083 on_progress: Option<&ProgressCallback>,
2084) -> anyhow::Result<Bytes> {
2085 for attempt in 0..=MAX_RESUME_ATTEMPTS {
2086 let result = fetch_in_memory_once(
2087 endpoint,
2088 target.clone(),
2089 ctx,
2090 Arc::clone(ledger),
2091 slash_domain,
2092 expected_signer,
2093 hash,
2094 namespace_id,
2095 byte_offset,
2096 timestamp_us,
2097 max_blob_size_bytes,
2098 max_rate_per_mb,
2099 deadlines,
2100 on_progress,
2101 )
2102 .await;
2103 let Err(e) = result else {
2104 return result;
2105 };
2106 if attempt == MAX_RESUME_ATTEMPTS {
2107 return Err(e);
2108 }
2109 // A bundle that proves no desync (see `heal_watermark_desync`) is not worth
2110 // the resume budget: surface the real error instead.
2111 let healed = match rejection_watermark(&e, ctx) {
2112 Some(watermark) => heal_watermark_desync(&e, watermark, ledger).await,
2113 None => None,
2114 };
2115 let Some(healed) = healed else {
2116 return Err(e);
2117 };
2118 tracing::debug!(
2119 attempt,
2120 byte_offset,
2121 ?healed,
2122 "voucher rejection carried an authenticated watermark bundle; retrying at the same \
2123 byte_offset"
2124 );
2125 }
2126 // Unreachable: the loop above always returns on both the `Ok` and every `Err`
2127 // branch (either directly or after `attempt == MAX_RESUME_ATTEMPTS`
2128 // triggers on the final iteration). Kept as a typed bail rather than
2129 // `unreachable!()`/`panic!()` per the workspace anti-panic policy.
2130 Err(anyhow::anyhow!("resume loop exited without returning"))
2131}
2132
2133/// One attempt of the in-memory `stream_fetch*` path: open a progressive pull,
2134/// feed its wire bytes through the bao verifying decoder AS THEY ARRIVE, and
2135/// return the decoded plaintext span `[byte_offset, total_bytes)`.
2136///
2137/// This is the same receive loop the production callers drive
2138/// ([`UpstreamPull::next_chunk`] under a [`PullReader`]) with an in-memory `Vec`
2139/// standing in for the ranged store / cache admit; there is no second receive
2140/// implementation. A corrupt chunk group therefore aborts the pull at that group
2141/// (ADR 038 §Receive side), before the rest of the stream is pulled or paid for —
2142/// so a peer that signs an inflated `total_bytes` and streams framed garbage can
2143/// bill at most the bytes that arrived before the first unverifiable group, never
2144/// its claim.
2145///
2146/// # Errors
2147///
2148/// Everything [`open_progressive_pull`] raises, plus [`HashMismatch`] for a
2149/// paid-but-corrupt delivery (including a non-empty root claimed against an empty
2150/// blob, #1054), the typed pull faults [`PullReader`] stashes (stall, refusal,
2151/// voucher rejection), a truncated stream, or a short delivery at `finish`.
2152#[cfg(any(test, feature = "test-util"))]
2153#[allow(clippy::too_many_arguments)]
2154async fn fetch_in_memory_once(
2155 endpoint: &Endpoint,
2156 target: EndpointAddr,
2157 ctx: &PoolContext,
2158 ledger: Arc<PoolLedger>,
2159 slash_domain: &Eip712Domain,
2160 expected_signer: Address,
2161 hash: [u8; 32],
2162 namespace_id: [u8; 32],
2163 byte_offset: u64,
2164 timestamp_us: u64,
2165 max_blob_size_bytes: u64,
2166 max_rate_per_mb: u64,
2167 deadlines: PullDeadlines,
2168 on_progress: Option<&ProgressCallback>,
2169) -> anyhow::Result<Bytes> {
2170 let (header, pull) = open_progressive_pull(
2171 endpoint,
2172 target,
2173 ctx,
2174 ledger,
2175 slash_domain,
2176 expected_signer,
2177 hash,
2178 namespace_id,
2179 byte_offset,
2180 timestamp_us,
2181 max_blob_size_bytes,
2182 max_rate_per_mb,
2183 deadlines,
2184 // Whole tail: the in-memory wrapper has no store to compute gaps against.
2185 0,
2186 // One long-lived test runtime: the dial runs on it.
2187 None,
2188 )
2189 .await?;
2190 let total_bytes = header.total_bytes;
2191 // A 0-byte blob (#1054) aligns to an empty range the decoder cannot anchor, so
2192 // the empty stream must be proven against the empty root explicitly rather than
2193 // accepted for an arbitrary one (see [`reject_empty_claim_for_nonempty_root`]).
2194 if total_bytes == 0 {
2195 reject_empty_claim_for_nonempty_root(total_bytes, hash)?;
2196 pull.finish().await?;
2197 return Ok(Bytes::new());
2198 }
2199 let aligned = align_range(byte_offset, 0, total_bytes)
2200 .map_err(|e| anyhow::anyhow!("range alignment: {e}").context(LocalPullFault))?;
2201 let reader = PullReader::new(pull);
2202 let (plaintext, reader) =
2203 decode_to_vec(hash, total_bytes, &aligned, reader, on_progress).await?;
2204 // Wire completeness (the full promised bao wire size arrived before
2205 // `StreamEnd`) and a clean close; the watermark it returns is read back from
2206 // the shared ledger by the caller, so it is not needed here.
2207 reader.into_inner().finish().await?;
2208 // The decoded buffer spans the aligned superset `[fetch_start, total_bytes)`;
2209 // trim back to the caller's requested span. Guard the bound explicitly:
2210 // `Bytes::slice` panics out of range, and a short decode must surface as a
2211 // clean error.
2212 let lead = usize::try_from(byte_offset.saturating_sub(aligned.fetch_start()))?;
2213 let bytes = Bytes::from(plaintext);
2214 if lead > bytes.len() {
2215 anyhow::bail!("decoded range shorter than requested span");
2216 }
2217 Ok(bytes.slice(lead..))
2218}
2219
2220/// Like [`fetch_in_memory_once`], but opens the pull on a caller-owned
2221/// [`WarmConnection`] so the in-memory `stream_fetch_on` wrapper can prove
2222/// connection reuse across hashes (#1848).
2223///
2224/// # Errors
2225///
2226/// The same set as [`fetch_in_memory_once`].
2227#[cfg(any(test, feature = "test-util"))]
2228#[allow(clippy::too_many_arguments)]
2229async fn fetch_in_memory_once_on(
2230 warm: &WarmConnection,
2231 ctx: &PoolContext,
2232 ledger: Arc<PoolLedger>,
2233 slash_domain: &Eip712Domain,
2234 expected_signer: Address,
2235 hash: [u8; 32],
2236 namespace_id: [u8; 32],
2237 byte_offset: u64,
2238 timestamp_us: u64,
2239 max_blob_size_bytes: u64,
2240 max_rate_per_mb: u64,
2241 deadlines: PullDeadlines,
2242 on_progress: Option<&ProgressCallback>,
2243) -> anyhow::Result<Bytes> {
2244 let (header, pull) = open_progressive_pull_on(
2245 warm.connection(),
2246 connection::ConnOwner::Borrowed,
2247 ctx,
2248 ledger,
2249 slash_domain,
2250 expected_signer,
2251 hash,
2252 namespace_id,
2253 byte_offset,
2254 timestamp_us,
2255 max_blob_size_bytes,
2256 max_rate_per_mb,
2257 deadlines,
2258 // Whole tail: the in-memory wrapper has no store to compute gaps against.
2259 0,
2260 )
2261 .await?;
2262 let total_bytes = header.total_bytes;
2263 if total_bytes == 0 {
2264 reject_empty_claim_for_nonempty_root(total_bytes, hash)?;
2265 pull.finish().await?;
2266 return Ok(Bytes::new());
2267 }
2268 let aligned = align_range(byte_offset, 0, total_bytes)
2269 .map_err(|e| anyhow::anyhow!("range alignment: {e}").context(LocalPullFault))?;
2270 let reader = PullReader::new(pull);
2271 let (plaintext, reader) =
2272 decode_to_vec(hash, total_bytes, &aligned, reader, on_progress).await?;
2273 reader.into_inner().finish().await?;
2274 let lead = usize::try_from(byte_offset.saturating_sub(aligned.fetch_start()))?;
2275 let bytes = Bytes::from(plaintext);
2276 if lead > bytes.len() {
2277 anyhow::bail!("decoded range shorter than requested span");
2278 }
2279 Ok(bytes.slice(lead..))
2280}
2281
2282/// Feed a bao interleaved wire stream through the verifying decoder, appending
2283/// each verified leaf to an in-memory buffer, and return the buffer plus the
2284/// reader for the caller to finish. Every chunk group is checked against the
2285/// content-hash root `root` as it is decoded, so a corrupt group — at any offset,
2286/// including a resumed tail — is rejected at that group without needing earlier
2287/// bytes or the rest of the stream (ADR 038 §Receive side).
2288///
2289/// The decoded buffer spans the chunk-group-aligned **superset**
2290/// `[aligned.fetch_start(), aligned.fetch_end())` the server serves (a bao proof
2291/// anchors whole 16 KiB groups); the caller trims the lead. The buffer is grown
2292/// leaf by leaf rather than pre-sized: `aligned` derives from the peer's
2293/// unverified `total_bytes`, so a capacity hint would let an inflated claim drive
2294/// an over-allocation before a single byte verifies.
2295///
2296/// `on_progress`, when set, is called with `(content_position, total_bytes)` after
2297/// each verified leaf lands — the same content-byte unit [`driver::drive`] reports.
2298///
2299/// Same loop shape as [`ClientRangedStore::ingest_stream`], minus the durable
2300/// checkpointing; a typed fault the reader stashed ([`StashedFault`]) takes
2301/// precedence over the decoder's own complaint, and a decode failure is
2302/// classified by [`sink::classify_decode_error`] ([`HashMismatch`] for a
2303/// verification failure, a truncation error for a short stream).
2304///
2305/// # Errors
2306///
2307/// The reader's stashed typed fault, [`HashMismatch`], or a truncation / IO fault.
2308#[cfg(any(test, feature = "test-util"))]
2309async fn decode_to_vec<R>(
2310 root: [u8; 32],
2311 total_bytes: u64,
2312 aligned: &AlignedRange,
2313 reader: R,
2314 on_progress: Option<&ProgressCallback>,
2315) -> anyhow::Result<(Vec<u8>, R)>
2316where
2317 R: iroh_io::AsyncStreamReader + StashedFault + Send,
2318{
2319 let tree = BaoTree::new(total_bytes, IROH_BLOCK_SIZE);
2320 let root = blake3::Hash::from_bytes(root);
2321 let mut decoder = ResponseDecoder::new(root, aligned.chunk_ranges().clone(), tree, reader);
2322 let mut out = Vec::new();
2323 loop {
2324 match decoder.next().await {
2325 ResponseDecoderNext::More((rest, Ok(BaoContentItem::Leaf(leaf)))) => {
2326 out.extend_from_slice(&leaf.data);
2327 if let Some(cb) = on_progress {
2328 let position = aligned
2329 .fetch_start()
2330 .saturating_add(u64::try_from(out.len()).unwrap_or(u64::MAX));
2331 cb(position, total_bytes);
2332 }
2333 decoder = rest;
2334 }
2335 ResponseDecoderNext::More((rest, Ok(BaoContentItem::Parent(_)))) => decoder = rest,
2336 ResponseDecoderNext::More((rest, Err(decode_err))) => {
2337 let mut r = rest.finish();
2338 if let Some(fault) = r.take_fault() {
2339 return Err(fault);
2340 }
2341 return Err(sink::classify_decode_error(decode_err));
2342 }
2343 ResponseDecoderNext::Done(mut r) => {
2344 if let Some(fault) = r.take_fault() {
2345 return Err(fault);
2346 }
2347 return Ok((out, r));
2348 }
2349 }
2350 }
2351}
2352
2353/// The security-critical core shared by every "did WE sign this?" check: does `signature` recover
2354/// to `expected` over `voucher` under `domain`?
2355///
2356/// A node's claim about a lane's watermark arrives over an unauthenticated application-level
2357/// message — a mid-stream `StreamError::VoucherRejected` on the fetch path (#1042) — so the claimed
2358/// `amount`/`bytes_delivered` are attacker-controllable on the wire. Without this check a malicious
2359/// or buggy upstream could hand back an inflated watermark and have the client act on it: `reseed`
2360/// its ledger and then sign a voucher for the echoed `amount + delta` on the retried pull. That is
2361/// a voucher this client genuinely holds the key to sign and the node can redeem on-chain up to the
2362/// deposit, draining the pool while delivering ~nothing.
2363///
2364/// The client's OWN last-accepted voucher signature closes that hole: a node can only echo back a
2365/// signature the client itself produced, so a tuple that verifies is by construction one this client
2366/// already committed to. That the node's stored signature always matches its stored tuple is the
2367/// write-side invariant of the seller-side lane state, which writes `last_amount`/`last_bytes` and
2368/// the accepted signature together and only after verifying the signature against the lane's pinned
2369/// capability `signer`. A signature that does not recover to `expected` is treated as a hostile or
2370/// corrupt echo, never as evidence.
2371///
2372/// Callers MUST verify over the exact tuple they are about to adopt as their new committed baseline
2373/// — never a tuple derived from it — so there is no window in which the proof covers one state and
2374/// the action commits another. Taking the whole [`Voucher`] rather than its fields loose is what
2375/// lets a caller verify and then act on *the same value*.
2376#[must_use]
2377pub(crate) fn voucher_signed_by(
2378 voucher: &Voucher,
2379 signature: &[u8],
2380 expected: Address,
2381 domain: &Eip712Domain,
2382) -> bool {
2383 let Ok(signature) = Signature::try_from(signature) else {
2384 return false;
2385 };
2386 SignedVoucher {
2387 voucher: voucher.clone(),
2388 signature,
2389 }
2390 .recover_signer(domain)
2391 .is_ok_and(|recovered| recovered == expected)
2392}
2393
2394/// Extract a resumable, AUTHENTICATED [`WatermarkBundle`] from a pull error, or `None` if the
2395/// error is not an `UpstreamVoucherRejected`, its reason is not
2396/// [`VoucherRejectReason::is_watermark_gated`], it carries no bundle, the bundle fails
2397/// [`WatermarkBundle::validate`] (malformed `last_signature` length), or either of the two
2398/// security-critical checks fails: `last_signature` must recover to this client's OWN
2399/// voucher-signing address over the bundle's anchor and chain section, and the bundle's `tip`
2400/// must PROVE its `verified_index`, by hashing forward to the root that signature covers.
2401///
2402/// Both halves of the bundle are folded into money a resuming signer signs, so both have to be
2403/// evidence. The anchor half is the client's own signature ([`voucher_signed_by`]; see its doc
2404/// for why acting on an unverified watermark is a channel-draining hole rather than a robustness
2405/// nicety). The chain half carries no signature at all, which is what makes the tip check the
2406/// only thing standing between an inflated `verified_index` and a client that signs it.
2407pub(crate) fn resumable_watermark<'a>(
2408 err: &'a anyhow::Error,
2409 ctx: &PoolContext,
2410) -> Option<&'a WatermarkBundle> {
2411 let rejected = err.downcast_ref::<UpstreamVoucherRejected>()?;
2412 if !rejected.reason.is_watermark_gated() {
2413 return None;
2414 }
2415 let bundle = rejected.bundle.as_ref()?;
2416 if bundle.validate().is_err() {
2417 return None;
2418 }
2419 let claimed = Voucher {
2420 pool_id: ctx.pool_id,
2421 signer: ctx.client_signer.address(),
2422 provider: ctx.provider,
2423 amount: U256::from(bundle.amount),
2424 bytes_delivered: U256::from(bundle.bytes_delivered),
2425 chain_root: B256::from(bundle.chain_root),
2426 chunk_price: U256::from(bundle.chunk_price),
2427 };
2428 if !voucher_signed_by(
2429 &claimed,
2430 &bundle.last_signature,
2431 ctx.client_signer.address(),
2432 &ctx.voucher_domain,
2433 ) {
2434 return None;
2435 }
2436 frontier_is_proved(bundle).then_some(bundle)
2437}
2438
2439/// How [`heal_watermark_desync`] resolved a rejection's authenticated watermark.
2440#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2441pub(crate) enum Healed {
2442 /// The bundle was AHEAD of our committed watermark: the node holds a voucher
2443 /// we lost. [`PoolLedger::reseed`] moved us up to it.
2444 Reseeded,
2445 /// The bundle was BEHIND our committed watermark on `amount`, and the
2446 /// ledger moved to it: on `Underpaid`, we hold vouchers the node never
2447 /// accepted ([`PoolLedger::rebase`]); on `BytesRegression`, the bundle is
2448 /// ahead of us on bytes, because the lane resumed below a voucher the node
2449 /// holds ([`PoolLedger::rebase_ahead_on_bytes`]).
2450 Rebased,
2451 /// The ledger already covers this rejection's watermark: an `Underpaid` or
2452 /// `BytesRegression` for a voucher signed before the latest rebase, or an
2453 /// `UnderFold`, `AmountRegression` or `BytesRegression` whose bundle the
2454 /// ledger has reached. The watermark
2455 /// did not move, and the pull retries from the ledger. An `UnderFold` that
2456 /// no heal has taken yet also retires the live chain the node refused
2457 /// ([`PoolLedger::retire_unadopted_chain`]), so the retry opens a fresh one.
2458 Stale,
2459}
2460
2461/// The authenticated watermark a voucher rejection carries, as the cumulative a
2462/// ledger re-anchors to, or `None` when [`resumable_watermark`] refuses it.
2463#[must_use]
2464pub(crate) fn rejection_watermark(err: &anyhow::Error, ctx: &PoolContext) -> Option<Cumulative> {
2465 resumable_watermark(err, ctx).map(Cumulative::from)
2466}
2467
2468/// Heal the watermark desync a voucher rejection reports, from `watermark` — the
2469/// authenticated bundle [`rejection_watermark`] extracted — and say how, or
2470/// `None` when it proves no desync and the caller must surface the real error.
2471///
2472/// A bundle ahead of our committed watermark reseeds, whatever the reason. A
2473/// bundle that does not advance our amount rebases on
2474/// [`VoucherRejectReason::Underpaid`], and on a
2475/// [`VoucherRejectReason::BytesRegression`] whose bundle is ahead of us on
2476/// bytes (see [`heal_bytes_regression`]). A rebase applies only to a voucher
2477/// signed under the ledger's current generation; see [`PoolLedger::rebase`].
2478/// On [`VoucherRejectReason::UnderFold`] or
2479/// [`VoucherRejectReason::AmountRegression`] it retries without moving the
2480/// ledger (below). On every other reason it proves no desync: the node attaches
2481/// a bundle to every watermark-gated rejection once a voucher is accepted, so an
2482/// exhausted lane echoes our own watermark straight back.
2483///
2484/// An `UnderFold` or `AmountRegression` whose folded bundle does not advance our
2485/// amount splits two ways. Both reasons say a proof trailed the node's
2486/// watermark. When our ledger already covers the bundle on both axes, the
2487/// stream retries ([`Healed::Stale`]): either a sibling stream took the same
2488/// rejection and healed first, or the ledger metered reveals under a root the
2489/// node refused, which an `UnderFold` retires. When it covers the amount but
2490/// not the bytes, the ledger paid the node's whole claim but signed fewer bytes
2491/// than the node holds. No bundle can heal that; it is terminal and logged at
2492/// `warn!`.
2493pub(crate) async fn heal_watermark_desync(
2494 err: &anyhow::Error,
2495 watermark: Cumulative,
2496 ledger: &PoolLedger,
2497) -> Option<Healed> {
2498 if ledger.reseed(watermark) {
2499 tracing::info!(
2500 amount = %watermark.amount,
2501 bytes = %watermark.bytes,
2502 "voucher rejection carried a watermark ahead of ours; reseeded"
2503 );
2504 return Some(Healed::Reseeded);
2505 }
2506 let rejected = err.downcast_ref::<UpstreamVoucherRejected>()?;
2507 if matches!(
2508 rejected.reason,
2509 VoucherRejectReason::UnderFold | VoucherRejectReason::AmountRegression
2510 ) {
2511 return already_covered(rejected.reason, watermark, ledger).await;
2512 }
2513 match rejected.reason {
2514 VoucherRejectReason::Underpaid => {
2515 let outcome = ledger.rebase(watermark, rejected.proof_generation).await;
2516 rebase_outcome(
2517 rejected.reason,
2518 outcome,
2519 watermark,
2520 rejected.proof_generation,
2521 )
2522 }
2523 VoucherRejectReason::BytesRegression => {
2524 heal_bytes_regression(watermark, rejected.proof_generation, ledger).await
2525 }
2526 _ => None,
2527 }
2528}
2529
2530/// Resolve a `BytesRegression` whose bundle does not advance our amount.
2531///
2532/// A bundle ahead of us on bytes is our own voucher that the node holds and
2533/// our ledger does not. The lane resumed from a lower anchor, typically the
2534/// chain watermark after the payer's store lost the lane, while the node holds
2535/// an unredeemed voucher above it. The ledger priced its spans from that lower
2536/// anchor, so its amount passed the node's anchor while its bytes still
2537/// trailed it. The rebase moves the ledger to the node's anchor
2538/// ([`PoolLedger::rebase_ahead_on_bytes`]). The payer never pays more than it
2539/// signed for, because the anchor is its own voucher.
2540///
2541/// A bundle the ledger already covers on both axes retries from the ledger
2542/// ([`Healed::Stale`]). The rejected voucher trailed the ledger: a sibling
2543/// stream healed first, or a sibling voucher committed past the node's anchor,
2544/// or the voucher was signed before the latest rebase. Every voucher the
2545/// ledger signs next is at or above the node's anchor on both axes. A
2546/// `BytesRegression` with no bundle is the single-signer fault that no heal
2547/// takes; it never reaches this function.
2548async fn heal_bytes_regression(
2549 watermark: Cumulative,
2550 proof_generation: Option<u64>,
2551 ledger: &PoolLedger,
2552) -> Option<Healed> {
2553 let outcome = ledger
2554 .rebase_ahead_on_bytes(watermark, proof_generation)
2555 .await;
2556 if outcome != Rebase::Refused {
2557 return rebase_outcome(
2558 VoucherRejectReason::BytesRegression,
2559 outcome,
2560 watermark,
2561 proof_generation,
2562 );
2563 }
2564 let committed = ledger.committed();
2565 if committed.amount >= watermark.amount && committed.bytes >= watermark.bytes {
2566 tracing::debug!(
2567 bundle_amount = %watermark.amount,
2568 bundle_bytes = %watermark.bytes,
2569 committed_amount = %committed.amount,
2570 committed_bytes = %committed.bytes,
2571 ?proof_generation,
2572 "bytes-regression rejection carried a watermark our ledger already covers; \
2573 retrying from the ledger"
2574 );
2575 } else {
2576 // The bundle is ahead of us on amount, which `reseed` refused a moment
2577 // ago: a rewind landed in between. The retry's next rejection carries
2578 // the same bundle, and the reseed takes it then.
2579 tracing::warn!(
2580 bundle_amount = %watermark.amount,
2581 bundle_bytes = %watermark.bytes,
2582 committed_amount = %committed.amount,
2583 committed_bytes = %committed.bytes,
2584 ?proof_generation,
2585 "bytes-regression rejection carried a watermark ahead of our ledger on amount \
2586 that neither a reseed nor a rebase took; retrying"
2587 );
2588 }
2589 Some(Healed::Stale)
2590}
2591
2592/// Resolve a trailing-proof rejection (`UnderFold` or `AmountRegression`) whose
2593/// folded bundle does not advance our amount. When our ledger covers its bytes
2594/// too, the stream retries. Otherwise the ledger covers the amount but not the
2595/// bytes, which no bundle can heal.
2596///
2597/// A covering ledger is in one of two states. Either a sibling stream took the
2598/// same rejection and healed the lane first, or the ledger meters a root the
2599/// node refused to adopt. The second state follows an `UnderFold` on a fresh
2600/// root: the stream released reveals under it before the rejection landed, and
2601/// only the last one rewound. A retry would re-anchor under that root and draw
2602/// the same rejection, so an `UnderFold` retires it
2603/// ([`PoolLedger::retire_unadopted_chain`]), unless a heal has already taken
2604/// this bundle.
2605async fn already_covered(
2606 reason: VoucherRejectReason,
2607 watermark: Cumulative,
2608 ledger: &PoolLedger,
2609) -> Option<Healed> {
2610 let committed = ledger.committed();
2611 if committed.bytes < watermark.bytes {
2612 tracing::warn!(
2613 bundle_amount = %watermark.amount,
2614 bundle_bytes = %watermark.bytes,
2615 committed_amount = %committed.amount,
2616 committed_bytes = %committed.bytes,
2617 "trailing-proof rejection carried a watermark our amount covers but our bytes \
2618 do not; the ledger paid the amount but not the bytes"
2619 );
2620 return None;
2621 }
2622 let retired = if reason == VoucherRejectReason::UnderFold {
2623 ledger.retire_unadopted_chain(watermark).await
2624 } else {
2625 None
2626 };
2627 log_covered(watermark, committed, retired);
2628 Some(Healed::Stale)
2629}
2630
2631/// Log how [`already_covered`] resolved a rejection whose watermark our
2632/// `committed` ledger covers, and the root it `retired`, if any.
2633fn log_covered(watermark: Cumulative, committed: Cumulative, retired: Option<B256>) {
2634 if let Some(root) = retired {
2635 tracing::info!(
2636 retired_root = %root,
2637 bundle_amount = %watermark.amount,
2638 bundle_bytes = %watermark.bytes,
2639 committed_amount = %committed.amount,
2640 committed_bytes = %committed.bytes,
2641 "under-fold rejection: the node never adopted our live chain, and our ledger \
2642 already covers its watermark; folded the ledger and retired the chain, so the \
2643 retry opens a fresh one"
2644 );
2645 } else if committed == watermark {
2646 tracing::debug!(
2647 amount = %watermark.amount,
2648 bytes = %watermark.bytes,
2649 "trailing-proof rejection already healed by a sibling stream; retrying"
2650 );
2651 } else {
2652 tracing::debug!(
2653 bundle_amount = %watermark.amount,
2654 bundle_bytes = %watermark.bytes,
2655 committed_amount = %committed.amount,
2656 committed_bytes = %committed.bytes,
2657 "trailing-proof rejection carried a watermark our ledger is already past; \
2658 retrying from the ledger"
2659 );
2660 }
2661}
2662
2663/// Log what a [`PoolLedger::rebase`] did and say how the heal resolved.
2664fn rebase_outcome(
2665 reason: VoucherRejectReason,
2666 outcome: Rebase,
2667 watermark: Cumulative,
2668 proof_generation: Option<u64>,
2669) -> Option<Healed> {
2670 match outcome {
2671 Rebase::Rebased { from } => {
2672 tracing::warn!(
2673 ?reason,
2674 from_amount = %from.amount,
2675 from_bytes = %from.bytes,
2676 to_amount = %watermark.amount,
2677 to_bytes = %watermark.bytes,
2678 "upstream refused our vouchers: the watermark it accepted differs from our \
2679 lane's; rebased to it"
2680 );
2681 Some(Healed::Rebased)
2682 }
2683 Rebase::Stale => {
2684 tracing::debug!(
2685 ?reason,
2686 ?proof_generation,
2687 "rejection of a voucher signed before the latest rebase; retrying from the \
2688 healed anchor"
2689 );
2690 Some(Healed::Stale)
2691 }
2692 Rebase::Refused => None,
2693 }
2694}
2695
2696/// Whether the bundle's `tip` proves the `verified_index` it reports:
2697/// `keccak^verified_index(tip) == chain_root`.
2698///
2699/// A bundle reports two things and the resuming signer folds BOTH into the amount it re-signs.
2700/// The anchor half is covered by the client's own signature. The chain half is covered by
2701/// nothing — `verified_index` is a number the node writes, and the signature it echoes is over a
2702/// voucher that contains no index. A node that reported 255 where the client released 3 would
2703/// otherwise collect `252 × chunk_price` for chunks it never delivered, from a client that signed
2704/// for them itself.
2705///
2706/// The tip closes that, and it is the only thing that can: a preimage is unforgeable, so the
2707/// deepest one the node can present is the deepest one it was actually GIVEN, and hashing it
2708/// forward to a root the client's own signature commits to turns `verified_index` from a claim
2709/// into a proof. The walk is `verified_index` keccaks against local state — no signer, no round
2710/// trip.
2711///
2712/// `verified_index == 0` folds nothing, so it needs no proof and carries no tip (the node sends
2713/// all-zero). That is the never-metered and sealed case, and the only one where an all-zero tip
2714/// is legitimate.
2715#[must_use]
2716fn frontier_is_proved(bundle: &WatermarkBundle) -> bool {
2717 bundle.verified_index == 0
2718 || decdn_incentive::chain::verify_forward(
2719 B256::from(bundle.tip),
2720 bundle.verified_index,
2721 B256::from(bundle.chain_root),
2722 )
2723}
2724
2725/// Whether a failed open of a bounded range could mean only that the range's
2726/// START is past the blob's end: the size it was cut from was wrong. An end
2727/// past the blob clamps and serves rather than failing, so this fires only on
2728/// a start with no bytes to serve.
2729///
2730/// A holder refuses such a range before it signs (`RangeNotSatisfiable`, which
2731/// reaches the wire as [`StreamError::Declined`]). That refusal names only the
2732/// class, so it removes the holder for this fetch and does not qualify here. A
2733/// relay signs its own size instead, and the range then fails to align against
2734/// it here ([`decdn_bao_range::RangeVerifyError::RangeOutOfBounds`] or
2735/// [`ResumeOffsetPastEnd`]). A caller that cut the range from an unsigned size
2736/// asks for the whole blob on these, and treats every other failure as the
2737/// open's own outcome (#2063).
2738#[must_use]
2739#[doc(hidden)]
2740pub fn is_range_past_end(err: &anyhow::Error) -> bool {
2741 err.downcast_ref::<ResumeOffsetPastEnd>().is_some()
2742 || err.chain().any(|cause| {
2743 matches!(
2744 cause.downcast_ref::<decdn_bao_range::RangeVerifyError>(),
2745 Some(decdn_bao_range::RangeVerifyError::RangeOutOfBounds { .. })
2746 )
2747 })
2748}
2749
2750/// The wire-byte bound for a fetch of `[byte_offset, byte_offset + byte_len)`
2751/// (`byte_len == 0` meaning "to end") of a `total_bytes` blob: the bao-encoded
2752/// size of the chunk-group-aligned range (content plus interleaved proof, ADR
2753/// 038 §Payment metering), exactly the byte count the server emits. The server
2754/// widens the request to enclosing 16 KiB groups and clamps an end past the
2755/// blob to the blob's end (a claimed size is a hint); `total_bytes` here is the response
2756/// header's signed size, so [`align_range_clamped`] /
2757/// [`AlignedRange::wire_len`](decdn_bao_range::AlignedRange::wire_len)
2758/// reproduce the same clamp the server applied, keeping the two sides in
2759/// lock-step. The one site every pull derives its wire bound (and its
2760/// received-byte ceiling) through. A range with no byte below `total_bytes`
2761/// faults with [`RangeVerifyError::RangeOutOfBounds`] (see
2762/// [`is_range_past_end`]). Every input but the offset comes from the peer's
2763/// signed size or the caller's own cap, so the fault is never marked
2764/// [`LocalPullFault`].
2765///
2766/// [`RangeVerifyError::RangeOutOfBounds`]: decdn_bao_range::RangeVerifyError::RangeOutOfBounds
2767fn aligned_wire_len(byte_offset: u64, byte_len: u64, total_bytes: u64) -> anyhow::Result<u64> {
2768 let aligned = align_range_clamped(byte_offset, byte_len, total_bytes)
2769 .map_err(|e| anyhow::Error::new(e).context("range alignment"))?;
2770 Ok(aligned.wire_len())
2771}
2772
2773/// The wire bound of an open of `[byte_offset, byte_offset + byte_len)`
2774/// (`byte_len == 0` meaning "to end") against the size `total_bytes` the peer
2775/// signed ([`aligned_wire_len`]). A range that asks for bytes and starts at or
2776/// past that size is refused first, with [`ResumeOffsetPastEnd`]: an offset
2777/// past a resumed blob's end, or a size of zero signed for a bounded open. The
2778/// open of the empty blob, `(0, 0)` against a size of zero, passes.
2779fn served_wire_len(byte_offset: u64, byte_len: u64, total_bytes: u64) -> anyhow::Result<u64> {
2780 if total_bytes <= byte_offset && (byte_offset > 0 || byte_len > 0) {
2781 return Err(anyhow::Error::new(ResumeOffsetPastEnd {
2782 total_bytes,
2783 byte_offset,
2784 }));
2785 }
2786 aligned_wire_len(byte_offset, byte_len, total_bytes)
2787}
2788
2789/// How often the throughput floor samples the byte counter: a fraction of the window, so a
2790/// stall is detected within roughly one extra sample period beyond the window, floored at
2791/// 100 ms so a tiny window cannot spin the sampler.
2792pub(crate) fn stall_sample_period(window: Duration) -> Duration {
2793 (window / 8).max(Duration::from_millis(100))
2794}
2795
2796/// How long the receive loop waits for a terminal message after a voucher write
2797/// fails (below). The node writes `StreamEnd` (or a `StreamError`) *before* the
2798/// teardown that stops our send, so the terminal signal is already in flight and
2799/// arrives at once; the bound only stops a peer that stops our send and then goes
2800/// silent from pinning this recovery read. It is an error-path bound, not a
2801/// steady-state one.
2802const TERMINAL_AFTER_WRITE_TIMEOUT: Duration = Duration::from_secs(5);
2803
2804/// How long one phase of a leg may run before the leg logs it at debug
2805/// ([`log_proof_stall`]). A node stops a stream whose proof it has waited on
2806/// for 10 s, so this names the slow phase before that happens.
2807const PROOF_STALL_DEBUG_AFTER: Duration = Duration::from_secs(5);
2808
2809/// Log at debug that `phase` of one leg took `elapsed`, once it reaches
2810/// [`PROOF_STALL_DEBUG_AFTER`]. Each phase names one place a leg can stall:
2811/// `unpolled` (the caller did not read the stream, so a proof the node waits
2812/// for was not sent), `pay` (waiting for the lane's ledger, signing and
2813/// writing the proof), `first_frame` (a fresh leg waited from its open for
2814/// its first frame, any time before the caller's first read included), or
2815/// `read` (the leg waited for bytes). A `read` stall
2816/// with `unproved > 0` can be the node waiting for a proof this leg does not
2817/// yet owe; with `unproved == 0` the leg owes nothing and the node is slow to
2818/// send. `unpolled` and `pay` are logged when the phase ends; `first_frame`
2819/// and `read` are logged while the leg still waits.
2820fn log_proof_stall(
2821 pull: (&dyn std::fmt::Display, [u8; 32], u64),
2822 cumulative: u64,
2823 unproved: u64,
2824 phase: &'static str,
2825 elapsed: Duration,
2826) {
2827 if elapsed < PROOF_STALL_DEBUG_AFTER {
2828 return;
2829 }
2830 let (peer, hash, byte_offset) = pull;
2831 tracing::debug!(
2832 peer = %peer,
2833 hash = %blake3::Hash::from_bytes(hash).to_hex(),
2834 byte_offset,
2835 cumulative,
2836 unproved,
2837 phase,
2838 elapsed_ms = elapsed.as_millis(),
2839 "a leg phase ran past {PROOF_STALL_DEBUG_AFTER:?}"
2840 );
2841}
2842
2843/// Header fields from the upstream `StreamResponse`, surfaced by
2844/// [`open_progressive_pull`] before the first chunk so the fused serve path
2845/// (#856) knows `total_bytes` up front — it must sign its OWN downstream
2846/// `StreamResponse` (which commits to a `total_bytes`) before forwarding a byte.
2847#[derive(Debug, Clone, Copy)]
2848#[doc(hidden)]
2849pub struct UpstreamPullHeader {
2850 /// Whole-blob size the upstream promised (echoed into our downstream
2851 /// `StreamResponse`).
2852 pub total_bytes: u64,
2853 /// Upstream rate; informational for the caller (the buyer pays it inside
2854 /// [`UpstreamPull::next_chunk`]).
2855 pub rate_per_mb: u64,
2856 /// Upstream metering quantum in bytes: the buyer releases one hash-chain
2857 /// preimage per chunk as they arrive. Always `CHUNK_BYTES` on a paid
2858 /// upstream — it is a protocol constant, not a negotiated value — and `0`
2859 /// on an unpaid source, which is the only reason it is carried at all.
2860 pub interval_bytes: u64,
2861}
2862
2863/// A live, progressive `cdn/client/v1` pull (#856) — the ONE receive loop for
2864/// paid delivery. Opened by [`open_progressive_pull`] (which has already done the
2865/// handshake and verified the response), driven chunk-by-chunk via
2866/// [`Self::next_chunk`], and closed by [`Self::finish`] (a wire-completeness
2867/// check — integrity is verified per bao chunk group by the consumer's decoder
2868/// as the bytes land, not by a whole-blob re-hash) or [`Self::abort`].
2869///
2870/// It yields each chunk to the caller — a [`sink::PullReader`] feeding a
2871/// verifying decoder into the ranged store, the cache admit, or (`test-util`)
2872/// memory — instead of buffering the whole blob, and pays the upstream per chunk
2873/// of wire bytes that decoder has verified ([`Self::mark_verified`]). This is what
2874/// lets the serving node cap its speculative exposure to a bounded window rather
2875/// than fronting the entire upstream cost before any downstream voucher arrives,
2876/// and what keeps a lying peer's bill at the bytes before its first unverifiable
2877/// chunk group.
2878///
2879/// The acked voucher watermark is OWNED here (not threaded as a `&mut`
2880/// out-param) and read back via [`Self::progress`] /
2881/// returned by `finish`/`abort` — the caller (`node_origin`) persists it. On any
2882/// exit, the caller MUST call `progress`/`finish`/`abort` to recover the
2883/// watermark for `record_progress` (#852); a [`Drop`] guard closes the
2884/// connection if none ran, but cannot return the watermark, so the obligation
2885/// stands.
2886///
2887/// **Deadlines.** The *open* (handshake) phase is bounded inside
2888/// [`open_progressive_pull`] by `PullDeadlines::open`, via the shared `open_stream`
2889/// helper — NOT by the caller (#1134). `decdn-node` does additionally wrap the open
2890/// in its per-candidate budget, but that is belt-and-braces: a bound left to the
2891/// caller is a bound a caller can forget.
2892///
2893/// The streaming `next_chunk`/`finish` reads are bounded here, by a THROUGHPUT FLOOR
2894/// (#1797): bytes are counted off the QUIC stream sub-frame, and a read is abandoned when
2895/// the bytes across the trailing `window` fall below `floor_bps · window`. The detector
2896/// persists across reads, so its window spans the whole stream rather than one call, and a
2897/// self-inflicted window-pacing pause is excluded from the window.
2898///
2899/// A wall clock would be the wrong bound to reach for here: this type exists to
2900/// stream blobs of any size, so any fixed deadline would either kill a healthy
2901/// large transfer or be too loose to catch a dead one. A byte-throughput floor is
2902/// indifferent to size and link speed, and frame-size-independent.
2903#[doc(hidden)]
2904pub struct UpstreamPull {
2905 conn: iroh::endpoint::Connection,
2906 /// What this pull owns of its connection: a one-shot dial it closes on its
2907 /// terminal method, a caller-owned [`WarmConnection`] it borrows, or a
2908 /// [`Connections`] map's connection it holds and unpins on a transport fault.
2909 owner: connection::ConnOwner,
2910 send: SendStream,
2911 recv: RecvStream,
2912 ctx: PoolContext,
2913 /// The channel's voucher ledger, SHARED with every other concurrent pull on this
2914 /// channel (#1145 review). Not a per-pull one-shot: that made two concurrent pulls
2915 /// each compute the same next cumulative `amount` independently and collide
2916 /// (`AmountRegression`).
2917 ledger: Arc<PoolLedger>,
2918 hash: [u8; 32],
2919 /// The requested content range's start, kept for the stall diagnostics.
2920 byte_offset: u64,
2921 /// The requested content range's length (`0` = to the end), kept for the
2922 /// stall diagnostics.
2923 byte_len: u64,
2924 rate_per_mb: u64,
2925 /// This stream's chain anchor — which epoch it has told the upstream about.
2926 meter: StreamMeter,
2927 /// Throughput-floor window for every streaming read (#1797). Names the `after` on a
2928 /// [`PullStalled`] / [`PullTimeout`]; the floor itself owns the window and floor rate.
2929 window: Duration,
2930 /// Bytes read off the QUIC stream so far, tallied sub-frame by the `ProgressReader`
2931 /// each `next_chunk`/`finish` read wraps `recv` in. The floor reads this counter.
2932 progress_counter: Arc<std::sync::atomic::AtomicU64>,
2933 /// The byte-progress stall detector, persisted across reads so its window spans the
2934 /// whole stream, not one `next_chunk` call.
2935 floor: progress::ThroughputFloor,
2936 /// The floor's sampling tick.
2937 sampler: tokio::time::Interval,
2938 /// Promised **wire** bytes for this stream: the bao-encoded size of the
2939 /// chunk-group-aligned range (content plus interleaved proof, ADR 038), not
2940 /// the content-byte remainder. Bounds the receive loop and the closing voucher.
2941 expected_wire_bytes: u64,
2942 /// The buyer's received-byte ceiling (#1895) as a WIRE bound: the wire size of a
2943 /// ceiling-sized blob's content from this stream's offset, so `next_chunk` can
2944 /// enforce `max_blob_size_bytes` on the bytes that ACTUALLY arrive without
2945 /// decoding. `0` = unlimited.
2946 max_received_wire: u64,
2947 /// Wire bytes received so far on this stream.
2948 cumulative: u64,
2949 /// Wire bytes the caller's decoder has verified against the root, never
2950 /// above `cumulative`. Payment follows this count, not `cumulative`.
2951 verified: u64,
2952 /// Verified wire bytes not yet covered by a proof.
2953 unproved: u64,
2954 /// `StreamEnd` seen — `next_chunk` returns `None` and `finish` skips the
2955 /// drain.
2956 ended: bool,
2957 /// When `next_chunk` last returned a chunk, so the next call can tell how
2958 /// long the caller left the stream unread ([`log_proof_stall`]).
2959 returned_at: Option<tokio::time::Instant>,
2960 /// When the open returned, so a fresh leg's wait for its first frame is
2961 /// timed from there ([`log_proof_stall`]).
2962 opened_at: tokio::time::Instant,
2963}
2964
2965impl std::fmt::Debug for UpstreamPull {
2966 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2967 f.debug_struct("UpstreamPull")
2968 .field("hash", &blake3::Hash::from_bytes(self.hash))
2969 .field("expected_wire_bytes", &self.expected_wire_bytes)
2970 .field("cumulative", &self.cumulative)
2971 .field("ended", &self.ended)
2972 .finish_non_exhaustive()
2973 }
2974}
2975
2976/// Open a progressive `cdn/client/v1` pull (#856): connect, send the
2977/// [`StreamRequest`], read and verify the signed [`StreamResponse`] (so
2978/// `total_bytes` is known up front), and return its header plus a live
2979/// [`UpstreamPull`] to drive. Zero-rate rejection, `slash_sig` recovery, echoed
2980/// field checks, the buyer's rate ceiling, and the `total_bytes >= byte_offset`
2981/// floor are all enforced BEFORE the first chunk. The `max_blob_size_bytes`
2982/// ceiling is NOT one of them (#1895): the peer's `total_bytes` claim is
2983/// unverified, so the ceiling is enforced on the bytes that ACTUALLY arrive,
2984/// inside [`UpstreamPull::next_chunk`], as [`BlobTooLarge`]. `0` = unlimited.
2985///
2986/// # Errors
2987///
2988/// Connect / transport faults, a refused or zero-rate response, a bad `slash_sig`,
2989/// a mismatched echoed field, a [`RateAboveCeiling`] quote, a
2990/// [`ResumeOffsetPastEnd`] offset, or a resume offset already at/past the size
2991/// ceiling ([`BlobTooLarge`]). The size ceiling otherwise surfaces later, as a
2992/// [`BlobTooLarge`] abort once received bytes cross it.
2993///
2994/// The open stage is bounded by `deadlines.open`, inside the shared `open_stream`
2995/// helper — NOT left to the caller (#1134). `node_origin` additionally wraps this
2996/// call in its per-candidate budget, which is belt-and-braces rather than the sole
2997/// bound. `deadlines.window` and `deadlines.floor_bps` are the throughput floor the returned
2998/// [`UpstreamPull`] carries into every streaming read; `deadlines.hard_cap` is not consulted
2999/// here (the caller owns the streaming lifetime on this path).
3000///
3001/// `ledger` is the CHANNEL's voucher ledger, not this pull's: pass the same
3002/// `Arc<PoolLedger>` to every concurrent pull on one channel, or they will each
3003/// compute the same next cumulative `amount` independently and collide
3004/// (`AmountRegression`, #1145 review). The caller reads what to
3005/// persist from it — including after a drop — via [`PoolLedger::settlement`].
3006///
3007/// `dial_runtime` is the runtime the dial and the connection's QUIC driver run
3008/// on; `None` runs them on the caller's. A caller that drops the runtime it pulls
3009/// on passes a long-lived one — see
3010/// [`PeerSource::with_dial_runtime`](crate::source::PeerSource::with_dial_runtime).
3011///
3012/// Runs inside an `open_progressive_pull` span that covers the dial and the
3013/// signed-response handshake — the time to first byte. Its `hash`, `pool_id`,
3014/// `byte_offset`, `peer` and `local_node_id` fields match the serving node's
3015/// `serve_stream` span, with `peer` and `local_node_id` swapped.
3016#[allow(clippy::too_many_arguments)]
3017#[tracing::instrument(
3018 name = "open_progressive_pull",
3019 skip_all,
3020 fields(
3021 error = tracing::field::Empty,
3022 otel.status_code = tracing::field::Empty,
3023 otel.kind = "client",
3024 peer = %target.id,
3025 local_node_id = %endpoint.id(),
3026 hash = %decdn_protocol::ContentHash::from_bytes(hash),
3027 pool_id = %ctx.pool_id,
3028 byte_offset = byte_offset,
3029 byte_len = byte_len,
3030 )
3031)]
3032#[doc(hidden)]
3033pub async fn open_progressive_pull(
3034 endpoint: &Endpoint,
3035 target: EndpointAddr,
3036 ctx: &PoolContext,
3037 ledger: Arc<PoolLedger>,
3038 slash_domain: &Eip712Domain,
3039 expected_signer: Address,
3040 hash: [u8; 32],
3041 namespace_id: [u8; 32],
3042 byte_offset: u64,
3043 timestamp_us: u64,
3044 max_blob_size_bytes: u64,
3045 max_rate_per_mb: u64,
3046 deadlines: PullDeadlines,
3047 byte_len: u64,
3048 dial_runtime: Option<&tokio::runtime::Handle>,
3049) -> anyhow::Result<(UpstreamPullHeader, UpstreamPull)> {
3050 open_progressive_pull_impl(
3051 ConnSource::Dial {
3052 endpoint,
3053 target,
3054 runtime: dial_runtime,
3055 },
3056 // One-shot dial: the pull owns this connection and closes it on teardown.
3057 connection::ConnOwner::Owned,
3058 ctx,
3059 ledger,
3060 slash_domain,
3061 expected_signer,
3062 hash,
3063 namespace_id,
3064 byte_offset,
3065 timestamp_us,
3066 max_blob_size_bytes,
3067 max_rate_per_mb,
3068 deadlines,
3069 byte_len,
3070 )
3071 .await
3072}
3073
3074/// Like [`open_progressive_pull`], but opens the pull on the warm connection
3075/// `conn` instead of dialling a fresh one (#1848). `owner` names where `conn`
3076/// comes from: a caller-owned [`WarmConnection`] or a [`Connections`] map's
3077/// connection. The warm connection is reused across
3078/// many hashes (one dial, a fresh bi-stream per hash: one stream = one hash, no
3079/// wire change) and stays open when this pull ends, so the pull never closes
3080/// it. A map's connection is held by the pull while it runs, and a transport
3081/// fault on the open or on a later read unpins it from the map
3082/// ([`Connections::invalidate`]).
3083///
3084/// Every other argument behaves exactly as on [`open_progressive_pull`]; see its
3085/// docs. There is no `endpoint`/`target` pair — the warm connection already names
3086/// its peer.
3087#[allow(clippy::too_many_arguments)]
3088#[tracing::instrument(
3089 name = "open_progressive_pull",
3090 skip_all,
3091 fields(
3092 error = tracing::field::Empty,
3093 otel.status_code = tracing::field::Empty,
3094 otel.kind = "client",
3095 peer = %conn.remote_id(),
3096 hash = %decdn_protocol::ContentHash::from_bytes(hash),
3097 pool_id = %ctx.pool_id,
3098 byte_offset = byte_offset,
3099 byte_len = byte_len,
3100 )
3101)]
3102pub(crate) async fn open_progressive_pull_on(
3103 conn: &iroh::endpoint::Connection,
3104 owner: connection::ConnOwner,
3105 ctx: &PoolContext,
3106 ledger: Arc<PoolLedger>,
3107 slash_domain: &Eip712Domain,
3108 expected_signer: Address,
3109 hash: [u8; 32],
3110 namespace_id: [u8; 32],
3111 byte_offset: u64,
3112 timestamp_us: u64,
3113 max_blob_size_bytes: u64,
3114 max_rate_per_mb: u64,
3115 deadlines: PullDeadlines,
3116 byte_len: u64,
3117) -> anyhow::Result<(UpstreamPullHeader, UpstreamPull)> {
3118 let opened = open_progressive_pull_impl(
3119 ConnSource::Reuse(conn),
3120 // A warm connection: leave it open for the next hash.
3121 owner.clone(),
3122 ctx,
3123 ledger,
3124 slash_domain,
3125 expected_signer,
3126 hash,
3127 namespace_id,
3128 byte_offset,
3129 timestamp_us,
3130 max_blob_size_bytes,
3131 max_rate_per_mb,
3132 deadlines,
3133 byte_len,
3134 )
3135 .await;
3136 if let Err(err) = &opened {
3137 owner.unpin_on_fault(err).await;
3138 }
3139 opened
3140}
3141
3142/// The shared body behind [`open_progressive_pull`] (dial) and
3143/// [`open_progressive_pull_on`] (reuse). `owner` records what the pull owns of
3144/// its connection, so the returned [`UpstreamPull`] knows whether its terminal
3145/// method closes the connection (owned, one-shot) or leaves it open for the
3146/// next hash (warm), and whether a read fault unpins it from a map.
3147#[allow(clippy::too_many_arguments)]
3148async fn open_progressive_pull_impl(
3149 source: ConnSource<'_>,
3150 owner: connection::ConnOwner,
3151 ctx: &PoolContext,
3152 ledger: Arc<PoolLedger>,
3153 slash_domain: &Eip712Domain,
3154 expected_signer: Address,
3155 hash: [u8; 32],
3156 namespace_id: [u8; 32],
3157 byte_offset: u64,
3158 timestamp_us: u64,
3159 max_blob_size_bytes: u64,
3160 max_rate_per_mb: u64,
3161 deadlines: PullDeadlines,
3162 // Upper bound on the requested range: `[byte_offset, byte_offset + byte_len)`.
3163 // `0` means "to end" (the whole tail, which every non-gap caller requests).
3164 // A gap-driven caller (`source::PeerSource`, #1608) passes the exact gap
3165 // length so the server scopes both the serve and the payment to it, rather
3166 // than streaming the whole remainder.
3167 byte_len: u64,
3168) -> anyhow::Result<(UpstreamPullHeader, UpstreamPull)> {
3169 let result: anyhow::Result<(UpstreamPullHeader, UpstreamPull)> = async {
3170 let window = deadlines.window;
3171 let floor_bps = deadlines.floor_bps;
3172 let (conn, send, recv, resp, resp_ext) = open_stream(
3173 source,
3174 ctx,
3175 slash_domain,
3176 expected_signer,
3177 hash,
3178 // Node-to-node pull. `NO_NAMESPACE` (0) for a DHT-discovered *holder* — it
3179 // already holds the bytes, so the downstream node needs no namespace (ADR
3180 // 002 §Retrieval by namespace). But a directory-discovered *cold origin* is
3181 // reached with the served request's namespace: it must fill from its own
3182 // backend, and its pull-through authorized-origin gate resolves on that
3183 // namespace (ADR 005 §Namespace routing). The caller passes whichever
3184 // applies.
3185 namespace_id,
3186 byte_offset,
3187 byte_len,
3188 timestamp_us,
3189 deadlines.open,
3190 )
3191 .await?;
3192 if !resp.body.ok {
3193 return Err(UpstreamRefused::open(resp, &resp_ext));
3194 }
3195 // The peer's signed `total_bytes` never drives a refusal (#1895): it is
3196 // peer-controlled and unverified (`StreamResponse::validate()` does not bound
3197 // it), so the `max_blob_size_bytes` ceiling is enforced on the bytes that
3198 // ACTUALLY arrive, inside `UpstreamPull::next_chunk`, not on the claim. Refusing
3199 // on an inflated claim would let a holder centralise a small blob's traffic
3200 // across every finite-ceiling relay; a lie cannot produce bytes that verify
3201 // against the true root, and an honest giant is streamed and paid for only up
3202 // to one ceiling.
3203 // Buyer-side rate ceiling (#1375): refuse an over-ceiling quote before the
3204 // first paid interval, carrying the signed quote out as rate-manipulation
3205 // evidence. `0` = unbounded.
3206 if max_rate_per_mb > 0 && resp.body.rate_per_mb > max_rate_per_mb {
3207 return Err(RateAboveCeiling::over_ceiling(resp, max_rate_per_mb));
3208 }
3209 let rate_per_mb = resp.body.rate_per_mb;
3210 // Wire-byte bound (bao-encoded size of the aligned range), not content bytes:
3211 // the window path forwards this stream verbatim and pays the upstream in wire
3212 // bytes (ADR 038 §Payment metering).
3213 let total_bytes = resp.body.total_bytes;
3214 let expected_wire_bytes = served_wire_len(byte_offset, byte_len, total_bytes)?;
3215 // Received-byte ceiling (#1895), expressed as a WIRE bound so `next_chunk` can
3216 // enforce it without decoding: the wire size of a ceiling-sized blob's content
3217 // from this offset. Enforced on the bytes that ACTUALLY arrive, never on the
3218 // peer's unverified `total_bytes` claim. A resume offset already at/past the
3219 // ceiling means the blob is genuinely oversized — abort before the first chunk.
3220 // `0` = unlimited.
3221 let max_received_wire = if max_blob_size_bytes == 0 {
3222 0
3223 } else if byte_offset >= max_blob_size_bytes {
3224 return Err(anyhow::Error::new(BlobTooLarge {
3225 reached: byte_offset,
3226 ceiling: max_blob_size_bytes,
3227 }));
3228 } else {
3229 aligned_wire_len(byte_offset, 0, max_blob_size_bytes)?
3230 };
3231 let header = UpstreamPullHeader {
3232 total_bytes,
3233 rate_per_mb,
3234 interval_bytes: CHUNK_BYTES,
3235 };
3236 let progress_counter = Arc::new(std::sync::atomic::AtomicU64::new(0));
3237 let floor = progress::ThroughputFloor::new(
3238 progress::FloorConfig { window, floor_bps },
3239 Arc::clone(&progress_counter),
3240 tokio::time::Instant::now(),
3241 );
3242 let mut sampler = tokio::time::interval(stall_sample_period(window));
3243 sampler.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
3244 let pull = UpstreamPull {
3245 window,
3246 progress_counter,
3247 floor,
3248 sampler,
3249 conn,
3250 owner,
3251 send,
3252 recv,
3253 ctx: ctx.clone(),
3254 ledger,
3255 hash,
3256 byte_offset,
3257 byte_len,
3258 rate_per_mb,
3259 meter: StreamMeter::default(),
3260 expected_wire_bytes,
3261 max_received_wire,
3262 cumulative: 0,
3263 verified: 0,
3264 unproved: 0,
3265 ended: false,
3266 returned_at: None,
3267 opened_at: tokio::time::Instant::now(),
3268 };
3269 Ok((header, pull))
3270 }
3271 .await;
3272 record_open_result(&tracing::Span::current(), &result);
3273 result
3274}
3275
3276impl UpstreamPull {
3277 /// Wire bytes this stream will deliver: the bao-encoded size of the
3278 /// chunk-group-aligned range (content plus interleaved proof, ADR 038). The
3279 /// window serve loop uses it as the pull budget `total` — it must be wire
3280 /// bytes, since `pulled`/`served_paid` count forwarded wire bytes.
3281 #[must_use]
3282 pub const fn expected_wire_bytes(&self) -> u64 {
3283 self.expected_wire_bytes
3284 }
3285
3286 /// The watermark to PERSIST on any exit — including an error from `next_chunk`, and
3287 /// including a DROP (#852, #1122).
3288 ///
3289 /// Reads [`PoolLedger::settlement`], not a copied-back field. A field can only be
3290 /// updated on a path that RUNS, and this pull's does not always run: the serve loop
3291 /// drives it as a future on the `accept` task, which is dropped on shutdown or a
3292 /// downstream reset, and `settlement` additionally covers a voucher left in flight when
3293 /// that happened. See the ledger's docs.
3294 #[must_use]
3295 pub fn progress(&self) -> VoucherProgress {
3296 VoucherProgress::from_ledger(&self.ledger, self.ctx.prior_amount)
3297 }
3298
3299 /// Record that the caller's decoder has verified the first `wire` bytes of
3300 /// this stream against the root. The count only moves forward and never
3301 /// passes the bytes received. The newly verified bytes become payable: the
3302 /// next [`Self::next_chunk`], [`Self::finish`] or [`Self::stop`] pays for
3303 /// them, and a byte that never verifies is never paid for.
3304 pub(crate) fn mark_verified(&mut self, wire: u64) {
3305 let wire = wire.min(self.cumulative);
3306 if wire > self.verified {
3307 self.unproved = self.unproved.saturating_add(wire - self.verified);
3308 self.verified = wire;
3309 }
3310 }
3311
3312 /// Pay for the verified bytes no proof covers yet: one reveal per whole
3313 /// chunk, plus one closing signature for the residual once every promised
3314 /// byte has verified. The sends commit optimistically; only a rejection
3315 /// comes back, on a later read.
3316 ///
3317 /// A write failure may only mean the node stopped our send after it
3318 /// finished, or is rejecting us, so the terminal signal it left wins over
3319 /// the opaque write failure ([`Self::terminal_after_write_failure`]).
3320 async fn pay_verified(&mut self) -> anyhow::Result<()> {
3321 let complete = self.verified >= self.expected_wire_bytes;
3322 if self.unproved < CHUNK_BYTES && !(complete && self.unproved > 0) {
3323 return Ok(());
3324 }
3325 let unproved = self.unproved;
3326 // Gate the floor across our own payment: while we owe the covering proof
3327 // the upstream legitimately pauses (ADR 005 §Payment pacing), so that
3328 // pause is self-inflicted, not a sender stall (#1797).
3329 let pay_started = tokio::time::Instant::now();
3330 self.floor.pause(pay_started);
3331 let paid = self.pay_one(unproved, complete).await;
3332 let paid_at = tokio::time::Instant::now();
3333 self.floor.resume(paid_at);
3334 log_proof_stall(
3335 (&self.conn.remote_id(), self.hash, self.byte_offset),
3336 self.cumulative,
3337 unproved,
3338 "pay",
3339 paid_at.saturating_duration_since(pay_started),
3340 );
3341 match paid {
3342 Ok(remaining) => self.unproved = remaining,
3343 // Boxed so this cold error-path future does not enlarge the steady
3344 // receive loop's future (`clippy::large_futures`); the allocation
3345 // only happens on the failure path. A confirmed voucher covers every
3346 // byte this stream verified, so nothing stays unproved.
3347 Err(write_err) => {
3348 if Box::pin(self.terminal_after_write_failure(write_err)).await? {
3349 self.unproved = 0;
3350 }
3351 }
3352 }
3353 Ok(())
3354 }
3355
3356 /// Send one voucher for `delta_bytes` newly verified since the last voucher,
3357 /// through the CHANNEL's ledger — shared with every other concurrent pull on it, so
3358 /// their vouchers are serialized into strict cumulative-amount order rather than
3359 /// colliding. Sends optimistically (#1484): the ack is
3360 /// read back by [`Self::next_chunk`] / [`Self::finish`], not awaited here.
3361 async fn pay_one(&mut self, unproved: u64, complete: bool) -> anyhow::Result<u64> {
3362 let ledger = Arc::clone(&self.ledger);
3363 self.meter
3364 .pay(
3365 &mut self.send,
3366 &self.ctx,
3367 &ledger,
3368 self.rate_per_mb,
3369 unproved,
3370 complete,
3371 )
3372 .await
3373 }
3374
3375 /// Read one message under the persistent throughput floor (#1797). Wraps `recv` in a
3376 /// `ProgressReader` feeding the shared counter, and races the read against
3377 /// the floor's sampling tick: a read slow enough to lose the race is judged, and a
3378 /// sub-floor window aborts with [`PullTimeout`] before the first byte (our own
3379 /// blob-size-dependent budget) or [`PullStalled`] after it — neither scores the peer.
3380 ///
3381 /// A transport fault on a [`Connections`] map's connection (either floor verdict,
3382 /// or a connection that has closed under the read) unpins that connection from
3383 /// the map, so the node's next leg dials again.
3384 async fn read_under_floor(&mut self) -> anyhow::Result<ClientMessage> {
3385 let read = self.read_under_floor_once().await;
3386 if let Err(err) = &read {
3387 self.owner.unpin_on_fault(err).await;
3388 }
3389 read
3390 }
3391
3392 /// [`Self::read_under_floor`] before a transport fault unpins the connection.
3393 async fn read_under_floor_once(&mut self) -> anyhow::Result<ClientMessage> {
3394 let cumulative = self.cumulative;
3395 let window = self.window;
3396 let recv = &mut self.recv;
3397 let sampler = &mut self.sampler;
3398 let floor = &mut self.floor;
3399 let (peer, hash, byte_offset) = (self.conn.remote_id(), self.hash, self.byte_offset);
3400 let unproved = self.unproved;
3401 // A fresh leg's wait runs from its open, any time before the caller's
3402 // first read included, so a first frame that never comes is named
3403 // once, as `first_frame` (#2230).
3404 let (phase, read_started) = if cumulative == 0 {
3405 ("first_frame", self.opened_at)
3406 } else {
3407 ("read", tokio::time::Instant::now())
3408 };
3409 // The stall line runs on its own timer, not on the sampler, whose
3410 // period follows the configurable window and can be later than the
3411 // node's proof timeout.
3412 let read_stall_at = read_started + PROOF_STALL_DEBUG_AFTER;
3413 let mut read_stall_logged = false;
3414 let mut reader = progress::ProgressReader::new(recv, Arc::clone(&self.progress_counter));
3415 // Pin ONE read future and poll it across ticks. `read_client_message` is not
3416 // cancellation-safe — `read_frame` fills a frame with `read_exact`, so dropping the
3417 // future mid-frame loses the bytes already consumed and desynchronises the stream.
3418 // Recreating it per tick would corrupt every frame that spans a tick; the pinned
3419 // future is dropped only when the floor actually aborts (#1797). `biased` cannot
3420 // starve the floor: a ready read arm means a whole frame arrived, which is byte
3421 // progress the counter already holds, so a skipped tick only ever coincides with
3422 // throughput the floor would clear.
3423 let read = read_client_message(&mut reader);
3424 tokio::pin!(read);
3425 loop {
3426 tokio::select! {
3427 biased;
3428 r = &mut read => return r,
3429 _ = sampler.tick() => {
3430 let now = tokio::time::Instant::now();
3431 let last_sample = floor.last_sampled_at();
3432 if let progress::FloorVerdict::Stalled = floor.evaluate(now) {
3433 // What tells a quiet peer from a reader that was not
3434 // polled (#2211): the gap since the previous sample.
3435 tracing::debug!(
3436 peer = %self.conn.remote_id(),
3437 hash = %blake3::Hash::from_bytes(self.hash).to_hex(),
3438 byte_offset = self.byte_offset,
3439 byte_len = self.byte_len,
3440 cumulative,
3441 expected_wire_bytes = self.expected_wire_bytes,
3442 window_bytes = floor.window_bytes(),
3443 unpolled_ms = last_sample
3444 .map(|t| now.saturating_duration_since(t).as_millis()),
3445 window_ms = window.as_millis(),
3446 "upstream pull fell under its throughput floor"
3447 );
3448 return Err(if cumulative == 0 {
3449 anyhow::Error::new(PullTimeout { after: window })
3450 } else {
3451 anyhow::Error::new(PullStalled { after: window })
3452 });
3453 }
3454 }
3455 // A long wait here is the leg waiting on the node, while the
3456 // node may wait on a proof or still be sending its first frame.
3457 () = tokio::time::sleep_until(read_stall_at), if !read_stall_logged => {
3458 read_stall_logged = true;
3459 let waited = read_started.elapsed();
3460 let pull = (&peer as &dyn std::fmt::Display, hash, byte_offset);
3461 log_proof_stall(pull, cumulative, unproved, phase, waited);
3462 }
3463 }
3464 }
3465 }
3466
3467 /// Pay for the bytes the caller's decoder has verified since the last proof
3468 /// ([`Self::mark_verified`]), then read the next `ChunkData` and return it
3469 /// for that decoder (and, on the node's serve leg, to forward downstream).
3470 /// The payment goes first because the node may be waiting on it: a read
3471 /// before it could wait on bytes the node holds back until it is paid.
3472 /// Returns `Ok(None)` on `StreamEnd`.
3473 ///
3474 /// # Errors
3475 ///
3476 /// More bytes than promised, received bytes crossing the buyer's size ceiling
3477 /// ([`BlobTooLarge`], raised before the crossing chunk is paid), a mid-stream `StreamError`
3478 /// (typed [`UpstreamRefused`]), an unexpected message, a [`UpstreamVoucherRejected`] /
3479 /// transport error while paying, or —
3480 /// on the throughput floor — [`PullStalled`] once bytes have flowed, or [`PullTimeout`] if
3481 /// the floor trips before the first byte (`cumulative == 0`). A malformed `ChunkData` is
3482 /// not raised here: an empty payload is rejected at decode by `ChunkData`'s
3483 /// `serde(try_from)` (#1088) and an oversized frame by the framing layer's
3484 /// `MAX_MESSAGE_SIZE` before it allocates, so both surface out of `read_client_message`.
3485 pub async fn next_chunk(&mut self) -> anyhow::Result<Option<Bytes>> {
3486 if self.ended {
3487 return Ok(None);
3488 }
3489 if let Some(returned_at) = self.returned_at.take() {
3490 log_proof_stall(
3491 (&self.conn.remote_id(), self.hash, self.byte_offset),
3492 self.cumulative,
3493 self.unproved,
3494 "unpolled",
3495 returned_at.elapsed(),
3496 );
3497 }
3498 self.pay_verified().await?;
3499 if self.ended {
3500 return Ok(None);
3501 }
3502 // Read exactly one message. In the pool model there is no positive ack to
3503 // consume (acceptance is implicit — continued delivery IS acceptance, ADR
3504 // 005), so every message either carries a chunk, ends the stream, or is a
3505 // mid-stream error/unexpected frame; none is skipped.
3506 {
3507 // Byte-progress bound (#1797): the read is judged by the persistent throughput
3508 // floor, which counts bytes off the QUIC stream sub-frame. A `ChunkData` carries
3509 // at least one byte (#1088 bans empty frames), so every frame is a unit of
3510 // progress. The floor's window spans the whole stream, not this call, and the
3511 // caller's downstream-forward time is not charged against it — the counter only
3512 // moves on bytes the reader pulls.
3513 let msg = self.read_under_floor().await?;
3514 match msg {
3515 ClientMessage::ChunkData(chunk) => {
3516 // The payload is bounded on both sides by construction (#1088): the
3517 // ceiling caps per-frame allocation, and the non-empty floor ties every
3518 // frame to payload, so a peer cannot advance this loop with a run of
3519 // empty frames that move neither `cumulative` nor the voucher
3520 // accounting. This path has no belt-and-braces payload check behind
3521 // that floor, and does not need one now the floor is structural.
3522 let chunk_len = chunk.bytes().len() as u64;
3523 self.cumulative = self.cumulative.saturating_add(chunk_len);
3524 if self.cumulative > self.expected_wire_bytes {
3525 anyhow::bail!(
3526 "server sent {} bytes, more than the {} promised",
3527 self.cumulative,
3528 self.expected_wire_bytes
3529 );
3530 }
3531 // Received-byte ceiling (#1895): enforce the size cap on the bytes
3532 // that ACTUALLY arrive, never on the peer's unverified `total_bytes`
3533 // claim. `max_received_wire` is the wire size of a ceiling-sized
3534 // blob's content, so this is the wire form of "received content >
3535 // ceiling" — distinct from the claim-derived `expected_wire_bytes`
3536 // overrun guard above. Abort BEFORE paying for the chunk that
3537 // crosses it, so the spend stays bounded to roughly one ceiling.
3538 // `0` = unlimited.
3539 if self.max_received_wire > 0 && self.cumulative > self.max_received_wire {
3540 return Err(anyhow::Error::new(BlobTooLarge {
3541 reached: self.cumulative,
3542 ceiling: self.max_received_wire,
3543 }));
3544 }
3545 // No per-chunk hashing here: this stream's bytes are bao wire
3546 // bytes the caller feeds to a verifying decoder — the cache's
3547 // (`admit_bao_stream`), the ranged store's, or the in-memory
3548 // test wrapper's — which checks every chunk group against the
3549 // root as it lands (ADR 038) and reports what it verified
3550 // through `mark_verified`. Only verified bytes are paid for, so
3551 // a peer that sends a bad group bills nothing for it.
3552 self.returned_at = Some(tokio::time::Instant::now());
3553 Ok(Some(Bytes::from(chunk.into_bytes())))
3554 }
3555 // A mid-stream `StreamError` is either a `VoucherRejected` (our
3556 // payment fault, with the self-heal bundle) or a refusal; both
3557 // surface as errors.
3558 ClientMessage::StreamError(e) => {
3559 Err(voucher_rejection(&self.ledger, &self.meter, e))
3560 }
3561 ClientMessage::StreamEnd => {
3562 self.ended = true;
3563 Ok(None)
3564 }
3565 other => {
3566 anyhow::bail!("unexpected message mid-delivery: {}", variant_name(&other))
3567 }
3568 }
3569 }
3570 }
3571
3572 /// Decide what a voucher write failure really means.
3573 ///
3574 /// A node stops our send half only once it needs nothing more from us: a
3575 /// completed delivery leaves a `StreamEnd` on the wire, a mid-stream rejection a
3576 /// `StreamError`. The write half and the read half run in lock-step in the
3577 /// receive loop, so a raw voucher-write failure — the end-of-stream
3578 /// `STOP_SENDING(0)` a node emits when it finishes and drops `recv` — would
3579 /// otherwise mask that terminal signal and abort a complete, paid fetch (or
3580 /// swallow a typed rejection the fault classification keys on). Read the terminal
3581 /// signal, briefly, and prefer it.
3582 ///
3583 /// A `StreamEnd` marks the stream ended, and the next read returns `None`. When the failed write was
3584 /// a voucher, the `StreamEnd` also confirms it. An honest node ends a stream
3585 /// only once every interval, the closing one included, is credited. So
3586 /// payment-based completion ([`driver::drive`]) sees this leg paid through its
3587 /// end and does not re-pull and re-bill the tail. Confirming costs nothing
3588 /// extra, because [`PoolLedger::settlement`] already reports the armed voucher.
3589 ///
3590 /// A `StreamError` surfaces as the typed rejection. The node delivers ahead of
3591 /// payment within its credit window, so `ChunkData` it sent before it rejected
3592 /// can sit in front of the `StreamError`: the read passes over those frames,
3593 /// unread, to reach the terminal message. The rejected leg pays for none of
3594 /// them, and a caller that resumes pulls them again. A `StreamEnd` behind
3595 /// skipped `ChunkData` confirms nothing, because this leg did not take in those
3596 /// bytes. A write we caused ourselves (a [`LocalPullFault`]) is never masked,
3597 /// nor is a stream that yields no terminal signal before the bound.
3598 ///
3599 /// Returns whether a voucher was confirmed. A `StreamEnd` after a failed reveal
3600 /// or re-anchor write, or after a sibling's voucher replaced this stream's
3601 /// armed one, ends the stream with nothing confirmed.
3602 async fn terminal_after_write_failure(
3603 &mut self,
3604 write_err: anyhow::Error,
3605 ) -> anyhow::Result<bool> {
3606 if write_err.is::<LocalPullFault>() {
3607 return Err(write_err);
3608 }
3609 // Any outcome other than a terminal message means nothing terminal was
3610 // waiting, so the write failure stands as the honest outcome (its typed
3611 // cause still downcasts through the added context); what the recovery saw
3612 // rides along so the log can tell a silent peer from a chatty one.
3613 // The frames it passes over stay under the same ceilings as the ones it
3614 // takes in (`next_chunk`): an honest node sends no byte past the
3615 // request's promised wire length, and our own received-byte ceiling
3616 // stops the pull as it would on any read.
3617 let mut skipped: u64 = 0;
3618 let terminal = tokio::time::timeout(TERMINAL_AFTER_WRITE_TIMEOUT, async {
3619 loop {
3620 match self.read_under_floor().await {
3621 Ok(ClientMessage::ChunkData(chunk)) => {
3622 skipped = skipped.saturating_add(chunk.bytes().len() as u64);
3623 let seen = self.cumulative.saturating_add(skipped);
3624 if seen > self.expected_wire_bytes {
3625 anyhow::bail!(
3626 "server sent {seen} bytes, more than the {} promised",
3627 self.expected_wire_bytes
3628 );
3629 }
3630 if self.max_received_wire > 0 && seen > self.max_received_wire {
3631 return Err(anyhow::Error::new(BlobTooLarge {
3632 reached: seen,
3633 ceiling: self.max_received_wire,
3634 }));
3635 }
3636 }
3637 other => return other,
3638 }
3639 }
3640 })
3641 .await;
3642 match terminal {
3643 Ok(Ok(ClientMessage::StreamEnd)) if skipped > 0 => Err(write_err.context(format!(
3644 "the peer ended the stream after {skipped} bytes of ChunkData this leg did not \
3645 take in once the voucher write failed"
3646 ))),
3647 Ok(Ok(ClientMessage::StreamEnd)) => {
3648 self.ended = true;
3649 let attempted = write_err
3650 .downcast_ref::<UnconfirmedVoucher>()
3651 .map(|voucher| voucher.amount);
3652 let confirmed = match attempted {
3653 Some(amount) => self.ledger.confirm_armed_stamped(amount).await,
3654 None => None,
3655 };
3656 if let Some((confirmed, generation)) = confirmed {
3657 tracing::debug!(
3658 amount = %confirmed.amount,
3659 error = %format_args!("{write_err:#}"),
3660 "upstream ended the stream after a voucher write failed; confirmed \
3661 the voucher"
3662 );
3663 self.meter.last_proof = Some(StreamProof::Voucher {
3664 amount: confirmed.amount,
3665 generation,
3666 });
3667 Ok(true)
3668 } else {
3669 tracing::warn!(
3670 attempted = ?attempted,
3671 error = %format_args!("{write_err:#}"),
3672 "upstream ended the stream after a proof write failed; nothing \
3673 confirmed, so the leg's tail stays unpaid"
3674 );
3675 Ok(false)
3676 }
3677 }
3678 Ok(Ok(ClientMessage::StreamError(e))) => {
3679 Err(voucher_rejection(&self.ledger, &self.meter, e))
3680 }
3681 Ok(Ok(other)) => Err(write_err.context(format!(
3682 "no terminal signal after the voucher write failed; peer sent {}",
3683 variant_name(&other)
3684 ))),
3685 // The ceiling stays typed: it is terminal, not a peer to fail over from.
3686 Ok(Err(read_err)) if read_err.is::<BlobTooLarge>() => Err(read_err),
3687 Ok(Err(read_err)) => Err(write_err.context(format!(
3688 "no terminal signal after the voucher write failed; recovery read failed: \
3689 {read_err:#}"
3690 ))),
3691 Err(_) => Err(write_err.context(format!(
3692 "no terminal signal within {TERMINAL_AFTER_WRITE_TIMEOUT:?} of the voucher \
3693 write failing"
3694 ))),
3695 }
3696 }
3697
3698 /// Finalize a completed pull: pay for the verified bytes no proof covers yet
3699 /// (the closing voucher, which the node waits for before its `StreamEnd`),
3700 /// drain to `StreamEnd` if needed, enforce wire-byte completeness (the full
3701 /// promised bao wire size was received), close the connection cleanly, and
3702 /// return the final acked watermark to persist. Per ADR 038 this does not
3703 /// re-hash the whole blob — bao verification is the caller's decoder's job,
3704 /// group by group as the bytes land.
3705 ///
3706 /// # Errors
3707 ///
3708 /// A short delivery (fewer wire bytes than promised before `StreamEnd`), a stream/protocol
3709 /// error while paying or draining, or — on the same inactivity bound as `next_chunk` —
3710 /// [`PullStalled`] if the upstream goes silent before `StreamEnd`.
3711 pub async fn finish(mut self) -> anyhow::Result<VoucherProgress> {
3712 if !self.ended {
3713 self.pay_verified().await?;
3714 }
3715 while !self.ended {
3716 // Same byte-progress bound as `next_chunk` (#1797): an upstream that never sends
3717 // its `StreamEnd` must not hold the drain open forever.
3718 let msg = self.read_under_floor().await?;
3719 match msg {
3720 ClientMessage::StreamEnd => self.ended = true,
3721 ClientMessage::ChunkData(_) => {
3722 anyhow::bail!("server sent ChunkData after the promised total")
3723 }
3724 // The closing voucher was committed optimistically when it was sent
3725 // (implicit acceptance, ADR 005), so the drain only needs to watch
3726 // for a late `VoucherRejected` / other `StreamError`, which still
3727 // surfaces as an error.
3728 ClientMessage::StreamError(e) => {
3729 return Err(voucher_rejection(&self.ledger, &self.meter, e));
3730 }
3731 other => {
3732 anyhow::bail!("unexpected message at stream end: {}", variant_name(&other))
3733 }
3734 }
3735 }
3736 // Completeness for every fetch (full and resumed): bao verification is
3737 // the caller's decoder's job, group by group as the bytes land, so
3738 // `finish` does not re-hash the whole blob. A truncated stream (fewer
3739 // wire bytes than promised) can't be decoded, so require the full
3740 // promised wire size as the completeness signal.
3741 if self.cumulative < self.expected_wire_bytes {
3742 self.close_transport(0, b"short-delivery");
3743 anyhow::bail!(
3744 "server sent {} of {} promised wire bytes before StreamEnd",
3745 self.cumulative,
3746 self.expected_wire_bytes
3747 );
3748 }
3749 self.close_transport(0, b"done");
3750 Ok(self.progress())
3751 }
3752
3753 /// Abandon the pull (e.g. the downstream client dropped, so we stop pulling
3754 /// and paying). Closes the connection and returns the acked watermark so the
3755 /// caller can still persist what it paid (#852).
3756 #[must_use]
3757 pub fn abort(mut self) -> VoucherProgress {
3758 self.close_transport(0, b"client-abandoned");
3759 self.progress()
3760 }
3761
3762 /// Stop the pull before its promised end, after paying for every wire byte
3763 /// the caller's decoder verified. A steal that takes the tail of this pull's
3764 /// range stops the pull at the split on its open stream, so the stolen tail
3765 /// goes to one source and this one opens no new stream for the part it
3766 /// keeps. Frames past the split that the stream already carried are not
3767 /// verified, so they are not paid for, and the stolen tail is paid once.
3768 ///
3769 /// A rejection the node already sent waits behind the `ChunkData` it sent
3770 /// first, so the stop first reads what has arrived, without waiting: a
3771 /// `VoucherRejected` there rewinds the rejected proof and surfaces as the
3772 /// typed rejection, as it does at [`Self::finish`]. A rejection still in
3773 /// flight reaches the lane's next leg instead. The closing voucher then
3774 /// settles the verified residual, the send half is finished, and the
3775 /// transport is torn down (this stream, or the whole connection when the
3776 /// pull owns it), so the node sees its sending stopped. A node that stops
3777 /// sending before it reads that voucher does not redeem it, and the lane's
3778 /// next cumulative voucher covers the same bytes.
3779 ///
3780 /// A closing voucher that fails to send stays armed in the ledger:
3781 /// [`PoolLedger::settlement`] counts it, and the lane's next voucher builds
3782 /// on it. The caller's paid frontier, which reads
3783 /// [`PoolLedger::committed`], stays behind the verified bytes, so its next
3784 /// leg re-delivers and pays for them once more.
3785 ///
3786 /// # Errors
3787 ///
3788 /// The typed rejection of a `StreamError` that had already arrived, or a
3789 /// closing voucher that fails on our side ([`LocalPullFault`]). Any other
3790 /// send failure is logged and leaves the residual as above.
3791 pub async fn stop(mut self) -> anyhow::Result<VoucherProgress> {
3792 if let Some(rejection) = self.arrived_rejection() {
3793 self.close_transport(0, b"stopped-at-split");
3794 return Err(rejection);
3795 }
3796 let paid = self.pay_one(self.unproved, true).await;
3797 let _ = self.send.finish();
3798 self.close_transport(0, b"stopped-at-split");
3799 match paid {
3800 Ok(remaining) => self.unproved = remaining,
3801 Err(err) if err.is::<LocalPullFault>() => return Err(err),
3802 Err(err) => tracing::warn!(
3803 peer = %self.conn.remote_id(),
3804 cumulative = self.cumulative,
3805 unproved = self.unproved,
3806 error = %format_args!("{err:#}"),
3807 "the closing voucher of a pull stopped at a split did not send"
3808 ),
3809 }
3810 Ok(self.progress())
3811 }
3812
3813 /// The typed rejection of a `StreamError` that has already arrived, read
3814 /// without waiting past the `ChunkData` in front of it. Every frame it
3815 /// takes in counts as received, as in [`Self::next_chunk`]; no decoder
3816 /// verifies it, so no voucher pays for it. A frame past the request's
3817 /// promised wire length or past our received-byte ceiling ends the read:
3818 /// an honest node sends neither. `None` once nothing more has arrived, on
3819 /// any other message, or on a read fault: the caller closes the stream
3820 /// either way.
3821 fn arrived_rejection(&mut self) -> Option<anyhow::Error> {
3822 loop {
3823 match futures_util::FutureExt::now_or_never(self.read_under_floor_once())? {
3824 Ok(ClientMessage::ChunkData(chunk)) => {
3825 let seen = self.cumulative.saturating_add(chunk.bytes().len() as u64);
3826 if seen > self.expected_wire_bytes
3827 || (self.max_received_wire > 0 && seen > self.max_received_wire)
3828 {
3829 return None;
3830 }
3831 self.cumulative = seen;
3832 }
3833 Ok(ClientMessage::StreamError(e)) => {
3834 return Some(voucher_rejection(&self.ledger, &self.meter, e));
3835 }
3836 _ => return None,
3837 }
3838 }
3839 }
3840
3841 /// Tear down this pull's transport on any exit.
3842 ///
3843 /// An OWNED connection (a one-shot dial) is closed, which ends the QUIC
3844 /// connection so the upstream's serve task stops and the paid stream does not
3845 /// linger half-open. A WARM connection (a [`WarmConnection`] reused across
3846 /// hashes, caller-owned or from a [`Connections`] map) is left open for the
3847 /// next hash, and only THIS stream is torn down: the send half is finished (a
3848 /// clean FIN) and the recv half is stopped. The warm connection's own `Drop`
3849 /// closes the connection once, later.
3850 ///
3851 /// `Connection::close`, `SendStream::finish`, and `RecvStream::stop` are all
3852 /// first-wins / idempotent, so an explicit terminal method (`finish`/`abort`)
3853 /// keeps its richer reason and the `Drop` safety net becomes a no-op.
3854 fn close_transport(&mut self, code: u32, reason: &[u8]) {
3855 if self.owner.owns() {
3856 self.conn.close(code.into(), reason);
3857 } else {
3858 let _ = self.send.finish();
3859 let _ = self.recv.stop(code.into());
3860 }
3861 }
3862}
3863
3864impl Drop for UpstreamPull {
3865 /// Safety net for the "call a terminal method on every exit" contract: if a
3866 /// caller returns or panics without `finish`/`abort`, still tear this pull's
3867 /// transport down through `close_transport` so the QUIC stream and the
3868 /// upstream's server-side serve task don't linger and keep that paid stream
3869 /// half-open. `Connection::close` / `SendStream::finish` / `RecvStream::stop`
3870 /// are all first-wins and idempotent, so an explicit teardown in
3871 /// `finish`/`abort` keeps its richer reason and this is a no-op when one of
3872 /// them ran; it only takes effect on a dropped-without-finalize path.
3873 ///
3874 /// It tears down the transport and nothing else, and that is sufficient: the
3875 /// acked watermark lives in the channel's [`PoolLedger`], which OUTLIVES the
3876 /// pull (it is shared with the other pulls on the channel), not in a field of
3877 /// this struct. A dropped pull's caller reads it with [`PoolLedger::settlement`]
3878 /// and persists it — `node_origin` does exactly that from its own `Drop` guard
3879 /// (#1145 review).
3880 fn drop(&mut self) {
3881 self.close_transport(0, b"upstream-pull-dropped");
3882 }
3883}
3884
3885/// Issue one cumulative voucher for `delta_bytes` newly delivered since the last
3886/// voucher and SEND it. Acceptance is implicit (ADR 005): continued delivery IS
3887/// acceptance, so there is no ack to wait for — the send itself commits.
3888///
3889/// Voucher issuance runs through the lane's [`PoolLedger`], which serializes the
3890/// compute → sign → send critical section across every concurrent stream drawing
3891/// on the lane — so vouchers reach the node in strict cumulative order — and
3892/// releases its issuance lock the instant the send returns. Parallel range
3893/// streams to one provider do not serialize their payments behind each other's
3894/// round trips, because there is no round trip: only a rejection comes back, and
3895/// it arrives as its own mid-stream `StreamError`.
3896///
3897/// Each voucher's own *delta* (`ceil(delta_bytes * rate / 1 MiB)`) covers its own
3898/// bytes at the advertised rate (the node checks deltas, not the rounded
3899/// cumulative). A successful send advances the committed watermark optimistically;
3900/// an ambiguous send failure leaves the voucher armed so [`PoolLedger::settlement`]
3901/// still reports it (settle high — the upstream persists a voucher before it would
3902/// reject it, ADR 003). The client never pays ahead of what it received (vouchers
3903/// are cumulative over delivered bytes).
3904///
3905/// Returns the sent voucher as the [`StreamProof`] the stream records, stamped
3906/// with the ledger generation it was signed under, and the chain root the
3907/// voucher anchors this stream to (`None` for a sealed voucher, which clears the
3908/// node's anchor). The root is the one the voucher signed, read under the
3909/// issuance lock: a read of the ledger after the send can already name a
3910/// sibling's newer chain.
3911async fn send_voucher(
3912 send: &mut SendStream,
3913 ctx: &PoolContext,
3914 ledger: &PoolLedger,
3915 rate_per_mb: u64,
3916 delta_bytes: u64,
3917 epoch: EpochAction,
3918) -> anyhow::Result<(StreamProof, Option<B256>)> {
3919 if ctx.provider.is_zero() {
3920 return Err(anyhow::anyhow!(
3921 "voucher provider is not pinned (Address::ZERO) — call PoolContext::with_provider \
3922 before signing"
3923 )
3924 .context(LocalPullFault));
3925 }
3926 let mut attempted = None;
3927 let mut signed_root = B256::ZERO;
3928 let issued = ledger
3929 .issue_stamped(delta_bytes, rate_per_mb, epoch, |next, chain| {
3930 attempted = Some(next.amount);
3931 signed_root = chain.chain_root;
3932 sign_and_write_voucher(send, ctx, next, chain)
3933 })
3934 .await;
3935 issued
3936 .map(|(next, generation)| {
3937 (
3938 StreamProof::Voucher {
3939 amount: next.amount,
3940 generation,
3941 },
3942 (!signed_root.is_zero()).then_some(signed_root),
3943 )
3944 })
3945 .map_err(|err| match attempted {
3946 Some(amount) => err.context(UnconfirmedVoucher { amount }),
3947 None => err,
3948 })
3949}
3950
3951/// Names the voucher a failed [`send_voucher`] left armed, so a terminal
3952/// `StreamEnd` read afterwards confirms that voucher and no other
3953/// ([`PoolLedger::confirm_armed_stamped`]). Carried as error context: it composes with,
3954/// and still downcasts through, the send error it wraps.
3955#[derive(Debug)]
3956struct UnconfirmedVoucher {
3957 /// The cumulative amount the armed voucher signs.
3958 amount: U256,
3959}
3960
3961impl std::fmt::Display for UnconfirmedVoucher {
3962 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3963 write!(
3964 f,
3965 "voucher for cumulative amount {} did not confirm",
3966 self.amount
3967 )
3968 }
3969}
3970
3971impl std::error::Error for UnconfirmedVoucher {}
3972
3973/// Sign one voucher over `next`/`chain` with the pool context's key and write it
3974/// to `send` — the body both [`send_voucher`] and [`PoolLedger::reanchor`] hand to
3975/// the ledger, so the two differ only in which cumulative the ledger hands back.
3976async fn sign_and_write_voucher(
3977 send: &mut SendStream,
3978 ctx: &PoolContext,
3979 next: Cumulative,
3980 chain: ChainCommit,
3981) -> anyhow::Result<()> {
3982 let signed = Voucher {
3983 pool_id: ctx.pool_id,
3984 signer: ctx.client_signer.address(),
3985 provider: ctx.provider,
3986 amount: next.amount,
3987 bytes_delivered: next.bytes,
3988 chain_root: chain.chain_root,
3989 chunk_price: chain.chunk_price,
3990 }
3991 .sign(ctx.client_signer.as_ref(), &ctx.voucher_domain)
3992 .map_err(|e| anyhow::anyhow!("voucher signing failed: {e}").context(LocalPullFault))?;
3993 let wire_voucher = signed_to_wire_voucher(&signed)
3994 .map_err(|e| anyhow::anyhow!("voucher exceeds wire width: {e}").context(LocalPullFault))?;
3995 write_message(send, &ClientMessage::Voucher(wire_voucher)).await
3996}
3997
3998/// One stream's view of the lane's hash chain (ADR 003 §Concurrent Streams).
3999///
4000/// The chain's scope is the **lane**, not the stream: every stream from one
4001/// signer to one node shares a single root and a single index, because a
4002/// released preimage advances the lane and cannot be attributed to whichever
4003/// stream happened to carry it. What IS per-stream is the *anchor* — which
4004/// epoch this stream has told the node about.
4005///
4006/// That distinction is the whole reason this type exists. A bare preimage names
4007/// no chain, so the node places it against the anchor of the stream it arrived
4008/// on. If a stream released a reveal before carrying that epoch's root voucher,
4009/// the node could not name the chain the reveal belongs to and would reject it
4010/// `UnanchoredPreimage`. So each stream re-anchors itself whenever the lane
4011/// rolls, rather than trusting a sibling to have done it — QUIC orders within a
4012/// stream, so a fast stream that has already adopted a new root can never
4013/// invalidate a slower sibling still finishing the old one.
4014#[derive(Debug, Default)]
4015struct StreamMeter {
4016 /// The chain this stream has already carried the root voucher for, named by
4017 /// its root. `None` until it has anchored to anything.
4018 anchored_root: Option<B256>,
4019 /// The last proof THIS stream claimed money with, so a `VoucherRejected`
4020 /// read here rewinds that proof and not whatever a sibling stream has done
4021 /// on the lane since (see [`PoolLedger::resolve_reject`]). `None` before the
4022 /// stream has claimed anything, and left alone by a re-anchor — which claims
4023 /// nothing and so has nothing to rewind.
4024 last_proof: Option<StreamProof>,
4025}
4026
4027impl StreamMeter {
4028 /// Ensure this stream has told the node which chain its reveals belong to,
4029 /// opening a chain if the lane meters none, and sending that chain's root
4030 /// voucher first if this stream has not carried it.
4031 ///
4032 /// Called from one place — a stream about to release a reveal, which is the
4033 /// only thing that needs a chain to exist. Residual settlement commits to
4034 /// whatever the lane already meters against and goes straight to
4035 /// [`send_voucher`].
4036 async fn anchor(
4037 &mut self,
4038 send: &mut SendStream,
4039 ctx: &PoolContext,
4040 ledger: &PoolLedger,
4041 rate_per_mb: u64,
4042 ) -> anyhow::Result<()> {
4043 if ledger.chain_root().is_none() {
4044 let (proof, root) =
4045 send_voucher(send, ctx, ledger, rate_per_mb, 0, EpochAction::Open).await?;
4046 self.last_proof = Some(proof);
4047 // The root the voucher signed: the send is what opened the chain,
4048 // and a sibling may have rolled it again since.
4049 self.anchored_root = root;
4050 return Ok(());
4051 }
4052 if ledger.chain_root().is_some() && self.anchored_root != ledger.chain_root() {
4053 // A sibling rolled the lane, or this is our first reveal on a chain a
4054 // sibling opened. Either way this stream must name the chain before it
4055 // can reveal against it (ADR 003 §Concurrent Streams, Rule 1).
4056 //
4057 // This must NOT go through `send_voucher`: every voucher the ledger
4058 // issues folds the chain's accrual, and a voucher that folds must also
4059 // roll — so re-anchoring that way would retire the chain it meant to
4060 // join, strand every sibling's in-flight reveals under the retired
4061 // root, and buy a signature plus a fresh keccak ladder each time.
4062 // `reanchor` re-states the signed anchor under the live root instead,
4063 // folding nothing.
4064 //
4065 // Take the root `reanchor` reports, not one read before the send: the
4066 // lane's live chain is only knowable while the issuance lock is held,
4067 // and storing a stale root would leave this stream re-anchoring on
4068 // every chunk.
4069 self.anchored_root = ledger
4070 .reanchor(|anchor, chain| sign_and_write_voucher(send, ctx, anchor, chain))
4071 .await?;
4072 }
4073 Ok(())
4074 }
4075
4076 /// Meter one delivered chunk: release the next preimage on this stream,
4077 /// rolling the chain first if the epoch is spent.
4078 ///
4079 /// The reveal costs one hash and one 33-byte message — no signature, no
4080 /// acknowledgement, and nothing durable to write — which is the whole point
4081 /// of the chain. A rollover costs one signature, and happens once per 255
4082 /// chunks rather than once per chunk.
4083 async fn release_chunk(
4084 &mut self,
4085 send: &mut SendStream,
4086 ctx: &PoolContext,
4087 ledger: &PoolLedger,
4088 rate_per_mb: u64,
4089 ) -> anyhow::Result<()> {
4090 // One rollover, never more: the first meter can find the epoch spent,
4091 // and the roll that follows opens a chain with index 1 available by
4092 // construction. A second exhaustion would mean the ledger contradicted
4093 // itself. A `Moved` tick re-anchors and meters again; each one means a
4094 // sibling rolled the lane, so the lane progresses every time it repeats.
4095 let mut rolled = false;
4096 loop {
4097 self.anchor(&mut *send, ctx, ledger, rate_per_mb).await?;
4098 // Reborrow per iteration: the closure takes the stream by unique
4099 // reference for the duration of the send, and the loop needs it back.
4100 let wire = &mut *send;
4101 match ledger
4102 .meter(self.anchored_root, |released: Released| async move {
4103 write_message(
4104 wire,
4105 &ClientMessage::ChunkPreimage(ChunkPreimage {
4106 preimage: released.preimage.into(),
4107 index: released.index,
4108 }),
4109 )
4110 .await
4111 })
4112 .await?
4113 {
4114 Metered::Released(released) => {
4115 self.last_proof = Some(StreamProof::Reveal {
4116 chain_root: released.chain_root,
4117 index: released.index,
4118 });
4119 return Ok(());
4120 }
4121 Metered::Moved => {}
4122 Metered::Exhausted if !rolled => {
4123 // Roll. The new voucher's `amount` already folds this
4124 // chain's frontier — every reveal advanced the committed
4125 // cumulative as it went — so the fold is the frontier
4126 // actually reached, never a flat 255 (ADR 003 §Rollover).
4127 let (proof, root) =
4128 send_voucher(&mut *send, ctx, ledger, rate_per_mb, 0, EpochAction::Roll)
4129 .await?;
4130 self.last_proof = Some(proof);
4131 self.anchored_root = root;
4132 rolled = true;
4133 }
4134 Metered::Exhausted => {
4135 // A ledger self-contradiction, not a peer fault: marked as OURS so
4136 // it is scored as a local fault and never rescued by the
4137 // voucher-write recovery into a clean completion.
4138 return Err(
4139 anyhow::anyhow!("hash chain still exhausted after a rollover")
4140 .context(LocalPullFault),
4141 );
4142 }
4143 }
4144 }
4145 }
4146
4147 /// Settle a residual smaller than one chunk with a signed voucher.
4148 ///
4149 /// A preimage always advances the claim by a *whole* chunk, so a partial
4150 /// trailing chunk cannot be priced by one — it settles through a signature,
4151 /// exact to one token base unit.
4152 ///
4153 /// It asks for [`EpochAction::Keep`], and usually does not get it. Any
4154 /// transfer that released at least one reveal has accrual outstanding, and a
4155 /// voucher that folds must also roll ([`PoolLedger::issue`]) — so the typical
4156 /// close retires the chain and opens a fresh one, and a sibling stream's
4157 /// in-flight reveals under the old root land as superseded and re-anchor.
4158 /// That is the correct trade and not an oversight: the fold is what makes the
4159 /// residual exact, and a folded frontier must never coexist with the root
4160 /// that proved it. `Keep` is what a transfer smaller than one whole chunk
4161 /// gets — nothing accrued, nothing to fold — and there it commits the sealed
4162 /// section, since such a lane never opened a chain at all.
4163 async fn settle_residual(
4164 &mut self,
4165 send: &mut SendStream,
4166 ctx: &PoolContext,
4167 ledger: &PoolLedger,
4168 rate_per_mb: u64,
4169 residual_bytes: u64,
4170 ) -> anyhow::Result<()> {
4171 let (proof, root) = send_voucher(
4172 send,
4173 ctx,
4174 ledger,
4175 rate_per_mb,
4176 residual_bytes,
4177 EpochAction::Keep,
4178 )
4179 .await?;
4180 self.last_proof = Some(proof);
4181 self.anchored_root = root;
4182 Ok(())
4183 }
4184
4185 /// Emit the proofs `delivered` new bytes have earned: one reveal per whole
4186 /// chunk, plus a closing signature for any residual once the transfer is
4187 /// complete.
4188 ///
4189 /// Returns the bytes still unproved — always below one chunk.
4190 async fn pay(
4191 &mut self,
4192 send: &mut SendStream,
4193 ctx: &PoolContext,
4194 ledger: &PoolLedger,
4195 rate_per_mb: u64,
4196 mut unproved: u64,
4197 complete: bool,
4198 ) -> anyhow::Result<u64> {
4199 while unproved >= CHUNK_BYTES {
4200 self.release_chunk(send, ctx, ledger, rate_per_mb).await?;
4201 unproved -= CHUNK_BYTES;
4202 }
4203 if complete && unproved > 0 {
4204 self.settle_residual(send, ctx, ledger, rate_per_mb, unproved)
4205 .await?;
4206 unproved = 0;
4207 }
4208 Ok(unproved)
4209 }
4210}
4211
4212/// Turn a mid-stream `StreamError` read off the receive loop into the typed error
4213/// the loop surfaces. There is no positive ack in the pool model — continued
4214/// delivery is acceptance (ADR 005) — so this handles only the rejection slot.
4215///
4216/// A `VoucherRejected` is OUR payment-side fault: rewind the proof `meter` last sent
4217/// (known-not-taken, so it must not be settled optimistically) and carry its typed
4218/// reason — plus the wallet-less-resume `bundle` (#1481) — so the orchestrator can
4219/// exonerate the provider (#857) and the reseed loops (`driver::fill_gap`, the
4220/// `test-util` `fetch_inner`) can self-heal from an authenticated watermark
4221/// instead of treating the rejection as terminal. Any
4222/// OTHER `StreamError` is the upstream refusing mid-stream; carry the typed wire
4223/// class as [`UpstreamRefused`] so a refusing peer is scored on its real class
4224/// rather than the `Unreachable` catch-all (#1145 review).
4225fn voucher_rejection(
4226 ledger: &PoolLedger,
4227 meter: &StreamMeter,
4228 error: StreamError,
4229) -> anyhow::Error {
4230 match error {
4231 StreamError::VoucherRejected { reason, bundle } => {
4232 // Rewind the proof THIS stream sent. The lane is shared, so the proof
4233 // it last issued may belong to a sibling; naming ours is what keeps
4234 // the rejection from un-committing a voucher the node accepted.
4235 if let Some(proof) = meter.last_proof {
4236 ledger.resolve_reject(proof);
4237 }
4238 let proof_generation = match meter.last_proof {
4239 Some(StreamProof::Voucher { generation, .. }) => Some(generation),
4240 Some(StreamProof::Reveal { .. }) | None => None,
4241 };
4242 anyhow::Error::new(UpstreamVoucherRejected {
4243 reason,
4244 bundle,
4245 proof_generation,
4246 })
4247 }
4248 other => anyhow::Error::new(UpstreamRefused::mid_stream(other)),
4249 }
4250}
4251
4252/// Validate + verify a `StreamResponse` on receive (ADR 005, ADR 014 §1, #252).
4253fn verify_response(
4254 resp: &StreamResponse,
4255 ext: &StreamResponseExt,
4256 slash_domain: &Eip712Domain,
4257 expected_signer: Address,
4258 hash: [u8; 32],
4259 pool_id: B256,
4260 timestamp_us: u64,
4261) -> anyhow::Result<()> {
4262 // #252 + slash_sig length/upper-bound checks on the frozen base...
4263 resp.validate()
4264 .map_err(|e| anyhow::anyhow!("invalid stream response: {e}"))?;
4265 // ...and the ok/error agreement, which spans the signed base and the unsigned
4266 // extension, so neither validator can see it alone (ADR 013 §Tier 1).
4267 ext.validate(resp.body.ok)
4268 .map_err(|e| anyhow::anyhow!("invalid stream response: {e}"))?;
4269 // Echoed-field correlation (ADR 005).
4270 if resp.body.hash != hash {
4271 anyhow::bail!("response hash does not match request");
4272 }
4273 if resp.body.pool_id != pool_id.as_slice() {
4274 anyhow::bail!("response pool_id does not match request");
4275 }
4276 if resp.body.timestamp_us != timestamp_us {
4277 anyhow::bail!("response timestamp_us not echoed");
4278 }
4279 // slash_sig must recover to the delivering node's Ethereum address.
4280 let sig = Signature::try_from(resp.slash_sig.as_slice())
4281 .map_err(|e| anyhow::anyhow!("slash_sig parse: {e}"))?;
4282 StreamSlashData::from_response_body(&resp.body)
4283 .verify_signer(&sig, expected_signer, slash_domain)
4284 .map_err(|e| anyhow::anyhow!("slash_sig verification failed: {e}"))?;
4285 Ok(())
4286}
4287
4288async fn write_message(send: &mut SendStream, msg: &ClientMessage) -> anyhow::Result<()> {
4289 // The encode is ours; the write below is the peer's connection.
4290 let payload = encode_message(msg)
4291 .map_err(|e| anyhow::anyhow!("encode failed: {e}").context(LocalPullFault))?;
4292 // A mid-stream voucher write, past the open handshake: `APP_ERR_RATE_LIMITED`
4293 // (`0x10`) is only ever sent before any application stream exists (ADR 013
4294 // §Application Error Codes), so it cannot reach this site. Keep the plain text
4295 // rather than route through `transport_error` — typing a shed here would trust
4296 // that invariant instead of scoping the recovery to the open-stage sites.
4297 write_frame(send, &payload)
4298 .await
4299 .map_err(|e| anyhow::anyhow!("write failed: {e}"))
4300}
4301
4302/// Read the open-stage `StreamResponse` together with its trailing
4303/// [`StreamResponseExt`] (ADR 013 §Tier 1, two-phase).
4304///
4305/// Separate from [`read_client_message`] because only this message carries an
4306/// extension: every mid-stream variant is a single postcard value, and widening
4307/// the shared reader would push an always-`default()` ext onto all of them.
4308async fn read_stream_response(
4309 recv: &mut RecvStream,
4310) -> anyhow::Result<(StreamResponse, StreamResponseExt)> {
4311 let frame = read_frame(recv)
4312 .await
4313 .map_err(|e| rate_limited::transport_error("frame read failed", e))?;
4314 let (msg, tail) = decode_message::<ClientMessage>(&frame)
4315 .map_err(|e| anyhow::anyhow!("decode failed: {e}"))?;
4316 let ClientMessage::StreamResponse(response) = msg else {
4317 anyhow::bail!("expected StreamResponse, got {}", variant_name(&msg));
4318 };
4319 let ext = decdn_protocol::parse_stream_response_ext(tail)
4320 .map_err(|e| anyhow::anyhow!("decode stream response ext: {e}"))?;
4321 Ok((response, ext))
4322}
4323
4324async fn read_client_message<R: tokio::io::AsyncRead + Unpin>(
4325 recv: &mut R,
4326) -> anyhow::Result<ClientMessage> {
4327 // A mid-stream read, past the open handshake. `0x10` reaches only the
4328 // open-stage sites (`connect` / `open_bi` / the initial request write and
4329 // [`read_stream_response`]), never here — the limiters shed before any stream
4330 // exists (ADR 013 §Application Error Codes). Plain text, not `transport_error`.
4331 let frame = read_frame(recv)
4332 .await
4333 .map_err(|e| anyhow::anyhow!("frame read failed: {e}"))?;
4334 let (msg, _rest) = decode_message::<ClientMessage>(&frame)
4335 .map_err(|e| anyhow::anyhow!("decode failed: {e}"))?;
4336 Ok(msg)
4337}
4338
4339const fn variant_name(msg: &ClientMessage) -> &'static str {
4340 match msg {
4341 ClientMessage::StreamRequest(_) => "StreamRequest",
4342 ClientMessage::StreamResponse(_) => "StreamResponse",
4343 ClientMessage::ChunkData(_) => "ChunkData",
4344 ClientMessage::Voucher(_) => "Voucher",
4345 ClientMessage::ChunkPreimage(_) => "ChunkPreimage",
4346 ClientMessage::StreamEnd => "StreamEnd",
4347 ClientMessage::StreamError(_) => "StreamError",
4348 }
4349}
4350
4351/// Record a failed [`open_progressive_pull`] on its span once: the error text
4352/// and an error status, so a failed dial or handshake is not exported as a
4353/// slow success.
4354fn record_open_result<T>(span: &tracing::Span, result: &anyhow::Result<T>) {
4355 if let Err(e) = result {
4356 span.record("error", tracing::field::display(format_args!("{e:#}")));
4357 span.record("otel.status_code", "ERROR");
4358 }
4359}
4360
4361#[cfg(test)]
4362mod tests;