pub mod bundle;
pub mod buyer_pool;
pub mod config;
pub mod connection;
pub(crate) mod coverage_plan;
pub mod credential;
pub mod discovery;
pub mod downloader;
pub mod driver;
pub mod endpoint;
pub mod session;
pub mod uri;
#[doc(hidden)]
pub mod fault;
mod first_open;
#[doc(hidden)]
pub mod funding;
#[doc(hidden)]
pub mod health;
#[doc(hidden)]
pub mod lane_sources;
#[doc(hidden)]
pub mod lanes;
mod ledger;
#[doc(hidden)]
pub mod ledgers;
#[doc(hidden)]
pub mod ordered_writes;
#[doc(hidden)]
pub mod pacer;
#[doc(hidden)]
pub mod peer_store;
pub mod probe;
mod progress;
pub mod provider;
#[doc(hidden)]
pub mod ranged_store;
pub(crate) mod rate_limited;
pub mod recovery;
mod scheduler;
mod segment;
pub mod stop;
#[doc(hidden)]
pub mod stream_cap;
pub mod streamer;
#[doc(hidden)]
pub mod targets;
pub mod sink;
pub mod source;
pub mod source_set;
pub use config::PullConfig;
pub use connection::Connections;
pub use credential::{
CREDENTIAL_SWAP_WAIT, CapabilityCause, Credential, CredentialMismatch, CredentialSlot,
FundingEvent, FundingNeeded,
};
pub use downloader::{DownloadTarget, Downloader};
pub use driver::PoolExhausted;
pub use ledger::{Cumulative, PoolLedger};
pub use recovery::RecoveryGate;
pub use sink::{BlobCache, NoCache, SinkFuture};
pub use source::{Funder, PeerSource, PoolReplaced, Recovery, SourceFuture};
pub use source_set::{Holder, NoAffordableSource, NoNodeWillServe, NoSourceHasBlob, StaticSources};
pub use stop::{ClockHold, GaveUp, ProgressClock, StopPolicy};
pub use streamer::{LiveReader, StreamCandidate, StreamDrive, Streamer, VerifiedReader};
#[doc(hidden)]
pub use connection::WarmConnection;
#[doc(hidden)]
pub use coverage_plan::{CoveredRun, SourceCoverage, plan_covered_runs};
#[doc(hidden)]
pub use credential::{QuoteMax, RunDemand};
#[doc(hidden)]
pub use decdn_bao_range::RangedStore;
#[doc(hidden)]
pub use driver::{LegNoProgress, PacingWait, SharedPool, WaitReason, drive, first_leg};
#[doc(hidden)]
pub use fault::{FatalScope, Fault, HealExhausted, LaneBuildFault, classify};
#[doc(hidden)]
pub use first_open::first_open;
#[doc(hidden)]
pub use health::{Health, PeerHealth};
#[doc(hidden)]
pub use ledger::{ChainCommit, EpochAction, Metered, Rebase, Released};
#[doc(hidden)]
pub use ledgers::{LaneHandle, LaneLedgers};
#[doc(hidden)]
pub use pacer::{
BudgetPacer, DownstreamFrontier, PULL_WINDOW_FLOOR, PaceDecision, PaceState, Pacer, RampPacer,
VERIFY_LAG_BYTES, WindowPacer,
};
#[doc(hidden)]
pub use peer_store::{PeerRecord, PeerStore, StoreConfig};
#[doc(hidden)]
pub use ranged_store::ClientRangedStore;
#[doc(hidden)]
pub use rate_limited::UpstreamRateLimited;
#[doc(hidden)]
pub use recovery::{RECOVERY_SETTLE, Stepped};
#[doc(hidden)]
pub use scheduler::{
AcquireEnv, AcquireTarget, ConsumptionPacing, GROWTH_RETRY, GrowFor, LANE_WATCHDOG, LaneLease,
LaneWiden, acquire,
};
#[doc(hidden)]
pub use source::{
BaoRangeReader, BlobSource, IngestEnd, IngestFuture, IngestStore, PrimedSource, SourceStream,
};
#[doc(hidden)]
pub use source_set::{LaneRange, SourceProvider, SourceSet};
pub(crate) use ledger::StreamProof;
#[cfg(any(test, feature = "test-util"))]
pub use source::{FakeFunder, ScriptedReader, ScriptedSource};
use std::sync::Arc;
use std::time::Duration;
use alloy::dyn_abi::Eip712Domain;
use alloy::primitives::{Address, B256, Signature, U256};
use alloy::signers::SignerSync;
use alloy::signers::local::PrivateKeySigner;
#[cfg(any(test, feature = "test-util"))]
use bao_tree::BaoTree;
#[cfg(any(test, feature = "test-util"))]
use bao_tree::io::BaoContentItem;
#[cfg(any(test, feature = "test-util"))]
use bao_tree::io::fsm::{ResponseDecoder, ResponseDecoderNext};
use bytes::Bytes;
#[cfg(any(test, feature = "test-util"))]
use decdn_bao_range::align_range;
use decdn_bao_range::align_range_clamped;
#[cfg(any(test, feature = "test-util"))]
use decdn_bao_range::{AlignedRange, IROH_BLOCK_SIZE};
use decdn_incentive::{
BuyerPoolState, EPHEMERAL_BINDING_NONCE, SignedCapability, SignedVoucher, StreamSlashData,
Voucher, binding_signing_hash, signed_to_wire_voucher,
};
use decdn_protocol::client::{
ClientBinding, ClientMessage, StreamError, StreamRequest, StreamRequestExt, StreamResponse,
StreamResponseExt, VoucherRejectReason, WatermarkBundle, WireCapability,
};
use decdn_protocol::{
CHUNK_BYTES, ChunkPreimage, decode_message, encode_message, read_frame, write_frame,
};
use iroh::endpoint::{RecvStream, SendStream};
use iroh::{Endpoint, EndpointAddr};
#[cfg(any(test, feature = "test-util"))]
use sink::{PullReader, StashedFault};
#[derive(Clone)]
#[cfg_attr(not(feature = "test-util"), non_exhaustive)]
pub struct PoolContext {
pub pool_id: B256,
pub provider: Address,
pub deposit: U256,
pub client_signer: Arc<PrivateKeySigner>,
pub voucher_domain: Eip712Domain,
pub prior_bytes_delivered: U256,
pub prior_amount: U256,
pub client_binding: Option<ClientBinding>,
pub capability: Option<SignedCapability>,
}
impl PoolContext {
#[must_use]
pub const fn new(
pool_id: B256,
deposit: U256,
client_signer: Arc<PrivateKeySigner>,
voucher_domain: Eip712Domain,
) -> Self {
Self {
pool_id,
provider: Address::ZERO,
deposit,
client_signer,
voucher_domain,
prior_bytes_delivered: U256::ZERO,
prior_amount: U256::ZERO,
client_binding: None,
capability: None,
}
}
#[must_use]
pub const fn for_pool(
state: &BuyerPoolState,
client_signer: Arc<PrivateKeySigner>,
voucher_domain: Eip712Domain,
) -> Self {
Self::new(state.pool_id, state.deposit, client_signer, voucher_domain)
}
#[must_use]
pub const fn with_provider(
mut self,
provider: Address,
prior_bytes_delivered: U256,
prior_amount: U256,
) -> Self {
self.provider = provider;
self.prior_bytes_delivered = prior_bytes_delivered;
self.prior_amount = prior_amount;
self
}
#[must_use]
pub fn lane(&self) -> decdn_incentive::LaneKey {
decdn_incentive::LaneKey {
pool_id: self.pool_id,
signer: self.client_signer.address(),
provider: self.provider,
}
}
#[must_use]
pub fn new_ledger(&self) -> PoolLedger {
PoolLedger::new(Cumulative {
bytes: self.prior_bytes_delivered,
amount: self.prior_amount,
})
}
#[must_use]
pub fn with_client_binding(mut self, binding: ClientBinding) -> Self {
self.client_binding = Some(binding);
self
}
pub fn bind_endpoint(
self,
endpoint: &Endpoint,
bind_domain: &Eip712Domain,
) -> anyhow::Result<Self> {
let own_node_id = B256::from(*endpoint.id().as_bytes());
let binding = sign_client_binding(&self.client_signer, own_node_id, bind_domain)?;
Ok(self.with_client_binding(binding))
}
#[must_use]
pub const fn with_capability(mut self, capability: SignedCapability) -> Self {
self.capability = Some(capability);
self
}
}
#[doc(hidden)]
pub fn sign_client_binding(
signer: &PrivateKeySigner,
own_node_id: B256,
bind_domain: &Eip712Domain,
) -> anyhow::Result<ClientBinding> {
let hash = binding_signing_hash(own_node_id, EPHEMERAL_BINDING_NONCE, bind_domain);
let binding_signature = signer
.sign_hash_sync(&hash)
.map_err(|e| anyhow::anyhow!("sign client binding: {e}").context(LocalPullFault))?
.as_bytes()
.to_vec();
Ok(ClientBinding {
ethereum_address: signer.address().into(),
binding_signature,
})
}
fn client_binding_ext(ctx: &PoolContext) -> Option<StreamRequestExt> {
if ctx.client_binding.is_none() && ctx.capability.is_none() {
return None;
}
Some(StreamRequestExt {
binding: ctx.client_binding.clone(),
capability: ctx.capability.as_ref().map(|signed| WireCapability {
spending_cap: signed.capability.spending_cap,
expiry: signed.capability.expiry,
owner_signature: signed.signature.as_bytes().to_vec(),
}),
})
}
impl std::fmt::Debug for PoolContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PoolContext")
.field("pool_id", &self.pool_id)
.field("provider", &self.provider)
.field("deposit", &self.deposit)
.finish_non_exhaustive()
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct VoucherProgress {
bytes_delivered: U256,
amount: U256,
advanced: bool,
rebase_anchor: Option<Cumulative>,
}
impl VoucherProgress {
#[must_use]
pub fn from_cumulative(cum: Cumulative, prior_amount: U256) -> Self {
Self {
bytes_delivered: cum.bytes,
amount: cum.amount,
advanced: cum.amount > prior_amount,
rebase_anchor: None,
}
}
#[must_use]
pub const fn with_rebase_anchor(mut self, anchor: Option<Cumulative>) -> Self {
self.rebase_anchor = anchor;
self
}
#[must_use]
pub const fn rebase_anchor(&self) -> Option<Cumulative> {
self.rebase_anchor
}
#[must_use]
pub fn from_ledger(ledger: &PoolLedger, prior_amount: U256) -> Self {
let anchor = ledger.take_unsaved_rebase();
Self::from_cumulative(ledger.settlement(), prior_amount).with_rebase_anchor(anchor)
}
#[must_use]
pub fn advanced(&self) -> Option<(U256, U256)> {
self.advanced.then_some((self.bytes_delivered, self.amount))
}
#[must_use]
pub const fn totals(&self) -> (U256, U256) {
(self.bytes_delivered, self.amount)
}
}
#[derive(Debug)]
pub struct HashMismatch;
impl std::fmt::Display for HashMismatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("received bytes do not match requested hash")
}
}
impl std::error::Error for HashMismatch {}
pub(crate) fn reject_empty_claim_for_nonempty_root(
total_bytes: u64,
hash: [u8; 32],
) -> anyhow::Result<()> {
if total_bytes == 0 && hash != *blake3::hash(&[]).as_bytes() {
return Err(anyhow::Error::new(HashMismatch));
}
Ok(())
}
#[derive(Debug)]
#[cfg_attr(not(feature = "test-util"), non_exhaustive)]
pub struct BlobTooLarge {
pub reached: u64,
pub ceiling: u64,
}
#[derive(Debug)]
#[doc(hidden)]
pub struct ResumeOffsetPastEnd {
pub total_bytes: u64,
pub byte_offset: u64,
}
impl std::fmt::Display for ResumeOffsetPastEnd {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"cannot resume at byte {}: the blob is only {} bytes",
self.byte_offset, self.total_bytes
)
}
}
impl std::error::Error for ResumeOffsetPastEnd {}
impl std::fmt::Display for BlobTooLarge {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"reached byte {}, crossing the {}-byte size ceiling (BlobTooLarge)",
self.reached, self.ceiling
)
}
}
impl std::error::Error for BlobTooLarge {}
pub struct RateAboveCeiling {
quoted_rate_per_mb: u64,
ceiling_rate_per_mb: u64,
response: StreamResponse,
}
impl RateAboveCeiling {
fn over_ceiling(response: StreamResponse, ceiling_rate_per_mb: u64) -> anyhow::Error {
anyhow::Error::new(Self {
quoted_rate_per_mb: response.body.rate_per_mb,
ceiling_rate_per_mb,
response,
})
}
#[must_use]
pub const fn quoted_rate_per_mb(&self) -> u64 {
self.quoted_rate_per_mb
}
#[must_use]
pub const fn ceiling_rate_per_mb(&self) -> u64 {
self.ceiling_rate_per_mb
}
#[must_use]
pub const fn evidence(&self) -> &StreamResponse {
&self.response
}
}
impl std::fmt::Debug for RateAboveCeiling {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RateAboveCeiling")
.field("quoted_rate_per_mb", &self.quoted_rate_per_mb)
.field("ceiling_rate_per_mb", &self.ceiling_rate_per_mb)
.field("evidence_slash_sig_len", &self.response.slash_sig.len())
.finish()
}
}
impl std::fmt::Display for RateAboveCeiling {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"server quoted {} per MB, exceeding the buyer ceiling {} per MB (RateAboveCeiling)",
self.quoted_rate_per_mb, self.ceiling_rate_per_mb
)
}
}
impl std::error::Error for RateAboveCeiling {}
#[must_use]
pub const fn effective_rate_ceiling(probe_relative: u64, config_absolute: u64) -> u64 {
match (probe_relative, config_absolute) {
(0, c) => c,
(p, 0) => p,
(p, c) if p < c => p,
(_, c) => c,
}
}
#[derive(Debug)]
#[cfg_attr(not(feature = "test-util"), non_exhaustive)]
pub struct PullTimeout {
pub after: Duration,
}
impl std::fmt::Display for PullTimeout {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "upstream pull timed out after {:?}", self.after)
}
}
impl std::error::Error for PullTimeout {}
#[derive(Debug)]
#[cfg_attr(not(feature = "test-util"), non_exhaustive)]
pub struct UpstreamVoucherRejected {
pub reason: VoucherRejectReason,
pub bundle: Option<WatermarkBundle>,
pub proof_generation: Option<u64>,
}
impl std::fmt::Display for UpstreamVoucherRejected {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "voucher rejected: {:?}", self.reason)
}
}
impl std::error::Error for UpstreamVoucherRejected {}
#[derive(Clone)]
pub struct UpstreamRefused {
kind: Kind,
}
#[derive(Clone)]
enum Kind {
Open {
error: StreamError,
response: Box<StreamResponse>,
},
MidStream { error: StreamError },
}
impl UpstreamRefused {
fn open(response: StreamResponse, ext: &StreamResponseExt) -> anyhow::Error {
if response.body.ok {
return anyhow::anyhow!(
"open-stage refusal constructed from an ok == true response \
(StreamResponse::validate invariant bypassed)"
);
}
match ext.error.clone() {
Some(error) => anyhow::Error::new(Self {
kind: Kind::Open {
error,
response: Box::new(response),
},
}),
None => anyhow::anyhow!(
"delivery refused but the validated response carried no error code \
(StreamResponseExt::validate invariant bypassed)"
),
}
}
#[must_use]
pub const fn mid_stream(error: StreamError) -> Self {
Self {
kind: Kind::MidStream { error },
}
}
#[must_use]
pub const fn error(&self) -> &StreamError {
match &self.kind {
Kind::Open { error, .. } | Kind::MidStream { error } => error,
}
}
#[must_use]
pub fn evidence(&self) -> Option<&StreamResponse> {
match &self.kind {
Kind::Open { response, .. } => Some(response.as_ref()),
Kind::MidStream { .. } => None,
}
}
}
impl std::fmt::Debug for UpstreamRefused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut dbg = f.debug_struct("UpstreamRefused");
dbg.field("error", self.error());
match self.evidence() {
Some(resp) => dbg.field("evidence_slash_sig_len", &resp.slash_sig.len()),
None => dbg.field("evidence", &Option::<()>::None),
};
dbg.finish()
}
}
impl std::fmt::Display for UpstreamRefused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "delivery refused: {:?}", self.error())
}
}
impl std::error::Error for UpstreamRefused {}
#[derive(Debug)]
#[cfg_attr(not(feature = "test-util"), non_exhaustive)]
pub struct PullStalled {
pub after: Duration,
}
impl std::fmt::Display for PullStalled {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "upstream stalled: no progress for {:?}", self.after)
}
}
impl std::error::Error for PullStalled {}
#[derive(Debug)]
#[doc(hidden)]
pub struct LocalPullFault;
impl std::fmt::Display for LocalPullFault {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("local buyer-side fault (not attributable to the upstream)")
}
}
impl std::error::Error for LocalPullFault {}
#[derive(Debug, Clone, Copy)]
pub struct PullDeadlines {
open: Duration,
window: Duration,
floor_bps: u64,
hard_cap: Option<Duration>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum DeadlineError {
ZeroBudget,
CapCannotOutlastItsStages {
open: Duration,
window: Duration,
hard_cap: Duration,
},
}
impl std::fmt::Display for DeadlineError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ZeroBudget => write!(f, "a deadline of zero elapses before any work can run"),
Self::CapCannotOutlastItsStages {
open,
window,
hard_cap,
} => write!(
f,
"the overall cap ({hard_cap:?}) must exceed the open bound ({open:?}) plus the \
throughput-floor window ({window:?}): both run inside it and in the worst case \
consecutively, so below that the cap always elapses first and a stalled \
provider can never be detected"
),
}
}
}
impl std::error::Error for DeadlineError {}
impl PullDeadlines {
pub const fn new(
open: Duration,
window: Duration,
floor_bps: u64,
) -> Result<Self, DeadlineError> {
if open.is_zero() || window.is_zero() {
return Err(DeadlineError::ZeroBudget);
}
Ok(Self {
open,
window,
floor_bps,
hard_cap: None,
})
}
pub fn capped(
open: Duration,
window: Duration,
floor_bps: u64,
hard_cap: Duration,
) -> Result<Self, DeadlineError> {
if open.is_zero() || window.is_zero() {
return Err(DeadlineError::ZeroBudget);
}
if hard_cap <= open.saturating_add(window) {
return Err(DeadlineError::CapCannotOutlastItsStages {
open,
window,
hard_cap,
});
}
Ok(Self {
open,
window,
floor_bps,
hard_cap: Some(hard_cap),
})
}
#[must_use]
pub const fn open(&self) -> Duration {
self.open
}
#[must_use]
pub const fn window(&self) -> Duration {
self.window
}
#[must_use]
pub const fn floor_bps(&self) -> u64 {
self.floor_bps
}
#[must_use]
pub const fn hard_cap(&self) -> Option<Duration> {
self.hard_cap
}
#[cfg(any(test, feature = "test-util"))]
#[must_use]
pub const fn whole_transfer(timeout: Duration) -> Self {
Self {
open: timeout,
window: timeout,
floor_bps: 0,
hard_cap: Some(timeout),
}
}
}
#[cfg(any(test, feature = "test-util"))]
#[allow(clippy::too_many_arguments)]
pub async fn stream_fetch(
endpoint: &Endpoint,
target: EndpointAddr,
ctx: &PoolContext,
slash_domain: &Eip712Domain,
expected_signer: Address,
hash: [u8; 32],
byte_offset: u64,
timestamp_us: u64,
timeout: Duration,
) -> anyhow::Result<Bytes> {
stream_fetch_tracked(
endpoint,
target,
ctx,
slash_domain,
expected_signer,
hash,
decdn_protocol::client::NO_NAMESPACE,
byte_offset,
timestamp_us,
PullDeadlines::whole_transfer(timeout),
0,
0,
&mut VoucherProgress::default(),
)
.await
}
#[cfg(any(test, feature = "test-util"))]
#[allow(clippy::too_many_arguments)]
pub async fn stream_fetch_on(
warm: &WarmConnection,
ctx: &PoolContext,
slash_domain: &Eip712Domain,
expected_signer: Address,
hash: [u8; 32],
byte_offset: u64,
timestamp_us: u64,
timeout: Duration,
) -> anyhow::Result<Bytes> {
let ledger = Arc::new(ctx.new_ledger());
fetch_in_memory_once_on(
warm,
ctx,
ledger,
slash_domain,
expected_signer,
hash,
decdn_protocol::client::NO_NAMESPACE,
byte_offset,
timestamp_us,
0,
0,
PullDeadlines::whole_transfer(timeout),
None,
)
.await
}
#[cfg(any(test, feature = "test-util"))]
#[allow(clippy::too_many_arguments)]
pub async fn stream_fetch_tracked(
endpoint: &Endpoint,
target: EndpointAddr,
ctx: &PoolContext,
slash_domain: &Eip712Domain,
expected_signer: Address,
hash: [u8; 32],
namespace_id: [u8; 32],
byte_offset: u64,
timestamp_us: u64,
deadlines: PullDeadlines,
max_blob_size_bytes: u64,
max_rate_per_mb: u64,
progress: &mut VoucherProgress,
) -> anyhow::Result<Bytes> {
stream_fetch_tracked_with_progress(
endpoint,
target,
ctx,
slash_domain,
expected_signer,
hash,
namespace_id,
byte_offset,
timestamp_us,
deadlines,
max_blob_size_bytes,
max_rate_per_mb,
progress,
None,
)
.await
}
pub type ProgressCallback = dyn Fn(u64, u64) + Send + Sync;
#[cfg(any(test, feature = "test-util"))]
#[allow(clippy::too_many_arguments)]
pub async fn stream_fetch_tracked_with_progress(
endpoint: &Endpoint,
target: EndpointAddr,
ctx: &PoolContext,
slash_domain: &Eip712Domain,
expected_signer: Address,
hash: [u8; 32],
namespace_id: [u8; 32],
byte_offset: u64,
timestamp_us: u64,
deadlines: PullDeadlines,
max_blob_size_bytes: u64,
max_rate_per_mb: u64,
progress: &mut VoucherProgress,
on_progress: Option<&ProgressCallback>,
) -> anyhow::Result<Bytes> {
let ledger = Arc::new(ctx.new_ledger());
let result = with_hard_cap(
deadlines.hard_cap,
fetch_inner(
endpoint,
target,
ctx,
slash_domain,
expected_signer,
hash,
namespace_id,
byte_offset,
timestamp_us,
max_blob_size_bytes,
max_rate_per_mb,
deadlines,
&ledger,
on_progress,
),
)
.await;
*progress = VoucherProgress::from_ledger(&ledger, ctx.prior_amount);
result
}
#[cfg(any(test, feature = "test-util"))]
async fn with_hard_cap<F>(hard_cap: Option<Duration>, fut: F) -> anyhow::Result<Bytes>
where
F: std::future::Future<Output = anyhow::Result<Bytes>>,
{
match hard_cap {
Some(cap) => tokio::time::timeout(cap, fut)
.await
.map_err(|_| anyhow::Error::new(PullTimeout { after: cap }))?,
None => fut.await,
}
}
#[cfg(any(test, feature = "test-util"))]
#[allow(clippy::too_many_arguments)]
pub async fn stream_fetch_shared(
endpoint: &Endpoint,
target: EndpointAddr,
ctx: &PoolContext,
ledger: &Arc<PoolLedger>,
slash_domain: &Eip712Domain,
expected_signer: Address,
hash: [u8; 32],
byte_offset: u64,
timestamp_us: u64,
deadlines: PullDeadlines,
max_blob_size_bytes: u64,
max_rate_per_mb: u64,
) -> anyhow::Result<Bytes> {
with_hard_cap(
deadlines.hard_cap,
fetch_inner(
endpoint,
target,
ctx,
slash_domain,
expected_signer,
hash,
decdn_protocol::client::NO_NAMESPACE,
byte_offset,
timestamp_us,
max_blob_size_bytes,
max_rate_per_mb,
deadlines,
ledger,
None,
),
)
.await
}
enum ConnSource<'a> {
Dial {
endpoint: &'a Endpoint,
target: EndpointAddr,
runtime: Option<&'a tokio::runtime::Handle>,
},
Reuse(&'a iroh::endpoint::Connection),
}
#[allow(clippy::too_many_arguments)]
async fn open_stream(
source: ConnSource<'_>,
ctx: &PoolContext,
slash_domain: &Eip712Domain,
expected_signer: Address,
hash: [u8; 32],
namespace_id: [u8; 32],
byte_offset: u64,
byte_len: u64,
timestamp_us: u64,
open: Duration,
) -> anyhow::Result<(
iroh::endpoint::Connection,
SendStream,
RecvStream,
StreamResponse,
StreamResponseExt,
)> {
tokio::time::timeout(open, async move {
let conn = match source {
ConnSource::Dial {
endpoint,
target,
runtime,
} => connection::dial(endpoint, target, runtime, "connect failed").await?,
ConnSource::Reuse(conn) => conn.clone(),
};
let (mut send, mut recv) = conn
.open_bi()
.await
.map_err(|e| rate_limited::transport_error("open_bi failed", e))?;
let req = StreamRequest {
hash,
namespace_id,
pool_id: ctx.pool_id.into(),
byte_offset,
byte_len,
timestamp_us,
};
let ext = client_binding_ext(ctx);
let payload = decdn_protocol::encode_stream_request(&req, ext.as_ref())
.map_err(|e| anyhow::anyhow!("encode stream request: {e}").context(LocalPullFault))?;
write_frame(&mut send, &payload)
.await
.map_err(|e| rate_limited::transport_error("write stream request", e))?;
let (resp, resp_ext) = read_stream_response(&mut recv).await?;
verify_response(
&resp,
&resp_ext,
slash_domain,
expected_signer,
hash,
ctx.pool_id,
timestamp_us,
)?;
Ok((conn, send, recv, resp, resp_ext))
})
.await
.map_err(|_| anyhow::Error::new(PullTimeout { after: open }))?
}
#[doc(hidden)]
pub const MAX_RESUME_ATTEMPTS: u32 = 3;
#[cfg(any(test, feature = "test-util"))]
#[allow(clippy::too_many_arguments)]
async fn fetch_inner(
endpoint: &Endpoint,
target: EndpointAddr,
ctx: &PoolContext,
slash_domain: &Eip712Domain,
expected_signer: Address,
hash: [u8; 32],
namespace_id: [u8; 32],
byte_offset: u64,
timestamp_us: u64,
max_blob_size_bytes: u64,
max_rate_per_mb: u64,
deadlines: PullDeadlines,
ledger: &Arc<PoolLedger>,
on_progress: Option<&ProgressCallback>,
) -> anyhow::Result<Bytes> {
for attempt in 0..=MAX_RESUME_ATTEMPTS {
let result = fetch_in_memory_once(
endpoint,
target.clone(),
ctx,
Arc::clone(ledger),
slash_domain,
expected_signer,
hash,
namespace_id,
byte_offset,
timestamp_us,
max_blob_size_bytes,
max_rate_per_mb,
deadlines,
on_progress,
)
.await;
let Err(e) = result else {
return result;
};
if attempt == MAX_RESUME_ATTEMPTS {
return Err(e);
}
let healed = match rejection_watermark(&e, ctx) {
Some(watermark) => heal_watermark_desync(&e, watermark, ledger).await,
None => None,
};
let Some(healed) = healed else {
return Err(e);
};
tracing::debug!(
attempt,
byte_offset,
?healed,
"voucher rejection carried an authenticated watermark bundle; retrying at the same \
byte_offset"
);
}
Err(anyhow::anyhow!("resume loop exited without returning"))
}
#[cfg(any(test, feature = "test-util"))]
#[allow(clippy::too_many_arguments)]
async fn fetch_in_memory_once(
endpoint: &Endpoint,
target: EndpointAddr,
ctx: &PoolContext,
ledger: Arc<PoolLedger>,
slash_domain: &Eip712Domain,
expected_signer: Address,
hash: [u8; 32],
namespace_id: [u8; 32],
byte_offset: u64,
timestamp_us: u64,
max_blob_size_bytes: u64,
max_rate_per_mb: u64,
deadlines: PullDeadlines,
on_progress: Option<&ProgressCallback>,
) -> anyhow::Result<Bytes> {
let (header, pull) = open_progressive_pull(
endpoint,
target,
ctx,
ledger,
slash_domain,
expected_signer,
hash,
namespace_id,
byte_offset,
timestamp_us,
max_blob_size_bytes,
max_rate_per_mb,
deadlines,
0,
None,
)
.await?;
let total_bytes = header.total_bytes;
if total_bytes == 0 {
reject_empty_claim_for_nonempty_root(total_bytes, hash)?;
pull.finish().await?;
return Ok(Bytes::new());
}
let aligned = align_range(byte_offset, 0, total_bytes)
.map_err(|e| anyhow::anyhow!("range alignment: {e}").context(LocalPullFault))?;
let reader = PullReader::new(pull);
let (plaintext, reader) =
decode_to_vec(hash, total_bytes, &aligned, reader, on_progress).await?;
reader.into_inner().finish().await?;
let lead = usize::try_from(byte_offset.saturating_sub(aligned.fetch_start()))?;
let bytes = Bytes::from(plaintext);
if lead > bytes.len() {
anyhow::bail!("decoded range shorter than requested span");
}
Ok(bytes.slice(lead..))
}
#[cfg(any(test, feature = "test-util"))]
#[allow(clippy::too_many_arguments)]
async fn fetch_in_memory_once_on(
warm: &WarmConnection,
ctx: &PoolContext,
ledger: Arc<PoolLedger>,
slash_domain: &Eip712Domain,
expected_signer: Address,
hash: [u8; 32],
namespace_id: [u8; 32],
byte_offset: u64,
timestamp_us: u64,
max_blob_size_bytes: u64,
max_rate_per_mb: u64,
deadlines: PullDeadlines,
on_progress: Option<&ProgressCallback>,
) -> anyhow::Result<Bytes> {
let (header, pull) = open_progressive_pull_on(
warm.connection(),
connection::ConnOwner::Borrowed,
ctx,
ledger,
slash_domain,
expected_signer,
hash,
namespace_id,
byte_offset,
timestamp_us,
max_blob_size_bytes,
max_rate_per_mb,
deadlines,
0,
)
.await?;
let total_bytes = header.total_bytes;
if total_bytes == 0 {
reject_empty_claim_for_nonempty_root(total_bytes, hash)?;
pull.finish().await?;
return Ok(Bytes::new());
}
let aligned = align_range(byte_offset, 0, total_bytes)
.map_err(|e| anyhow::anyhow!("range alignment: {e}").context(LocalPullFault))?;
let reader = PullReader::new(pull);
let (plaintext, reader) =
decode_to_vec(hash, total_bytes, &aligned, reader, on_progress).await?;
reader.into_inner().finish().await?;
let lead = usize::try_from(byte_offset.saturating_sub(aligned.fetch_start()))?;
let bytes = Bytes::from(plaintext);
if lead > bytes.len() {
anyhow::bail!("decoded range shorter than requested span");
}
Ok(bytes.slice(lead..))
}
#[cfg(any(test, feature = "test-util"))]
async fn decode_to_vec<R>(
root: [u8; 32],
total_bytes: u64,
aligned: &AlignedRange,
reader: R,
on_progress: Option<&ProgressCallback>,
) -> anyhow::Result<(Vec<u8>, R)>
where
R: iroh_io::AsyncStreamReader + StashedFault + Send,
{
let tree = BaoTree::new(total_bytes, IROH_BLOCK_SIZE);
let root = blake3::Hash::from_bytes(root);
let mut decoder = ResponseDecoder::new(root, aligned.chunk_ranges().clone(), tree, reader);
let mut out = Vec::new();
loop {
match decoder.next().await {
ResponseDecoderNext::More((rest, Ok(BaoContentItem::Leaf(leaf)))) => {
out.extend_from_slice(&leaf.data);
if let Some(cb) = on_progress {
let position = aligned
.fetch_start()
.saturating_add(u64::try_from(out.len()).unwrap_or(u64::MAX));
cb(position, total_bytes);
}
decoder = rest;
}
ResponseDecoderNext::More((rest, Ok(BaoContentItem::Parent(_)))) => decoder = rest,
ResponseDecoderNext::More((rest, Err(decode_err))) => {
let mut r = rest.finish();
if let Some(fault) = r.take_fault() {
return Err(fault);
}
return Err(sink::classify_decode_error(decode_err));
}
ResponseDecoderNext::Done(mut r) => {
if let Some(fault) = r.take_fault() {
return Err(fault);
}
return Ok((out, r));
}
}
}
}
#[must_use]
pub(crate) fn voucher_signed_by(
voucher: &Voucher,
signature: &[u8],
expected: Address,
domain: &Eip712Domain,
) -> bool {
let Ok(signature) = Signature::try_from(signature) else {
return false;
};
SignedVoucher {
voucher: voucher.clone(),
signature,
}
.recover_signer(domain)
.is_ok_and(|recovered| recovered == expected)
}
pub(crate) fn resumable_watermark<'a>(
err: &'a anyhow::Error,
ctx: &PoolContext,
) -> Option<&'a WatermarkBundle> {
let rejected = err.downcast_ref::<UpstreamVoucherRejected>()?;
if !rejected.reason.is_watermark_gated() {
return None;
}
let bundle = rejected.bundle.as_ref()?;
if bundle.validate().is_err() {
return None;
}
let claimed = Voucher {
pool_id: ctx.pool_id,
signer: ctx.client_signer.address(),
provider: ctx.provider,
amount: U256::from(bundle.amount),
bytes_delivered: U256::from(bundle.bytes_delivered),
chain_root: B256::from(bundle.chain_root),
chunk_price: U256::from(bundle.chunk_price),
};
if !voucher_signed_by(
&claimed,
&bundle.last_signature,
ctx.client_signer.address(),
&ctx.voucher_domain,
) {
return None;
}
frontier_is_proved(bundle).then_some(bundle)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Healed {
Reseeded,
Rebased,
Stale,
}
#[must_use]
pub(crate) fn rejection_watermark(err: &anyhow::Error, ctx: &PoolContext) -> Option<Cumulative> {
resumable_watermark(err, ctx).map(Cumulative::from)
}
pub(crate) async fn heal_watermark_desync(
err: &anyhow::Error,
watermark: Cumulative,
ledger: &PoolLedger,
) -> Option<Healed> {
if ledger.reseed(watermark) {
tracing::info!(
amount = %watermark.amount,
bytes = %watermark.bytes,
"voucher rejection carried a watermark ahead of ours; reseeded"
);
return Some(Healed::Reseeded);
}
let rejected = err.downcast_ref::<UpstreamVoucherRejected>()?;
if matches!(
rejected.reason,
VoucherRejectReason::UnderFold | VoucherRejectReason::AmountRegression
) {
return already_covered(rejected.reason, watermark, ledger).await;
}
match rejected.reason {
VoucherRejectReason::Underpaid => {
let outcome = ledger.rebase(watermark, rejected.proof_generation).await;
rebase_outcome(
rejected.reason,
outcome,
watermark,
rejected.proof_generation,
)
}
VoucherRejectReason::BytesRegression => {
heal_bytes_regression(watermark, rejected.proof_generation, ledger).await
}
_ => None,
}
}
async fn heal_bytes_regression(
watermark: Cumulative,
proof_generation: Option<u64>,
ledger: &PoolLedger,
) -> Option<Healed> {
let outcome = ledger
.rebase_ahead_on_bytes(watermark, proof_generation)
.await;
if outcome != Rebase::Refused {
return rebase_outcome(
VoucherRejectReason::BytesRegression,
outcome,
watermark,
proof_generation,
);
}
let committed = ledger.committed();
if committed.amount >= watermark.amount && committed.bytes >= watermark.bytes {
tracing::debug!(
bundle_amount = %watermark.amount,
bundle_bytes = %watermark.bytes,
committed_amount = %committed.amount,
committed_bytes = %committed.bytes,
?proof_generation,
"bytes-regression rejection carried a watermark our ledger already covers; \
retrying from the ledger"
);
} else {
tracing::warn!(
bundle_amount = %watermark.amount,
bundle_bytes = %watermark.bytes,
committed_amount = %committed.amount,
committed_bytes = %committed.bytes,
?proof_generation,
"bytes-regression rejection carried a watermark ahead of our ledger on amount \
that neither a reseed nor a rebase took; retrying"
);
}
Some(Healed::Stale)
}
async fn already_covered(
reason: VoucherRejectReason,
watermark: Cumulative,
ledger: &PoolLedger,
) -> Option<Healed> {
let committed = ledger.committed();
if committed.bytes < watermark.bytes {
tracing::warn!(
bundle_amount = %watermark.amount,
bundle_bytes = %watermark.bytes,
committed_amount = %committed.amount,
committed_bytes = %committed.bytes,
"trailing-proof rejection carried a watermark our amount covers but our bytes \
do not; the ledger paid the amount but not the bytes"
);
return None;
}
let retired = if reason == VoucherRejectReason::UnderFold {
ledger.retire_unadopted_chain(watermark).await
} else {
None
};
log_covered(watermark, committed, retired);
Some(Healed::Stale)
}
fn log_covered(watermark: Cumulative, committed: Cumulative, retired: Option<B256>) {
if let Some(root) = retired {
tracing::info!(
retired_root = %root,
bundle_amount = %watermark.amount,
bundle_bytes = %watermark.bytes,
committed_amount = %committed.amount,
committed_bytes = %committed.bytes,
"under-fold rejection: the node never adopted our live chain, and our ledger \
already covers its watermark; folded the ledger and retired the chain, so the \
retry opens a fresh one"
);
} else if committed == watermark {
tracing::debug!(
amount = %watermark.amount,
bytes = %watermark.bytes,
"trailing-proof rejection already healed by a sibling stream; retrying"
);
} else {
tracing::debug!(
bundle_amount = %watermark.amount,
bundle_bytes = %watermark.bytes,
committed_amount = %committed.amount,
committed_bytes = %committed.bytes,
"trailing-proof rejection carried a watermark our ledger is already past; \
retrying from the ledger"
);
}
}
fn rebase_outcome(
reason: VoucherRejectReason,
outcome: Rebase,
watermark: Cumulative,
proof_generation: Option<u64>,
) -> Option<Healed> {
match outcome {
Rebase::Rebased { from } => {
tracing::warn!(
?reason,
from_amount = %from.amount,
from_bytes = %from.bytes,
to_amount = %watermark.amount,
to_bytes = %watermark.bytes,
"upstream refused our vouchers: the watermark it accepted differs from our \
lane's; rebased to it"
);
Some(Healed::Rebased)
}
Rebase::Stale => {
tracing::debug!(
?reason,
?proof_generation,
"rejection of a voucher signed before the latest rebase; retrying from the \
healed anchor"
);
Some(Healed::Stale)
}
Rebase::Refused => None,
}
}
#[must_use]
fn frontier_is_proved(bundle: &WatermarkBundle) -> bool {
bundle.verified_index == 0
|| decdn_incentive::chain::verify_forward(
B256::from(bundle.tip),
bundle.verified_index,
B256::from(bundle.chain_root),
)
}
#[must_use]
#[doc(hidden)]
pub fn is_range_past_end(err: &anyhow::Error) -> bool {
err.downcast_ref::<ResumeOffsetPastEnd>().is_some()
|| err.chain().any(|cause| {
matches!(
cause.downcast_ref::<decdn_bao_range::RangeVerifyError>(),
Some(decdn_bao_range::RangeVerifyError::RangeOutOfBounds { .. })
)
})
}
fn aligned_wire_len(byte_offset: u64, byte_len: u64, total_bytes: u64) -> anyhow::Result<u64> {
let aligned = align_range_clamped(byte_offset, byte_len, total_bytes)
.map_err(|e| anyhow::Error::new(e).context("range alignment"))?;
Ok(aligned.wire_len())
}
fn served_wire_len(byte_offset: u64, byte_len: u64, total_bytes: u64) -> anyhow::Result<u64> {
if total_bytes <= byte_offset && (byte_offset > 0 || byte_len > 0) {
return Err(anyhow::Error::new(ResumeOffsetPastEnd {
total_bytes,
byte_offset,
}));
}
aligned_wire_len(byte_offset, byte_len, total_bytes)
}
pub(crate) fn stall_sample_period(window: Duration) -> Duration {
(window / 8).max(Duration::from_millis(100))
}
const TERMINAL_AFTER_WRITE_TIMEOUT: Duration = Duration::from_secs(5);
const PROOF_STALL_DEBUG_AFTER: Duration = Duration::from_secs(5);
fn log_proof_stall(
pull: (&dyn std::fmt::Display, [u8; 32], u64),
cumulative: u64,
unproved: u64,
phase: &'static str,
elapsed: Duration,
) {
if elapsed < PROOF_STALL_DEBUG_AFTER {
return;
}
let (peer, hash, byte_offset) = pull;
tracing::debug!(
peer = %peer,
hash = %blake3::Hash::from_bytes(hash).to_hex(),
byte_offset,
cumulative,
unproved,
phase,
elapsed_ms = elapsed.as_millis(),
"a leg phase ran past {PROOF_STALL_DEBUG_AFTER:?}"
);
}
#[derive(Debug, Clone, Copy)]
#[doc(hidden)]
pub struct UpstreamPullHeader {
pub total_bytes: u64,
pub rate_per_mb: u64,
pub interval_bytes: u64,
}
#[doc(hidden)]
pub struct UpstreamPull {
conn: iroh::endpoint::Connection,
owner: connection::ConnOwner,
send: SendStream,
recv: RecvStream,
ctx: PoolContext,
ledger: Arc<PoolLedger>,
hash: [u8; 32],
byte_offset: u64,
byte_len: u64,
rate_per_mb: u64,
meter: StreamMeter,
window: Duration,
progress_counter: Arc<std::sync::atomic::AtomicU64>,
floor: progress::ThroughputFloor,
sampler: tokio::time::Interval,
expected_wire_bytes: u64,
max_received_wire: u64,
cumulative: u64,
verified: u64,
unproved: u64,
ended: bool,
returned_at: Option<tokio::time::Instant>,
opened_at: tokio::time::Instant,
}
impl std::fmt::Debug for UpstreamPull {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("UpstreamPull")
.field("hash", &blake3::Hash::from_bytes(self.hash))
.field("expected_wire_bytes", &self.expected_wire_bytes)
.field("cumulative", &self.cumulative)
.field("ended", &self.ended)
.finish_non_exhaustive()
}
}
#[allow(clippy::too_many_arguments)]
#[tracing::instrument(
name = "open_progressive_pull",
skip_all,
fields(
error = tracing::field::Empty,
otel.status_code = tracing::field::Empty,
otel.kind = "client",
peer = %target.id,
local_node_id = %endpoint.id(),
hash = %decdn_protocol::ContentHash::from_bytes(hash),
pool_id = %ctx.pool_id,
byte_offset = byte_offset,
byte_len = byte_len,
)
)]
#[doc(hidden)]
pub async fn open_progressive_pull(
endpoint: &Endpoint,
target: EndpointAddr,
ctx: &PoolContext,
ledger: Arc<PoolLedger>,
slash_domain: &Eip712Domain,
expected_signer: Address,
hash: [u8; 32],
namespace_id: [u8; 32],
byte_offset: u64,
timestamp_us: u64,
max_blob_size_bytes: u64,
max_rate_per_mb: u64,
deadlines: PullDeadlines,
byte_len: u64,
dial_runtime: Option<&tokio::runtime::Handle>,
) -> anyhow::Result<(UpstreamPullHeader, UpstreamPull)> {
open_progressive_pull_impl(
ConnSource::Dial {
endpoint,
target,
runtime: dial_runtime,
},
connection::ConnOwner::Owned,
ctx,
ledger,
slash_domain,
expected_signer,
hash,
namespace_id,
byte_offset,
timestamp_us,
max_blob_size_bytes,
max_rate_per_mb,
deadlines,
byte_len,
)
.await
}
#[allow(clippy::too_many_arguments)]
#[tracing::instrument(
name = "open_progressive_pull",
skip_all,
fields(
error = tracing::field::Empty,
otel.status_code = tracing::field::Empty,
otel.kind = "client",
peer = %conn.remote_id(),
hash = %decdn_protocol::ContentHash::from_bytes(hash),
pool_id = %ctx.pool_id,
byte_offset = byte_offset,
byte_len = byte_len,
)
)]
pub(crate) async fn open_progressive_pull_on(
conn: &iroh::endpoint::Connection,
owner: connection::ConnOwner,
ctx: &PoolContext,
ledger: Arc<PoolLedger>,
slash_domain: &Eip712Domain,
expected_signer: Address,
hash: [u8; 32],
namespace_id: [u8; 32],
byte_offset: u64,
timestamp_us: u64,
max_blob_size_bytes: u64,
max_rate_per_mb: u64,
deadlines: PullDeadlines,
byte_len: u64,
) -> anyhow::Result<(UpstreamPullHeader, UpstreamPull)> {
let opened = open_progressive_pull_impl(
ConnSource::Reuse(conn),
owner.clone(),
ctx,
ledger,
slash_domain,
expected_signer,
hash,
namespace_id,
byte_offset,
timestamp_us,
max_blob_size_bytes,
max_rate_per_mb,
deadlines,
byte_len,
)
.await;
if let Err(err) = &opened {
owner.unpin_on_fault(err).await;
}
opened
}
#[allow(clippy::too_many_arguments)]
async fn open_progressive_pull_impl(
source: ConnSource<'_>,
owner: connection::ConnOwner,
ctx: &PoolContext,
ledger: Arc<PoolLedger>,
slash_domain: &Eip712Domain,
expected_signer: Address,
hash: [u8; 32],
namespace_id: [u8; 32],
byte_offset: u64,
timestamp_us: u64,
max_blob_size_bytes: u64,
max_rate_per_mb: u64,
deadlines: PullDeadlines,
byte_len: u64,
) -> anyhow::Result<(UpstreamPullHeader, UpstreamPull)> {
let result: anyhow::Result<(UpstreamPullHeader, UpstreamPull)> = async {
let window = deadlines.window;
let floor_bps = deadlines.floor_bps;
let (conn, send, recv, resp, resp_ext) = open_stream(
source,
ctx,
slash_domain,
expected_signer,
hash,
namespace_id,
byte_offset,
byte_len,
timestamp_us,
deadlines.open,
)
.await?;
if !resp.body.ok {
return Err(UpstreamRefused::open(resp, &resp_ext));
}
if max_rate_per_mb > 0 && resp.body.rate_per_mb > max_rate_per_mb {
return Err(RateAboveCeiling::over_ceiling(resp, max_rate_per_mb));
}
let rate_per_mb = resp.body.rate_per_mb;
let total_bytes = resp.body.total_bytes;
let expected_wire_bytes = served_wire_len(byte_offset, byte_len, total_bytes)?;
let max_received_wire = if max_blob_size_bytes == 0 {
0
} else if byte_offset >= max_blob_size_bytes {
return Err(anyhow::Error::new(BlobTooLarge {
reached: byte_offset,
ceiling: max_blob_size_bytes,
}));
} else {
aligned_wire_len(byte_offset, 0, max_blob_size_bytes)?
};
let header = UpstreamPullHeader {
total_bytes,
rate_per_mb,
interval_bytes: CHUNK_BYTES,
};
let progress_counter = Arc::new(std::sync::atomic::AtomicU64::new(0));
let floor = progress::ThroughputFloor::new(
progress::FloorConfig { window, floor_bps },
Arc::clone(&progress_counter),
tokio::time::Instant::now(),
);
let mut sampler = tokio::time::interval(stall_sample_period(window));
sampler.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let pull = UpstreamPull {
window,
progress_counter,
floor,
sampler,
conn,
owner,
send,
recv,
ctx: ctx.clone(),
ledger,
hash,
byte_offset,
byte_len,
rate_per_mb,
meter: StreamMeter::default(),
expected_wire_bytes,
max_received_wire,
cumulative: 0,
verified: 0,
unproved: 0,
ended: false,
returned_at: None,
opened_at: tokio::time::Instant::now(),
};
Ok((header, pull))
}
.await;
record_open_result(&tracing::Span::current(), &result);
result
}
impl UpstreamPull {
#[must_use]
pub const fn expected_wire_bytes(&self) -> u64 {
self.expected_wire_bytes
}
#[must_use]
pub fn progress(&self) -> VoucherProgress {
VoucherProgress::from_ledger(&self.ledger, self.ctx.prior_amount)
}
pub(crate) fn mark_verified(&mut self, wire: u64) {
let wire = wire.min(self.cumulative);
if wire > self.verified {
self.unproved = self.unproved.saturating_add(wire - self.verified);
self.verified = wire;
}
}
async fn pay_verified(&mut self) -> anyhow::Result<()> {
let complete = self.verified >= self.expected_wire_bytes;
if self.unproved < CHUNK_BYTES && !(complete && self.unproved > 0) {
return Ok(());
}
let unproved = self.unproved;
let pay_started = tokio::time::Instant::now();
self.floor.pause(pay_started);
let paid = self.pay_one(unproved, complete).await;
let paid_at = tokio::time::Instant::now();
self.floor.resume(paid_at);
log_proof_stall(
(&self.conn.remote_id(), self.hash, self.byte_offset),
self.cumulative,
unproved,
"pay",
paid_at.saturating_duration_since(pay_started),
);
match paid {
Ok(remaining) => self.unproved = remaining,
Err(write_err) => {
if Box::pin(self.terminal_after_write_failure(write_err)).await? {
self.unproved = 0;
}
}
}
Ok(())
}
async fn pay_one(&mut self, unproved: u64, complete: bool) -> anyhow::Result<u64> {
let ledger = Arc::clone(&self.ledger);
self.meter
.pay(
&mut self.send,
&self.ctx,
&ledger,
self.rate_per_mb,
unproved,
complete,
)
.await
}
async fn read_under_floor(&mut self) -> anyhow::Result<ClientMessage> {
let read = self.read_under_floor_once().await;
if let Err(err) = &read {
self.owner.unpin_on_fault(err).await;
}
read
}
async fn read_under_floor_once(&mut self) -> anyhow::Result<ClientMessage> {
let cumulative = self.cumulative;
let window = self.window;
let recv = &mut self.recv;
let sampler = &mut self.sampler;
let floor = &mut self.floor;
let (peer, hash, byte_offset) = (self.conn.remote_id(), self.hash, self.byte_offset);
let unproved = self.unproved;
let (phase, read_started) = if cumulative == 0 {
("first_frame", self.opened_at)
} else {
("read", tokio::time::Instant::now())
};
let read_stall_at = read_started + PROOF_STALL_DEBUG_AFTER;
let mut read_stall_logged = false;
let mut reader = progress::ProgressReader::new(recv, Arc::clone(&self.progress_counter));
let read = read_client_message(&mut reader);
tokio::pin!(read);
loop {
tokio::select! {
biased;
r = &mut read => return r,
_ = sampler.tick() => {
let now = tokio::time::Instant::now();
let last_sample = floor.last_sampled_at();
if let progress::FloorVerdict::Stalled = floor.evaluate(now) {
tracing::debug!(
peer = %self.conn.remote_id(),
hash = %blake3::Hash::from_bytes(self.hash).to_hex(),
byte_offset = self.byte_offset,
byte_len = self.byte_len,
cumulative,
expected_wire_bytes = self.expected_wire_bytes,
window_bytes = floor.window_bytes(),
unpolled_ms = last_sample
.map(|t| now.saturating_duration_since(t).as_millis()),
window_ms = window.as_millis(),
"upstream pull fell under its throughput floor"
);
return Err(if cumulative == 0 {
anyhow::Error::new(PullTimeout { after: window })
} else {
anyhow::Error::new(PullStalled { after: window })
});
}
}
() = tokio::time::sleep_until(read_stall_at), if !read_stall_logged => {
read_stall_logged = true;
let waited = read_started.elapsed();
let pull = (&peer as &dyn std::fmt::Display, hash, byte_offset);
log_proof_stall(pull, cumulative, unproved, phase, waited);
}
}
}
}
pub async fn next_chunk(&mut self) -> anyhow::Result<Option<Bytes>> {
if self.ended {
return Ok(None);
}
if let Some(returned_at) = self.returned_at.take() {
log_proof_stall(
(&self.conn.remote_id(), self.hash, self.byte_offset),
self.cumulative,
self.unproved,
"unpolled",
returned_at.elapsed(),
);
}
self.pay_verified().await?;
if self.ended {
return Ok(None);
}
{
let msg = self.read_under_floor().await?;
match msg {
ClientMessage::ChunkData(chunk) => {
let chunk_len = chunk.bytes().len() as u64;
self.cumulative = self.cumulative.saturating_add(chunk_len);
if self.cumulative > self.expected_wire_bytes {
anyhow::bail!(
"server sent {} bytes, more than the {} promised",
self.cumulative,
self.expected_wire_bytes
);
}
if self.max_received_wire > 0 && self.cumulative > self.max_received_wire {
return Err(anyhow::Error::new(BlobTooLarge {
reached: self.cumulative,
ceiling: self.max_received_wire,
}));
}
self.returned_at = Some(tokio::time::Instant::now());
Ok(Some(Bytes::from(chunk.into_bytes())))
}
ClientMessage::StreamError(e) => {
Err(voucher_rejection(&self.ledger, &self.meter, e))
}
ClientMessage::StreamEnd => {
self.ended = true;
Ok(None)
}
other => {
anyhow::bail!("unexpected message mid-delivery: {}", variant_name(&other))
}
}
}
}
async fn terminal_after_write_failure(
&mut self,
write_err: anyhow::Error,
) -> anyhow::Result<bool> {
if write_err.is::<LocalPullFault>() {
return Err(write_err);
}
let mut skipped: u64 = 0;
let terminal = tokio::time::timeout(TERMINAL_AFTER_WRITE_TIMEOUT, async {
loop {
match self.read_under_floor().await {
Ok(ClientMessage::ChunkData(chunk)) => {
skipped = skipped.saturating_add(chunk.bytes().len() as u64);
let seen = self.cumulative.saturating_add(skipped);
if seen > self.expected_wire_bytes {
anyhow::bail!(
"server sent {seen} bytes, more than the {} promised",
self.expected_wire_bytes
);
}
if self.max_received_wire > 0 && seen > self.max_received_wire {
return Err(anyhow::Error::new(BlobTooLarge {
reached: seen,
ceiling: self.max_received_wire,
}));
}
}
other => return other,
}
}
})
.await;
match terminal {
Ok(Ok(ClientMessage::StreamEnd)) if skipped > 0 => Err(write_err.context(format!(
"the peer ended the stream after {skipped} bytes of ChunkData this leg did not \
take in once the voucher write failed"
))),
Ok(Ok(ClientMessage::StreamEnd)) => {
self.ended = true;
let attempted = write_err
.downcast_ref::<UnconfirmedVoucher>()
.map(|voucher| voucher.amount);
let confirmed = match attempted {
Some(amount) => self.ledger.confirm_armed_stamped(amount).await,
None => None,
};
if let Some((confirmed, generation)) = confirmed {
tracing::debug!(
amount = %confirmed.amount,
error = %format_args!("{write_err:#}"),
"upstream ended the stream after a voucher write failed; confirmed \
the voucher"
);
self.meter.last_proof = Some(StreamProof::Voucher {
amount: confirmed.amount,
generation,
});
Ok(true)
} else {
tracing::warn!(
attempted = ?attempted,
error = %format_args!("{write_err:#}"),
"upstream ended the stream after a proof write failed; nothing \
confirmed, so the leg's tail stays unpaid"
);
Ok(false)
}
}
Ok(Ok(ClientMessage::StreamError(e))) => {
Err(voucher_rejection(&self.ledger, &self.meter, e))
}
Ok(Ok(other)) => Err(write_err.context(format!(
"no terminal signal after the voucher write failed; peer sent {}",
variant_name(&other)
))),
Ok(Err(read_err)) if read_err.is::<BlobTooLarge>() => Err(read_err),
Ok(Err(read_err)) => Err(write_err.context(format!(
"no terminal signal after the voucher write failed; recovery read failed: \
{read_err:#}"
))),
Err(_) => Err(write_err.context(format!(
"no terminal signal within {TERMINAL_AFTER_WRITE_TIMEOUT:?} of the voucher \
write failing"
))),
}
}
pub async fn finish(mut self) -> anyhow::Result<VoucherProgress> {
if !self.ended {
self.pay_verified().await?;
}
while !self.ended {
let msg = self.read_under_floor().await?;
match msg {
ClientMessage::StreamEnd => self.ended = true,
ClientMessage::ChunkData(_) => {
anyhow::bail!("server sent ChunkData after the promised total")
}
ClientMessage::StreamError(e) => {
return Err(voucher_rejection(&self.ledger, &self.meter, e));
}
other => {
anyhow::bail!("unexpected message at stream end: {}", variant_name(&other))
}
}
}
if self.cumulative < self.expected_wire_bytes {
self.close_transport(0, b"short-delivery");
anyhow::bail!(
"server sent {} of {} promised wire bytes before StreamEnd",
self.cumulative,
self.expected_wire_bytes
);
}
self.close_transport(0, b"done");
Ok(self.progress())
}
#[must_use]
pub fn abort(mut self) -> VoucherProgress {
self.close_transport(0, b"client-abandoned");
self.progress()
}
pub async fn stop(mut self) -> anyhow::Result<VoucherProgress> {
if let Some(rejection) = self.arrived_rejection() {
self.close_transport(0, b"stopped-at-split");
return Err(rejection);
}
let paid = self.pay_one(self.unproved, true).await;
let _ = self.send.finish();
self.close_transport(0, b"stopped-at-split");
match paid {
Ok(remaining) => self.unproved = remaining,
Err(err) if err.is::<LocalPullFault>() => return Err(err),
Err(err) => tracing::warn!(
peer = %self.conn.remote_id(),
cumulative = self.cumulative,
unproved = self.unproved,
error = %format_args!("{err:#}"),
"the closing voucher of a pull stopped at a split did not send"
),
}
Ok(self.progress())
}
fn arrived_rejection(&mut self) -> Option<anyhow::Error> {
loop {
match futures_util::FutureExt::now_or_never(self.read_under_floor_once())? {
Ok(ClientMessage::ChunkData(chunk)) => {
let seen = self.cumulative.saturating_add(chunk.bytes().len() as u64);
if seen > self.expected_wire_bytes
|| (self.max_received_wire > 0 && seen > self.max_received_wire)
{
return None;
}
self.cumulative = seen;
}
Ok(ClientMessage::StreamError(e)) => {
return Some(voucher_rejection(&self.ledger, &self.meter, e));
}
_ => return None,
}
}
}
fn close_transport(&mut self, code: u32, reason: &[u8]) {
if self.owner.owns() {
self.conn.close(code.into(), reason);
} else {
let _ = self.send.finish();
let _ = self.recv.stop(code.into());
}
}
}
impl Drop for UpstreamPull {
fn drop(&mut self) {
self.close_transport(0, b"upstream-pull-dropped");
}
}
async fn send_voucher(
send: &mut SendStream,
ctx: &PoolContext,
ledger: &PoolLedger,
rate_per_mb: u64,
delta_bytes: u64,
epoch: EpochAction,
) -> anyhow::Result<(StreamProof, Option<B256>)> {
if ctx.provider.is_zero() {
return Err(anyhow::anyhow!(
"voucher provider is not pinned (Address::ZERO) — call PoolContext::with_provider \
before signing"
)
.context(LocalPullFault));
}
let mut attempted = None;
let mut signed_root = B256::ZERO;
let issued = ledger
.issue_stamped(delta_bytes, rate_per_mb, epoch, |next, chain| {
attempted = Some(next.amount);
signed_root = chain.chain_root;
sign_and_write_voucher(send, ctx, next, chain)
})
.await;
issued
.map(|(next, generation)| {
(
StreamProof::Voucher {
amount: next.amount,
generation,
},
(!signed_root.is_zero()).then_some(signed_root),
)
})
.map_err(|err| match attempted {
Some(amount) => err.context(UnconfirmedVoucher { amount }),
None => err,
})
}
#[derive(Debug)]
struct UnconfirmedVoucher {
amount: U256,
}
impl std::fmt::Display for UnconfirmedVoucher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"voucher for cumulative amount {} did not confirm",
self.amount
)
}
}
impl std::error::Error for UnconfirmedVoucher {}
async fn sign_and_write_voucher(
send: &mut SendStream,
ctx: &PoolContext,
next: Cumulative,
chain: ChainCommit,
) -> anyhow::Result<()> {
let signed = Voucher {
pool_id: ctx.pool_id,
signer: ctx.client_signer.address(),
provider: ctx.provider,
amount: next.amount,
bytes_delivered: next.bytes,
chain_root: chain.chain_root,
chunk_price: chain.chunk_price,
}
.sign(ctx.client_signer.as_ref(), &ctx.voucher_domain)
.map_err(|e| anyhow::anyhow!("voucher signing failed: {e}").context(LocalPullFault))?;
let wire_voucher = signed_to_wire_voucher(&signed)
.map_err(|e| anyhow::anyhow!("voucher exceeds wire width: {e}").context(LocalPullFault))?;
write_message(send, &ClientMessage::Voucher(wire_voucher)).await
}
#[derive(Debug, Default)]
struct StreamMeter {
anchored_root: Option<B256>,
last_proof: Option<StreamProof>,
}
impl StreamMeter {
async fn anchor(
&mut self,
send: &mut SendStream,
ctx: &PoolContext,
ledger: &PoolLedger,
rate_per_mb: u64,
) -> anyhow::Result<()> {
if ledger.chain_root().is_none() {
let (proof, root) =
send_voucher(send, ctx, ledger, rate_per_mb, 0, EpochAction::Open).await?;
self.last_proof = Some(proof);
self.anchored_root = root;
return Ok(());
}
if ledger.chain_root().is_some() && self.anchored_root != ledger.chain_root() {
self.anchored_root = ledger
.reanchor(|anchor, chain| sign_and_write_voucher(send, ctx, anchor, chain))
.await?;
}
Ok(())
}
async fn release_chunk(
&mut self,
send: &mut SendStream,
ctx: &PoolContext,
ledger: &PoolLedger,
rate_per_mb: u64,
) -> anyhow::Result<()> {
let mut rolled = false;
loop {
self.anchor(&mut *send, ctx, ledger, rate_per_mb).await?;
let wire = &mut *send;
match ledger
.meter(self.anchored_root, |released: Released| async move {
write_message(
wire,
&ClientMessage::ChunkPreimage(ChunkPreimage {
preimage: released.preimage.into(),
index: released.index,
}),
)
.await
})
.await?
{
Metered::Released(released) => {
self.last_proof = Some(StreamProof::Reveal {
chain_root: released.chain_root,
index: released.index,
});
return Ok(());
}
Metered::Moved => {}
Metered::Exhausted if !rolled => {
let (proof, root) =
send_voucher(&mut *send, ctx, ledger, rate_per_mb, 0, EpochAction::Roll)
.await?;
self.last_proof = Some(proof);
self.anchored_root = root;
rolled = true;
}
Metered::Exhausted => {
return Err(
anyhow::anyhow!("hash chain still exhausted after a rollover")
.context(LocalPullFault),
);
}
}
}
}
async fn settle_residual(
&mut self,
send: &mut SendStream,
ctx: &PoolContext,
ledger: &PoolLedger,
rate_per_mb: u64,
residual_bytes: u64,
) -> anyhow::Result<()> {
let (proof, root) = send_voucher(
send,
ctx,
ledger,
rate_per_mb,
residual_bytes,
EpochAction::Keep,
)
.await?;
self.last_proof = Some(proof);
self.anchored_root = root;
Ok(())
}
async fn pay(
&mut self,
send: &mut SendStream,
ctx: &PoolContext,
ledger: &PoolLedger,
rate_per_mb: u64,
mut unproved: u64,
complete: bool,
) -> anyhow::Result<u64> {
while unproved >= CHUNK_BYTES {
self.release_chunk(send, ctx, ledger, rate_per_mb).await?;
unproved -= CHUNK_BYTES;
}
if complete && unproved > 0 {
self.settle_residual(send, ctx, ledger, rate_per_mb, unproved)
.await?;
unproved = 0;
}
Ok(unproved)
}
}
fn voucher_rejection(
ledger: &PoolLedger,
meter: &StreamMeter,
error: StreamError,
) -> anyhow::Error {
match error {
StreamError::VoucherRejected { reason, bundle } => {
if let Some(proof) = meter.last_proof {
ledger.resolve_reject(proof);
}
let proof_generation = match meter.last_proof {
Some(StreamProof::Voucher { generation, .. }) => Some(generation),
Some(StreamProof::Reveal { .. }) | None => None,
};
anyhow::Error::new(UpstreamVoucherRejected {
reason,
bundle,
proof_generation,
})
}
other => anyhow::Error::new(UpstreamRefused::mid_stream(other)),
}
}
fn verify_response(
resp: &StreamResponse,
ext: &StreamResponseExt,
slash_domain: &Eip712Domain,
expected_signer: Address,
hash: [u8; 32],
pool_id: B256,
timestamp_us: u64,
) -> anyhow::Result<()> {
resp.validate()
.map_err(|e| anyhow::anyhow!("invalid stream response: {e}"))?;
ext.validate(resp.body.ok)
.map_err(|e| anyhow::anyhow!("invalid stream response: {e}"))?;
if resp.body.hash != hash {
anyhow::bail!("response hash does not match request");
}
if resp.body.pool_id != pool_id.as_slice() {
anyhow::bail!("response pool_id does not match request");
}
if resp.body.timestamp_us != timestamp_us {
anyhow::bail!("response timestamp_us not echoed");
}
let sig = Signature::try_from(resp.slash_sig.as_slice())
.map_err(|e| anyhow::anyhow!("slash_sig parse: {e}"))?;
StreamSlashData::from_response_body(&resp.body)
.verify_signer(&sig, expected_signer, slash_domain)
.map_err(|e| anyhow::anyhow!("slash_sig verification failed: {e}"))?;
Ok(())
}
async fn write_message(send: &mut SendStream, msg: &ClientMessage) -> anyhow::Result<()> {
let payload = encode_message(msg)
.map_err(|e| anyhow::anyhow!("encode failed: {e}").context(LocalPullFault))?;
write_frame(send, &payload)
.await
.map_err(|e| anyhow::anyhow!("write failed: {e}"))
}
async fn read_stream_response(
recv: &mut RecvStream,
) -> anyhow::Result<(StreamResponse, StreamResponseExt)> {
let frame = read_frame(recv)
.await
.map_err(|e| rate_limited::transport_error("frame read failed", e))?;
let (msg, tail) = decode_message::<ClientMessage>(&frame)
.map_err(|e| anyhow::anyhow!("decode failed: {e}"))?;
let ClientMessage::StreamResponse(response) = msg else {
anyhow::bail!("expected StreamResponse, got {}", variant_name(&msg));
};
let ext = decdn_protocol::parse_stream_response_ext(tail)
.map_err(|e| anyhow::anyhow!("decode stream response ext: {e}"))?;
Ok((response, ext))
}
async fn read_client_message<R: tokio::io::AsyncRead + Unpin>(
recv: &mut R,
) -> anyhow::Result<ClientMessage> {
let frame = read_frame(recv)
.await
.map_err(|e| anyhow::anyhow!("frame read failed: {e}"))?;
let (msg, _rest) = decode_message::<ClientMessage>(&frame)
.map_err(|e| anyhow::anyhow!("decode failed: {e}"))?;
Ok(msg)
}
const fn variant_name(msg: &ClientMessage) -> &'static str {
match msg {
ClientMessage::StreamRequest(_) => "StreamRequest",
ClientMessage::StreamResponse(_) => "StreamResponse",
ClientMessage::ChunkData(_) => "ChunkData",
ClientMessage::Voucher(_) => "Voucher",
ClientMessage::ChunkPreimage(_) => "ChunkPreimage",
ClientMessage::StreamEnd => "StreamEnd",
ClientMessage::StreamError(_) => "StreamError",
}
}
fn record_open_result<T>(span: &tracing::Span, result: &anyhow::Result<T>) {
if let Err(e) = result {
span.record("error", tracing::field::display(format_args!("{e:#}")));
span.record("otel.status_code", "ERROR");
}
}
#[cfg(test)]
mod tests;