use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use alloy::primitives::U256;
use decdn_client::buyer_pool::UnconfirmedOpens;
use decdn_client::funding::{RunFunding, cached_pool_token};
use decdn_client::lane_sources::{FaultContext, LaneSources};
use decdn_client::lanes::{LaneDeps, LanePayer, STALL_WINDOW, SettleOnDrop};
use decdn_client::session::{
ChainAdoption, ChainConfig, DepositPolicy, DiscoveryOptions, PinnedNode, ProxyWarmingParams,
PullSite,
};
use decdn_client::targets::{
ProbeOpts, ProbeRound, ResolveConfigFault, holders_or_none, resolve_target_node,
};
use decdn_client::{
Connections, CredentialSlot, DownloadTarget, Downloader, Holder, LaneLedgers,
NoAffordableSource, NoCache, NoSourceHasBlob, PeerHealth, PoolContext, PoolReplaced,
ProgressClock, PullConfig, PullDeadlines, RecoveryGate, StopPolicy, Streamer, UpstreamRefused,
UpstreamVoucherRejected,
};
use decdn_common::cli::{self, common::expand_tilde};
use decdn_common::config::{DEFAULT_CHAIN_ID, FileConfig, load_file_config};
use decdn_incentive::buyer_pool::BuyerPoolStore;
use decdn_incentive::eth_identity::{self, PasswordUse, load_signer};
use decdn_incentive::payment_pool::PaymentPool;
use decdn_incentive::rate::min_payment;
use decdn_incentive::{CapabilityGrant, PoolId, slash_judge_domain, voucher_domain};
use decdn_protocol::client::StreamError;
use iroh::{Endpoint, PublicKey, RelayUrl};
use decdn_client::endpoint as client_endpoint;
use decdn_client::provider;
use super::buyer_store::{DataDirSource, chain_adoption_for_buy, open_client_store_for_buy};
use super::fetch_timings::{FetchTimings, Mark};
use super::interrupt::{Interrupt, Interrupted, name_unconfirmed_open};
use super::tab_progress::TabProgress;
use decdn_client::ordered_writes::OrderedWrites;
pub(crate) use decdn_client::bundle::parse_hash;
use decdn_client::uri::DecdnUri;
fn new_progress_bar() -> indicatif::ProgressBar {
let style = indicatif::ProgressStyle::with_template(
"{spinner:.green} {bytes}/{total_bytes} {msg}[{wide_bar:.cyan/blue}]",
)
.unwrap_or_else(|_| indicatif::ProgressStyle::default_bar())
.progress_chars("=>-");
let bar = crate::logging::attach_progress_bar(
indicatif::ProgressBar::new(0).with_finish(indicatif::ProgressFinish::AndClear),
);
bar.set_style(style);
bar.enable_steady_tick(Duration::from_millis(120));
bar
}
#[derive(Debug)]
pub(crate) struct ResolvedChain {
pub(crate) config: ChainConfig,
pub(crate) site: PullSite,
pub(crate) keystore: PathBuf,
pub(crate) keystore_password_file: Option<PathBuf>,
pub(crate) data_dir_source: DataDirSource,
pub(crate) working_deposit: U256,
pub(crate) max_approve: bool,
}
impl ResolvedChain {
pub(crate) fn deposit_policy(&self, delegated: bool) -> anyhow::Result<DepositPolicy> {
let adoption = if delegated {
ChainAdoption::Refused
} else {
chain_adoption_for_buy(&self.site.data_dir, &self.keystore)?
};
Ok(DepositPolicy {
working_deposit: self.working_deposit,
max_approve: self.max_approve,
adoption,
})
}
}
fn parse_region_allowlist(file: &FileConfig) -> Vec<decdn_protocol::Region> {
file.client
.as_ref()
.and_then(|c| c.region_allowlist.as_ref())
.into_iter()
.flatten()
.filter_map(|code| {
let parsed = decdn_protocol::Region::parse(code);
if parsed.is_none() {
tracing::warn!(
"client.region_allowlist entry {code:?} is not a recognized \
region code; dropping it from the filter"
);
}
parsed
})
.collect()
}
pub(crate) fn resolve_chain(
args: &cli::ClientFetchArgs,
file: &FileConfig,
) -> anyhow::Result<ResolvedChain> {
let bc = file.blockchain.as_ref();
let rpc_url = args
.rpc_url
.clone()
.or_else(|| bc.and_then(|b| b.rpc_url.clone()))
.ok_or_else(|| anyhow::anyhow!("rpc_url not set (--rpc-url or blockchain.rpc_url)"))?;
let pp_raw = args
.payment_pool_address
.clone()
.or_else(|| bc.and_then(|b| b.payment_pool_address.clone()))
.ok_or_else(|| {
anyhow::anyhow!(
"payment_pool_address not set (--payment-pool-address or \
blockchain.payment_pool_address)"
)
})?;
let payment_pool = super::chain_ctx::parse_nonzero_address(&pp_raw, "payment_pool_address")?;
let sj_raw = args
.slash_judge_address
.clone()
.or_else(|| bc.and_then(|b| b.slash_judge_address.clone()))
.ok_or_else(|| {
anyhow::anyhow!(
"slash_judge_address not set (--slash-judge-address or \
blockchain.slash_judge_address)"
)
})?;
let slash_judge = super::chain_ctx::parse_nonzero_address(&sj_raw, "slash_judge_address")?;
let capacity_bond = args
.capacity_bond_address
.clone()
.or_else(|| bc.and_then(|b| b.capacity_bond_address.clone()))
.map(|raw| super::chain_ctx::parse_nonzero_address(&raw, "capacity_bond_address"))
.transpose()?;
let region = args
.region
.clone()
.or_else(|| file.identity.as_ref().and_then(|i| i.region.clone()));
let region_allowlist = parse_region_allowlist(file);
let chain_id = args
.chain_id
.or_else(|| bc.and_then(|b| b.chain_id))
.unwrap_or(DEFAULT_CHAIN_ID);
let (data_dir, data_dir_source) = args
.data_dir
.clone()
.map(|p| (p, DataDirSource::Flag))
.or_else(|| {
file.identity
.as_ref()
.and_then(|i| i.data_dir.clone())
.map(|p| (p, DataDirSource::Config))
})
.map(|(p, source)| (expand_tilde(&p), source))
.or_else(|| cli::default_client_data_dir().map(|p| (p, DataDirSource::Default)))
.ok_or_else(|| {
anyhow::anyhow!("data_dir not set and no default available (pass --data-dir)")
})?;
let keystore = args
.keystore
.clone()
.or_else(|| bc.and_then(|b| b.eth_keystore.clone()))
.map_or_else(
|| eth_identity::keystore_path(&data_dir),
|p| expand_tilde(&p),
);
let working_deposit_micro_usdc = args
.working_deposit_micro_usdc
.or_else(|| bc.and_then(|b| b.buyer_working_deposit_micro_usdc))
.unwrap_or(decdn_common::config::DEFAULT_BUYER_WORKING_DEPOSIT_MICRO_USDC);
anyhow::ensure!(
working_deposit_micro_usdc > 0,
"buyer_working_deposit_micro_usdc must be > 0 (openPool reverts ZeroAmount on a \
zero deposit) — set --working-deposit-micro-usdc or \
blockchain.buyer_working_deposit_micro_usdc"
);
let working_deposit = U256::from(working_deposit_micro_usdc);
let max_approve = bc.and_then(|b| b.buyer_max_approve).unwrap_or(false);
Ok(ResolvedChain {
config: ChainConfig {
rpc_url,
chain_id,
payment_pool,
slash_judge,
capacity_bond,
},
site: PullSite {
data_dir,
region,
region_allowlist,
},
keystore,
keystore_password_file: args.keystore_password_file.as_deref().map(expand_tilde),
data_dir_source,
working_deposit,
max_approve,
})
}
pub(crate) fn proxy_warming(args: &cli::ClientFetchArgs) -> ProxyWarmingParams {
let ms = |v: u64| f64::from(u32::try_from(v).unwrap_or(u32::MAX));
ProxyWarmingParams {
enabled: args.proxy_warming,
rtt_threshold_ms: ms(args.proxy_warming_rtt_threshold_ms),
margin_ms: ms(args.proxy_warming_margin_ms),
}
}
pub(crate) fn discovery_options(args: &cli::ClientFetchArgs) -> anyhow::Result<DiscoveryOptions> {
Ok(DiscoveryOptions {
pinned: pinned_node(args)?,
rediscover: args.rediscover,
max_sources: args.max_sources,
registry_timeout: args.discovery_cap(),
proxy_warming: proxy_warming(args),
})
}
fn pinned_node(args: &cli::ClientFetchArgs) -> anyhow::Result<Option<PinnedNode>> {
let Some(raw) = &args.node_id else {
return Ok(None);
};
let config = |e: anyhow::Error| anyhow::Error::new(ResolveConfigFault(format!("{e:#}")));
let node_id = PublicKey::from_str(raw)
.map_err(|e| config(anyhow::anyhow!("invalid --node-id {raw:?}: {e}")))?;
let provider_raw = args.provider_address.as_deref().ok_or_else(|| {
config(anyhow::anyhow!(
"--provider-address is required with --node-id"
))
})?;
let provider =
super::chain_ctx::parse_address(provider_raw, "--provider-address").map_err(config)?;
Ok(Some(PinnedNode {
node_id,
provider,
addr: args.addr,
}))
}
pub(crate) fn annotate_unbound_cache_miss(err: anyhow::Error, ctx: &PoolContext) -> anyhow::Error {
let refused = err
.downcast_ref::<UpstreamRefused>()
.filter(|refused| matches!(refused.error(), StreamError::NotFound));
if refused.is_none() && err.downcast_ref::<NoSourceHasBlob>().is_none() {
return err;
}
let mut causes: Vec<String> = Vec::new();
if let Some(quoted_rate) = refused
.and_then(UpstreamRefused::evidence)
.map(|resp| resp.body.rate_per_mb)
{
let headroom = ctx.deposit.saturating_sub(ctx.prior_amount);
let estimate = min_payment(decdn_protocol::client::CHUNK_BYTES, quoted_rate);
if quoted_rate > 0 && headroom < estimate {
causes.push(format!(
"this pool's remaining deposit ({headroom}) is below the ~{estimate} the node \
reserves before serving at its quoted rate of {quoted_rate} per MB. The fetch \
path already auto-refills below a low-water mark, so reaching this means the \
configured working deposit is itself too small: raise \
`--working-deposit-micro-usdc` (or `blockchain.buyer_working_deposit_micro_usdc`) \
and retry"
));
}
}
if ctx.client_binding.is_none() {
causes.push(
"no client identity binding was sent because \
blockchain.capacity_bond_address is unset, so the node could not \
reactively pull this cache-missed blob from its origin; set \
blockchain.capacity_bond_address to enable reactive pull-through"
.to_string(),
);
}
match causes.len() {
0 => err,
1 => err.context(causes.concat()),
n => err.context(format!(
"{n} possible causes: {}",
causes
.iter()
.enumerate()
.map(|(i, c)| format!("({}) {c}", i + 1))
.collect::<Vec<_>>()
.join("; ")
)),
}
}
pub(crate) async fn fetch(
args: &cli::GetArgs,
uri: &DecdnUri,
config_path: Option<&Path>,
) -> anyhow::Result<()> {
let blob = BlobFetch {
hash: uri.hash,
namespace: uri.namespace,
output: args.output.clone(),
common: args.common.clone(),
};
let timings = FetchTimings::start();
let (host, grant) = FetchHost::open(&blob.common, config_path, false).await?;
timings.mark(Mark::Endpoint);
let payer = Payer::keystore(grant);
let mut interrupt = Interrupt::watch(host.opens.clone());
let mut sink = Sink::Output;
let result = tokio::select! {
result = fetch_on(&host, &payer, &blob, &mut sink, &timings) => result,
() = interrupt.wait() => Err(Interrupted.into()),
};
let result = name_unconfirmed_open(result, &host.opens.pending());
let output = if wants_stdout(&blob.output) {
"stdout"
} else {
"file"
};
timings.log(output, result.is_ok());
host.close().await;
result
}
pub(crate) struct BlobFetch {
pub(crate) hash: [u8; 32],
pub(crate) namespace: Option<u64>,
pub(crate) output: PathBuf,
pub(crate) common: cli::ClientFetchArgs,
}
pub(crate) struct Credential {
pub(crate) signer: Arc<alloy::signers::local::PrivateKeySigner>,
pub(crate) grant: CapabilityGrant,
}
pub(crate) enum Sink {
Output,
Writer(Box<dyn tokio::io::AsyncWrite + Send + Unpin>),
}
impl Sink {
fn streams(&self, output: &Path) -> bool {
match self {
Self::Output => wants_stdout(output),
Self::Writer(_) => true,
}
}
}
pub(crate) struct FetchHost {
relays: Vec<RelayUrl>,
chain: ResolvedChain,
store: Arc<dyn BuyerPoolStore>,
endpoint: Endpoint,
connections: Connections,
health: Arc<PeerHealth>,
writes: OrderedWrites,
pub(crate) opens: UnconfirmedOpens,
token: tokio::sync::OnceCell<alloy::primitives::Address>,
}
impl FetchHost {
pub(crate) async fn open(
common: &cli::ClientFetchArgs,
config_path: Option<&Path>,
held_key: bool,
) -> anyhow::Result<(Self, Option<CapabilityGrant>)> {
common.validate()?;
let relays = client_endpoint::resolve_relays(common.relay_url.as_deref(), config_path)?;
let disc = client_endpoint::client_discovery(config_path)?;
let file = load_file_config(config_path)?;
let mut chain = resolve_chain(common, &file)?;
let grant = if held_key {
chain.working_deposit = U256::ZERO;
None
} else {
resolve_delegation_grant(common, &mut chain)?
};
let store: Arc<dyn BuyerPoolStore> = Arc::new(open_client_store_for_buy(
&chain.site.data_dir,
chain.data_dir_source,
"fetch",
)?);
let endpoint = client_endpoint::client_endpoint(&relays, &disc).await?;
let host = Self {
relays,
chain,
store,
connections: Connections::new(endpoint.clone()),
endpoint,
health: Arc::new(PeerHealth::default()),
writes: OrderedWrites::default(),
opens: UnconfirmedOpens::default(),
token: tokio::sync::OnceCell::new(),
};
Ok((host, grant))
}
pub(crate) async fn close(self) {
self.writes.settle().await;
self.endpoint.close().await;
}
}
pub(crate) struct Payer {
key: PayerKey,
ledgers: Option<LaneLedgers>,
recovery: Arc<RecoveryGate>,
open_lock: tokio::sync::Mutex<()>,
}
enum PayerKey {
Keystore(Option<CapabilityGrant>),
Held {
signer: Arc<alloy::signers::local::PrivateKeySigner>,
slot: CredentialSlot,
},
}
impl Payer {
pub(crate) fn keystore(grant: Option<CapabilityGrant>) -> Self {
Self {
key: PayerKey::Keystore(grant),
ledgers: None,
recovery: Arc::new(RecoveryGate::new()),
open_lock: tokio::sync::Mutex::new(()),
}
}
pub(crate) fn held(credential: Credential) -> anyhow::Result<Self> {
let slot = delegated_slot(&credential.grant, Arc::clone(&credential.signer))?;
Ok(Self {
key: PayerKey::Held {
signer: credential.signer,
slot,
},
ledgers: Some(LaneLedgers::new()),
recovery: Arc::new(RecoveryGate::new()),
open_lock: tokio::sync::Mutex::new(()),
})
}
const fn delegated(&self) -> bool {
match &self.key {
PayerKey::Keystore(grant) => grant.is_some(),
PayerKey::Held { .. } => true,
}
}
}
#[allow(clippy::too_many_lines)]
pub(crate) async fn fetch_on(
host: &FetchHost,
payer: &Payer,
args: &BlobFetch,
sink: &mut Sink,
timings: &FetchTimings,
) -> anyhow::Result<()> {
let FetchHost {
relays,
chain,
store,
endpoint,
connections,
health,
writes,
opens,
token,
} = host;
let hash = args.hash;
let common = &args.common;
let streams = sink.streams(&args.output);
let discovery = discovery_options(common)?;
let probe = ProbeOpts {
marks: Some(timings),
round: ProbeRound::Stream,
};
let resolved = holders_or_none(
resolve_target_node(
&discovery,
(&chain.config, &chain.site),
endpoint,
relays,
hash,
probe,
)
.await,
)?;
timings.mark(Mark::Resolved);
let (signer, slot) = match &payer.key {
PayerKey::Held { signer, slot } => (Arc::clone(signer), Some(slot.clone())),
PayerKey::Keystore(grant) => {
let password = super::chain_ctx::read_keystore_password(
&super::chain_ctx::password_sources(
chain.keystore_password_file.as_deref(),
PasswordUse::Unlock,
),
"eth keystore password",
)?
.into_secret();
let signer = Arc::new(load_signer(&chain.keystore, &password)?);
let slot = grant
.as_ref()
.map(|grant| delegated_slot(grant, Arc::clone(&signer)))
.transpose()?;
(signer, slot)
}
};
timings.mark(Mark::Unlocked);
let self_address = signer.address();
let config = &chain.config;
let rpc = provider::build_provider(&config.rpc_url, &signer)?;
let contract = PaymentPool::new(config.payment_pool, rpc.clone());
let voucher_dom = voucher_domain(config.chain_id, config.payment_pool);
let slash_dom = slash_judge_domain(config.chain_id, config.slash_judge);
let token = *token
.get_or_try_init(|| async {
match cached_pool_token(store.as_ref(), self_address, config.deployment())? {
Some(token) => anyhow::Ok(token),
None => contract
.usdc()
.call()
.await
.map_err(|e| anyhow::Error::new(e).context("read PaymentPool.usdc()")),
}
})
.await?;
let max_blob_bytes = common.max_blob_mb.saturating_mul(1024 * 1024);
let namespace_id = args
.namespace
.map_or(decdn_protocol::client::NO_NAMESPACE, |n| {
alloy::primitives::U256::from(n).to_be_bytes()
});
let deadlines = PullDeadlines::new(STALL_WINDOW, STALL_WINDOW, 0)?;
let clock = Arc::new(ProgressClock::new());
let stop = StopPolicy::new(
std::io::IsTerminal::is_terminal(&std::io::stderr()),
common.give_up_after(),
clock,
);
let deposit = chain.deposit_policy(payer.delegated())?;
let recovery = &payer.recovery;
let mut replaced: Option<PoolReplaced> = None;
loop {
let funding = RunFunding::reporting_opens_to(opens.clone());
let deps = LaneDeps {
endpoint,
store,
contract: &contract,
rpc: &rpc,
slash_dom: &slash_dom,
self_address,
token,
chain: config,
site: &chain.site,
deposit,
namespace_id,
max_rate_per_mb: common.max_rate_per_mb,
max_blob_bytes,
deadlines,
connections,
writes,
funding: &funding,
marks: Some(timings),
};
let sources = LaneSources::new(
&deps,
&discovery,
relays,
match &slot {
Some(slot) => LanePayer::Delegated(slot),
None => LanePayer::Owned(&signer),
},
&voucher_dom,
Some(&payer.open_lock),
payer.ledgers.as_ref(),
None,
recovery,
);
let holders = sources.holders_from(&resolved);
if replaced.is_none() {
timings.set_holders_start(holders.len());
}
let on_drop = SettleOnDrop::new(|| sources.persist_watermarks_detached());
let result = async {
let claim = sources.first_claim(hash, holders, health, &stop).await?;
if streams {
return stream_out(
&deps,
&sources,
claim.holders,
Arc::clone(health),
hash,
claim.total_bytes,
stop.clone(),
(common.max_sources, timings),
&mut *sink,
)
.await;
}
download_to_file(
&deps,
&sources,
claim.holders,
Arc::clone(health),
DownloadTarget {
hash,
total_bytes: claim.total_bytes,
dest: &args.output,
ranges: None,
},
&stop,
(common.max_sources, timings),
)
.await
}
.await;
on_drop.disarm();
sources.persist_watermarks().await;
if let Some(shortfall) = funding.shortfall() {
eprintln!("warning: {shortfall}");
}
let result = result.map_err(|err| annotate(err, &sources.fault_context()));
match next_pass(result, &mut replaced, streams) {
Some(pass_result) => return pass_result,
None => recovery.start_next_pass(),
}
}
}
fn next_pass(
result: anyhow::Result<()>,
replaced: &mut Option<PoolReplaced>,
streams: bool,
) -> Option<anyhow::Result<()>> {
let now_replaced = result
.as_ref()
.err()
.and_then(|err| err.downcast_ref::<PoolReplaced>().copied());
if let Some(new) = now_replaced
&& replaced.is_none()
&& !streams
{
eprintln!("note: {new}");
*replaced = Some(new);
return None;
}
if let Some(earlier) = (*replaced).or(now_replaced) {
eprintln!(
"note: pool {} accepts no more funds and may still hold a balance; once its \
dispute window ends, reclaim it with: decdn pool reclaim --pool {}",
earlier.closed, earlier.closed
);
}
Some(result.map_err(|err| match (now_replaced, *replaced) {
(Some(_), Some(_)) => err.context(
"funding needed: the pool this command opened accepts no more funds either; run \
the command again",
),
(Some(new), None) => err.context(format!(
"pool {} is now the current pool; run the command again to stream from it",
new.opened
)),
_ => err,
}))
}
pub(crate) fn annotate_delegated_exhaustion(err: anyhow::Error) -> anyhow::Error {
use decdn_protocol::client::VoucherRejectReason;
let funding_rejection = err
.downcast_ref::<UpstreamVoucherRejected>()
.is_some_and(|rejected| {
matches!(
rejected.reason,
VoucherRejectReason::SpendingCapExhausted
| VoucherRejectReason::CapabilityExpired
| VoucherRejectReason::PoolExhausted
| VoucherRejectReason::SignerCapExhausted
)
});
let underpaid = err
.downcast_ref::<UpstreamVoucherRejected>()
.is_some_and(|rejected| rejected.reason == VoucherRejectReason::Underpaid);
if let Some(&decdn_client::FundingNeeded::PublisherPool { pool }) = err.downcast_ref() {
return err.context(format!(
"ask the owner of pool {pool} to top it up: the capability still covers the fetch, \
and a delegated client cannot top up a pool it does not own. A node refuses a \
signer whose registered terms are spent the same way; once this signer key is \
registered on-chain its terms are write-once, so the owner then issues a \
capability for a new signer key"
));
}
if funding_rejection
|| err.downcast_ref::<NoAffordableSource>().is_some()
|| err.downcast_ref::<decdn_client::FundingNeeded>().is_some()
{
err.context(
"funding needed: no provider serves this capability at its current funding. Ask \
the pool owner to top up the pool or issue a fresh capability; once this signer \
key is registered on-chain its terms are write-once, so the fresh capability must \
name a new signer key (a delegated client cannot top up a pool it does not own)",
)
} else if underpaid {
err.context(
"the node kept refusing this lane's vouchers as underpaid after the resync attempts \
ran out — retry the fetch; a lane with no accepted voucher yet means the signed \
price is below the node's quote",
)
} else {
err
}
}
pub(crate) fn annotate(err: anyhow::Error, at: &FaultContext) -> anyhow::Error {
let err = match &at.first_lane {
Some(ctx) => annotate_with(err, ctx),
None => err,
};
if at.delegated {
return annotate_delegated_exhaustion(err);
}
with_top_up_hint(err, at.pool_id)
}
pub(crate) fn with_top_up_hint(err: anyhow::Error, pool_id: Option<PoolId>) -> anyhow::Error {
if err.downcast_ref::<NoAffordableSource>().is_none() {
return err;
}
let pool = pool_id.map_or_else(|| "<pool id>".to_string(), |id| id.to_string());
err.context(format!(
"top up with: decdn pool top-up --pool {pool} --amount-micro-usdc <MICRO_USDC>"
))
}
fn annotate_with(err: anyhow::Error, ctx: &Mutex<PoolContext>) -> anyhow::Error {
match ctx.lock() {
Ok(guard) => annotate_unbound_cache_miss(err, &guard),
Err(_) => err,
}
}
async fn copy_verified<R, W>(
reader: &mut R,
sink: &mut W,
total_bytes: u64,
on_progress: Option<&dyn Fn(u64, u64)>,
) -> anyhow::Result<u64>
where
R: tokio::io::AsyncRead + Unpin,
W: tokio::io::AsyncWrite + Unpin + ?Sized,
{
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buf = vec![0u8; 256 * 1024];
let mut drained: u64 = 0;
loop {
let n = reader
.read(&mut buf)
.await
.map_err(|e| anyhow::anyhow!("read verified stream: {e}"))?;
if n == 0 {
break;
}
let chunk = buf
.get(..n)
.ok_or_else(|| anyhow::anyhow!("read returned more than the buffer holds"))?;
sink.write_all(chunk)
.await
.map_err(|e| anyhow::anyhow!("write verified bytes: {e}"))?;
drained = drained.saturating_add(u64::try_from(n).unwrap_or(u64::MAX));
if let Some(cb) = on_progress {
cb(drained, total_bytes);
}
}
sink.flush()
.await
.map_err(|e| anyhow::anyhow!("flush verified bytes: {e}"))?;
Ok(drained)
}
#[allow(clippy::too_many_arguments)]
async fn stream_out<P>(
deps: &LaneDeps<'_, P>,
sources: &LaneSources<'_, P>,
holders: Vec<Holder>,
health: Arc<PeerHealth>,
hash: [u8; 32],
total_bytes: u64,
stop: StopPolicy,
(max_sources, timings): (usize, &FetchTimings),
sink: &mut Sink,
) -> anyhow::Result<()>
where
P: alloy::providers::Provider + Clone,
{
let pull_config = PullConfig {
streamer_lane_cap: max_sources.max(1),
..PullConfig::new()
};
let funder = sources.funder();
let scratch = tempfile::tempdir_in(&deps.site.data_dir)
.map_err(|e| anyhow::anyhow!("open stream scratch dir: {e}"))?;
let mut streamer = Streamer::new(sources, funder, scratch.path())
.holders(holders)
.health(health)
.recovery(Arc::clone(sources.recovery()))
.max_blob_bytes(deps.max_blob_bytes);
if let Some(slot) = sources.credentials() {
streamer = streamer.credentials(slot);
}
let (mut reader, mut drive) = streamer
.open(hash, total_bytes, &pull_config, Arc::new(NoCache), stop)
.await?;
let bar = (matches!(sink, Sink::Output)
&& std::io::IsTerminal::is_terminal(&std::io::stderr()))
.then(delivery_progress);
let on_progress = |received: u64, expected: u64| {
if received > 0 {
timings.mark(Mark::FirstByte);
}
if let Some((_, cb, _)) = &bar {
cb(received, expected);
}
};
let mut stdout;
let out: &mut (dyn tokio::io::AsyncWrite + Send + Unpin) = match sink {
Sink::Writer(writer) => writer.as_mut(),
Sink::Output => {
stdout = tokio::io::stdout();
&mut stdout
}
};
let copy_result = drive
.alongside(copy_verified(
&mut reader,
out,
total_bytes,
Some(&on_progress),
))
.await;
if let Some((bar, _, _)) = &bar {
bar.finish_and_clear();
}
let copied = copy_result.map_err(|read_err| stream_error(read_err, drive.take_error()))?;
tracing::info!("streamed {copied} verified bytes");
Ok(())
}
fn stream_error(read_err: anyhow::Error, fetch_err: Option<anyhow::Error>) -> anyhow::Error {
fetch_err.unwrap_or(read_err)
}
async fn download_to_file<P>(
deps: &LaneDeps<'_, P>,
sources: &LaneSources<'_, P>,
holders: Vec<Holder>,
health: Arc<PeerHealth>,
target: DownloadTarget<'_>,
stop: &StopPolicy,
(max_sources, timings): (usize, &FetchTimings),
) -> anyhow::Result<()>
where
P: alloy::providers::Provider + Clone,
{
let (bar, on_bar, meter) = delivery_progress();
let on_progress = |received: u64, expected: u64| {
if received > 0 {
timings.mark(Mark::FirstByte);
}
on_bar(received, expected);
};
let mut downloader = Downloader::new(sources, sources.funder())
.holders(holders)
.health(health)
.recovery(Arc::clone(sources.recovery()))
.max_lanes(max_sources)
.max_blob_bytes(deps.max_blob_bytes);
if let Some(slot) = sources.credentials() {
downloader = downloader.credentials(slot);
}
let result =
Box::pin(downloader.fetch_to_paths_until(&[target], Some(&on_progress), stop)).await;
bar.finish_and_clear();
result?;
let proven = tokio::fs::metadata(target.dest)
.await
.map_or(target.total_bytes, |m| m.len());
print_fetch_summary(proven, &meter, target.dest);
Ok(())
}
fn wants_stdout(output: &Path) -> bool {
output == Path::new("-")
}
const RATE_SMOOTHING_TAU_SECS: f64 = 3.0;
#[derive(Default)]
pub(crate) struct SpeedState {
started: Option<(Instant, u64)>,
last: Option<(Instant, u64)>,
ewma_bps: Option<f64>,
}
impl SpeedState {
pub(crate) fn observe(&mut self, now: Instant, received: u64) -> f64 {
self.started.get_or_insert((now, received));
if let Some((prev_at, prev_bytes)) = self.last {
let dt = now.saturating_duration_since(prev_at).as_secs_f64();
if dt > 0.0 {
let inst = bytes_as_f64(received.saturating_sub(prev_bytes)) / dt;
let alpha = 1.0 - (-dt / RATE_SMOOTHING_TAU_SECS).exp();
let prev_bps = self.ewma_bps.unwrap_or(0.0);
self.ewma_bps = Some(prev_bps + alpha * (inst - prev_bps));
}
}
self.last = Some((now, received));
self.ewma_bps.unwrap_or(0.0)
}
}
#[expect(
clippy::cast_precision_loss,
reason = "byte counts stay far below f64's 2^53 exact-integer ceiling"
)]
pub(crate) const fn bytes_as_f64(n: u64) -> f64 {
n as f64
}
pub(crate) fn fmt_rate(bps: f64) -> String {
if bps.is_finite() && bps >= 1.0 {
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "bps is finite and >= 1.0 here; the cast cannot wrap or go negative"
)]
let whole = bps.min(bytes_as_f64(u64::MAX)) as u64;
format!("{}/s", indicatif::HumanBytes(whole))
} else {
"--".to_string()
}
}
pub(crate) fn fmt_eta(remaining_bytes: u64, bps: f64) -> String {
if bps >= 1.0 {
let secs = (bytes_as_f64(remaining_bytes) / bps).clamp(0.0, 8.64e7);
format!(
"ETA {}",
indicatif::HumanDuration(Duration::from_secs_f64(secs))
)
} else {
"ETA --".to_string()
}
}
#[derive(Clone)]
pub(crate) struct DeliveryMeter {
state: Arc<Mutex<SpeedState>>,
}
impl DeliveryMeter {
fn summary(&self) -> Option<(Duration, u64)> {
let state = self.state.lock().ok()?;
let (start_at, start_bytes) = state.started?;
let (last_at, last_bytes) = state.last?;
Some((
last_at.saturating_duration_since(start_at),
last_bytes.saturating_sub(start_bytes),
))
}
}
fn delivery_progress() -> (
indicatif::ProgressBar,
impl Fn(u64, u64) + 'static,
DeliveryMeter,
) {
let bar = new_progress_bar();
let (on_progress, meter) = bar_callback(bar.clone(), TabProgress::detect(true));
(bar, on_progress, meter)
}
fn bar_callback(
bar: indicatif::ProgressBar,
tab: Option<Arc<TabProgress>>,
) -> (impl Fn(u64, u64) + 'static, DeliveryMeter) {
let length = std::sync::atomic::AtomicU64::new(u64::MAX);
let state = Arc::new(Mutex::new(SpeedState::default()));
let cb_state = Arc::clone(&state);
let on_progress = move |received: u64, expected: u64| {
if length.swap(expected, std::sync::atomic::Ordering::Relaxed) != expected {
bar.set_length(expected);
}
bar.set_position(received);
if let Some(tab) = &tab {
tab.update(received, expected);
}
if let Ok(mut s) = cb_state.lock() {
let bps = s.observe(Instant::now(), received);
bar.set_message(format!(
"({}, {}) ",
fmt_rate(bps),
fmt_eta(expected.saturating_sub(received), bps)
));
}
};
(on_progress, DeliveryMeter { state })
}
fn print_fetch_summary(content_bytes: u64, meter: &DeliveryMeter, output: &Path) {
match meter.summary() {
Some((elapsed, moved_bytes)) if elapsed > Duration::ZERO && moved_bytes > 0 => {
let avg_bps = bytes_as_f64(moved_bytes) / elapsed.as_secs_f64();
println!(
"fetched {content_bytes} bytes in {} (avg {}) -> {}",
indicatif::HumanDuration(elapsed),
fmt_rate(avg_bps),
output.display()
);
}
_ => println!("fetched {content_bytes} bytes -> {}", output.display()),
}
}
pub(crate) fn delegated_slot(
grant: &CapabilityGrant,
signer: Arc<alloy::signers::local::PrivateKeySigner>,
) -> anyhow::Result<CredentialSlot> {
let capability = grant.to_signed_capability().map_err(|e| {
anyhow::anyhow!("capability token carries a malformed owner signature: {e}")
})?;
let slot = CredentialSlot::new(signer, capability).map_err(|mismatch| {
let (authorized, loaded) = (mismatch.capability_signer, mismatch.key);
anyhow::anyhow!(
"this capability authorizes signer {authorized}, but the loaded key is {loaded} — \
load the delegate keystore the pool owner assigned (--keystore / \
blockchain.eth_keystore), or ask for a capability issued to {loaded}"
)
})?;
Ok(slot.with_swap_wait(std::time::Duration::ZERO))
}
pub(crate) fn resolve_delegation_grant(
common: &cli::ClientFetchArgs,
chain: &mut ResolvedChain,
) -> anyhow::Result<Option<CapabilityGrant>> {
let grant = common
.resolve_capability_token()?
.map(|token| CapabilityGrant::from_token(&token))
.transpose()
.map_err(|e| anyhow::anyhow!("invalid --capability token: {e}"))?;
if grant.is_some() {
chain.working_deposit = U256::ZERO;
}
Ok(grant)
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic
)]
mod tests;