Skip to main content

ant_core/data/client/
mod.rs

1//! Client operations for the Autonomi network.
2//!
3//! Provides high-level APIs for storing and retrieving data
4//! on the Autonomi decentralized network.
5
6pub mod adaptive;
7pub mod batch;
8pub mod cache;
9pub(crate) mod cached_merkle;
10pub(crate) mod cached_single;
11pub mod chunk;
12pub mod data;
13pub mod diagnostics;
14pub mod file;
15pub mod merkle;
16pub mod payment;
17pub mod quote;
18
19use crate::data::client::adaptive::{AdaptiveConfig, AdaptiveController, ChannelStart, Outcome};
20use crate::data::client::cache::ChunkCache;
21use crate::data::error::{Error, Result};
22use crate::data::network::{Network, NetworkHealth};
23use crate::data::peer_cache;
24use ant_protocol::evm::Wallet;
25use ant_protocol::transport::{MultiAddr, P2PNode, PeerId};
26use ant_protocol::{XorName, CLOSE_GROUP_SIZE};
27use std::collections::HashSet;
28use std::path::PathBuf;
29use std::sync::atomic::{AtomicU64, Ordering};
30use std::sync::Arc;
31use std::sync::Mutex;
32use tracing::debug;
33
34/// Width of the chunk PUT-target set (initial writes plus fallback): the
35/// closest `PUT_TARGET_WIDTH` peers to the address.
36///
37/// Mirrors the node-side `K_BUCKET_SIZE` / `PAID_QUOTE_ISSUER_CLOSENESS_WIDTH`
38/// (20): a node accepts a reused payment proof only when one of the proof's
39/// closest-`CLOSE_GROUP_SIZE` quote issuers is within its own local 20-closest,
40/// so trying peers past this width is pointless.
41pub(crate) const PUT_TARGET_WIDTH: usize = 20;
42
43/// Ceiling on how long to wait for a peer to answer a settlement-versioned
44/// quote request before falling back to the unversioned shape.
45///
46/// A storer that predates the versioned request cannot decode it and never
47/// replies, so the only way to find out is to wait.
48///
49/// Note what is being waited for. This is **not** a cheap parse check: a peer
50/// that understands the request runs the whole quote handler, queueing,
51/// storage reads, pricing and signing, so the answer takes as long as any
52/// quote takes. Abandoning the wait does not cancel that work either, it just
53/// stops listening and adds a duplicate legacy request on top. So the wait has
54/// to be sized for a real quote, not for a ping.
55///
56/// The full timeout is what made this expensive. The merkle E2E suite runs
57/// with `quote_timeout_secs = 120`, so every probe against a fleet on the
58/// published node cost two minutes and the suite blew the 60-minute CI cap.
59///
60/// # It must never bind in production
61///
62/// **Keep this at or above the largest production `quote_timeout_secs`**,
63/// currently 10s. That is not a tuning preference, it is the safety property.
64///
65/// This wait is the only window in which a peer can refuse. Abandoning it
66/// early does not merely mislabel a slow peer: it drops the refusal, so it
67/// never counts toward corroboration, never sets the latch, and the legacy
68/// request it raced can return a perfectly good quote the client then pays
69/// against.
70///
71/// The two fallbacks lose it by different mechanisms. The merkle path sends
72/// its legacy request under a *new* request id (`merkle.rs`), so a refusal
73/// arriving after the ceiling is answering a request nobody is listening to
74/// and is discarded on the id mismatch. The single-node path reuses the same
75/// id (`quote.rs`), so a late refusal is seen only if it happens to arrive
76/// after the retry has resubscribed; the await that would have matched it is
77/// already gone, and anything landing in the gap between the two waits is
78/// lost. Neither path observes a late refusal reliably, which is what the
79/// ceiling exists to prevent.
80///
81/// A shorter ceiling was tried, at 5s, to bring the slower CI runner under the
82/// cap. It would have turned every legitimate 5-to-10 second refusal in
83/// production into exactly that silent downgrade. The never-demote rule does
84/// not help: it stops a peer being *cached* as legacy after it has answered
85/// once, but it does not stop the request in flight from falling back. And the
86/// compile-time guard does not help either, because it binds future builds
87/// while the clients at risk are the ones already released.
88///
89/// So the ceiling exists solely to bound configurations that set a timeout far
90/// above any real answer time, which in practice means test harnesses. Above
91/// 10s it never binds on a production client, and nothing real is truncated.
92///
93/// The cost of leaving it here is roughly two minutes per merkle E2E test
94/// while the suite's devnet still speaks the pre-versioned dialect. That is a
95/// consequence of the temporary fork-branch protocol pin, not of the design:
96/// once the fleet under test can answer a versioned request there are no
97/// probes to pay for, and the suite returns to its baseline.
98pub(crate) const VERSIONED_QUOTE_PROBE_CEILING: std::time::Duration =
99    std::time::Duration::from_secs(15);
100
101/// How many distinct peers must refuse this client's settlement version before
102/// the refusal is believed and uploads stop.
103///
104/// Nothing authenticates a refusal, so one peer's word cannot be enough: a
105/// single hostile or misconfigured storer answering `ClientUpdateRequired` to
106/// everything would otherwise deny every upload, turning an over-query design
107/// that tolerates many bad peers into one that tolerates none.
108///
109/// Two is deliberately low. A genuine incompatibility reaches it instantly,
110/// because every peer enforcing the newer rule refuses, and a client queries
111/// far more than two. An attacker has to control two of the peers a given
112/// request happens to reach, which is a materially different proposition from
113/// controlling one.
114pub(crate) const SETTLEMENT_REFUSAL_QUORUM: usize = 2;
115
116/// Compile-time cutover guard shared by **every** unversioned quote retry.
117///
118/// Both quote paths fall back to an unversioned request when a peer stays
119/// silent, because a storer built before the settlement version existed cannot
120/// decode the versioned one. Silence is not proof of that, though: a dropped
121/// response, packet loss, an overloaded peer, or one deliberately discarding
122/// versioned requests are indistinguishable from the client side. So the
123/// fallback is a downgrade path and can be provoked.
124///
125/// It is not only literal silence, either. `send_and_await_chunk_response`
126/// keeps waiting when a reply decodes but carries a body the mapper does not
127/// recognise, so a peer answering with an unexpected variant also lands on the
128/// timeout and takes this path. That grants no capability beyond staying
129/// silent, which is why it is bounded rather than special-cased, but a comment
130/// claiming "any structured response prevents the retry" would be wrong.
131///
132/// It is safe only while **no refusal of either kind is possible**, and that
133/// means bounding both constants, not just the minimum.
134///
135/// Raising `MIN` is the obvious hazard: a client below it would be refused,
136/// and the retry hands it a quote anyway. Raising `CURRENT` is the subtler
137/// one. As soon as some node runs a newer `CURRENT` than another, the older
138/// node refuses newer clients with `StorerUpdateRequired` precisely because it
139/// cannot promise to honour their payment. A client that retries such a peer
140/// unversioned gets that unhonourable quote, and for a settlement change that
141/// is not a pure increase the payment is then rejected after it has settled.
142/// Guarding only `MIN` would leave that route open.
143///
144/// So the guard requires both to still be at the first declarable version.
145///
146/// This bounds **future builds** only. A client binary already in the field
147/// carries whatever fallback it shipped with, and no source change reaches it;
148/// that is inherent to shipping software and is why the storer still verifies
149/// every payment it is actually offered.
150///
151/// Each fallback site references this constant so the guard cannot be orphaned
152/// by deleting one path and forgetting the other. Retiring the fallbacks means
153/// deleting this constant and every reference to it, which the compiler then
154/// points at one by one.
155pub(crate) const UNVERSIONED_RETRY_REQUIRES_MIN_V1: () = assert!(
156    ant_protocol::MIN_SUPPORTED_SETTLEMENT_VERSION == 1
157        && ant_protocol::CURRENT_SETTLEMENT_VERSION == 1,
158    "an unversioned quote retry is still compiled in: it is a downgrade path around \
159     both the too-old and the node-behind refusals, so delete every fallback site \
160     before raising MIN_SUPPORTED_SETTLEMENT_VERSION or CURRENT_SETTLEMENT_VERSION"
161);
162
163/// Distinct peers that have refused this client's settlement version, plus the
164/// wording of the first refusal.
165///
166/// A separate type rather than a field so the corroboration rule can be tested
167/// on its own: it is the piece that decides whether an upload stops, and it
168/// has to hold against both a lone lying peer and a genuine incompatibility.
169#[derive(Clone, Default)]
170pub(crate) struct SettlementRefusals {
171    inner: Arc<Mutex<(HashSet<PeerId>, Option<String>)>>,
172}
173
174impl SettlementRefusals {
175    /// Record a refusal from `peer_id`, returning the wording once
176    /// [`SETTLEMENT_REFUSAL_QUORUM`] distinct peers agree and `None` below it.
177    pub(crate) fn note(&self, peer_id: PeerId, message: &str) -> Option<String> {
178        let mut guard = self.inner.lock().ok()?;
179        let (peers, wording) = &mut *guard;
180        peers.insert(peer_id);
181        if wording.is_none() {
182            *wording = Some(message.to_string());
183        }
184        (peers.len() >= SETTLEMENT_REFUSAL_QUORUM)
185            .then(|| wording.clone())
186            .flatten()
187    }
188
189    /// The corroborated refusal, if one has been established.
190    pub(crate) fn corroborated(&self) -> Option<String> {
191        let guard = self.inner.lock().ok()?;
192        let (peers, wording) = &*guard;
193        (peers.len() >= SETTLEMENT_REFUSAL_QUORUM)
194            .then(|| wording.clone())
195            .flatten()
196    }
197
198    /// The distinct refusing peers recorded so far, rendered for logging,
199    /// sorted so the line is deterministic.
200    ///
201    /// Exists so the terminal abort can NAME its corroborators. Without it the
202    /// quorum is unverifiable from outside: "awaiting corroboration" is only
203    /// logged below the quorum and the terminal error carries no peer ids, so
204    /// a log reader sees one warned peer followed by an abort whether the
205    /// quorum counted two distinct peers or misfired on one — which is exactly
206    /// the ambiguity the V2-1109 testnet run hit (0/463 aborts showed a second
207    /// peer, because the second peer was structurally unloggable).
208    pub(crate) fn corroborating_peers(&self) -> Vec<String> {
209        let Ok(guard) = self.inner.lock() else {
210            return Vec::new();
211        };
212        let (peers, _) = &*guard;
213        let mut ids: Vec<String> = peers.iter().map(|p| format!("{p}")).collect();
214        ids.sort();
215        ids
216    }
217}
218
219#[cfg(test)]
220mod settlement_refusal_tests {
221    use super::*;
222
223    fn peer(seed: u8) -> PeerId {
224        PeerId::from_bytes([seed; 32])
225    }
226
227    /// The question the V2-1109 run could not answer from logs: does one peer
228    /// refusing many times count as many? It must not — the quorum is over
229    /// DISTINCT peers, and a lone hostile or misconfigured storer repeating
230    /// itself must never become a verdict about this build.
231    #[test]
232    fn one_peer_refusing_many_times_never_reaches_the_quorum() {
233        let refusals = SettlementRefusals::default();
234        for _ in 0..50 {
235            assert!(
236                refusals.note(peer(7), "run ant update").is_none(),
237                "a single peer's repeated refusals must stay below the quorum"
238            );
239        }
240        assert!(refusals.corroborated().is_none());
241        assert_eq!(refusals.corroborating_peers().len(), 1);
242    }
243
244    /// The terminal log line must be able to name both corroborators, so the
245    /// distinct-peer property is verifiable from the outside.
246    #[test]
247    fn corroborating_peers_names_every_distinct_refuser() {
248        let refusals = SettlementRefusals::default();
249        assert!(refusals.note(peer(1), "run ant update").is_none());
250        assert!(refusals.note(peer(2), "run ant update").is_some());
251
252        let ids = refusals.corroborating_peers();
253        assert_eq!(ids.len(), 2);
254        assert_ne!(ids[0], ids[1]);
255        assert_eq!(ids, {
256            let mut sorted = ids.clone();
257            sorted.sort();
258            sorted
259        });
260    }
261}
262
263/// Classify a `data::error::Error` into a controller `Outcome`.
264///
265/// Capacity signals (Timeout / NetworkError) drive the controller
266/// down; application errors do not. The mapping is conservative:
267/// anything that COULD be transport-related is treated as a network
268/// signal, because under-classifying a real network failure as
269/// "application error" makes the controller blind to genuine stress.
270///
271/// Mapping policy:
272/// - `Timeout` -> `Timeout` (per-op deadline elapsed)
273/// - `Network`, `InsufficientPeers`, `Io` -> `NetworkError` (transport
274///   layer reported failure)
275/// - `Protocol`, `Storage` -> `NetworkError` (these wrap remote errors
276///   that frequently include peer disconnects mid-stream — under
277///   network stress these are how transport failures surface)
278/// - `PartialUpload` -> `NetworkError` (literal capacity signal: some
279///   chunks could not be stored)
280/// - `AlreadyStored`, `Encryption`, `Crypto`, `Payment`,
281///   `Serialization`, `InvalidData`, `NotFound`, `SignatureVerification`,
282///   `Config`, `InsufficientDiskSpace`, `CostEstimationInconclusive`,
283///   `Cancelled` -> `ApplicationError` (would happen on a perfectly
284///   healthy link; `Cancelled` is caller-initiated and must not be retried
285///   as a transport failure; `NotFound` is a definitively absent record
286///   reported over a working link, not a transport symptom)
287/// - `RemotePut` -> `ApplicationError` (the remote node responded with a
288///   structured rejection — the transport succeeded, so the node declined
289///   at the application layer; not a local capacity signal)
290/// - `ClientUpdateRequired` -> `ApplicationError` (the storer refused to quote
291///   a client that settles under superseded rules — a terminal verdict about
292///   this build, not about link capacity, and no retry rate clears it)
293/// - `CloseGroupShortfall` -> `ApplicationError` (a quorum shortfall caused
294///   by close-group dial/relay churn with no PUT-response timeouts — remote
295///   peer churn, not local backpressure; a timeout-bearing shortfall keeps
296///   `InsufficientPeers`/`NetworkError` instead, so genuine congestion still
297///   cuts the cap — V2-554)
298pub(crate) fn classify_error(err: &Error) -> Outcome {
299    match err {
300        Error::Timeout(_) => Outcome::Timeout,
301        Error::Network(_)
302        | Error::InsufficientPeers(_)
303        | Error::Io(_)
304        | Error::Protocol(_)
305        | Error::Storage(_)
306        | Error::PartialUpload { .. } => Outcome::NetworkError,
307        Error::AlreadyStored
308        | Error::Encryption(_)
309        | Error::Crypto(_)
310        | Error::Payment(_)
311        | Error::Serialization(_)
312        | Error::InvalidData(_)
313        // A definitively absent record, reported over a working link —
314        // the peers answered, there was just nothing stored there. Not a
315        // transport symptom, so it must not push the limiter down.
316        | Error::NotFound(_)
317        | Error::SignatureVerification(_)
318        | Error::Config(_)
319        | Error::InsufficientDiskSpace(_)
320        | Error::CostEstimationInconclusive(_)
321        | Error::Cancelled(_)
322        // The storer parsed our request and refused it on its merits, over a
323        // working link. Sending fewer requests would not help, and treating it
324        // as congestion would quietly shrink the limiter for the rest of the
325        // run on the basis of a fault no retry can clear.
326        | Error::ClientUpdateRequired(_)
327        | Error::StorerUpdateRequired(_)
328        | Error::BadQuoteBinding { .. }
329        | Error::BadQuoteCommitment { .. }
330        // An external-signer merkle batch larger than one tree can hold —
331        // a caller-shape refusal raised before any network work, so it says
332        // nothing about link capacity.
333        | Error::MerkleBatchTooLarge { .. }
334        // A remote node responded with a structured rejection — the
335        // transport round-trip succeeded, so the node declined at the
336        // application layer (payment/disk/quote/pool). Not a local
337        // capacity signal; recorded but must not push the limiter down.
338        | Error::RemotePut { .. }
339        // A close-group PUT shortfall caused purely by dial/relay churn
340        // (dead/stale relayed peer addresses), with no PUT-response
341        // timeouts to signal local backpressure. Remote peer churn, not
342        // "client sending too fast" — must not push the limiter down
343        // (V2-554). A shortfall that DID time out keeps `InsufficientPeers`
344        // (`NetworkError`) so real congestion still cuts the cap.
345        | Error::CloseGroupShortfall(_) => Outcome::ApplicationError,
346    }
347}
348
349/// Compute XOR distance between a peer's ID bytes and a target address.
350///
351/// Uses the first 32 bytes of the peer ID (or fewer if shorter) XORed
352/// with the target address. The returned byte array sorts
353/// lexicographically from closest to furthest.
354pub(crate) fn peer_xor_distance(peer_id: &PeerId, target: &[u8; 32]) -> [u8; 32] {
355    let peer_bytes = peer_id.as_bytes();
356    let mut distance = [0u8; 32];
357    for (i, d) in distance.iter_mut().enumerate() {
358        let peer_byte = peer_bytes.get(i).copied().unwrap_or(0);
359        *d = peer_byte ^ target[i];
360    }
361    distance
362}
363
364/// Default timeout for lightweight network operations (quotes, DHT lookups) in seconds.
365const DEFAULT_QUOTE_TIMEOUT_SECS: u64 = 10;
366
367/// Default timeout for the per-peer chunk GET response and any other
368/// caller that explicitly reads `store_timeout_secs`, in seconds.
369///
370/// Note despite the name: this knob does **not** govern the non-merkle
371/// chunk PUT response timeout — that path uses the
372/// `STORE_RESPONSE_TIMEOUT` constant in `chunk.rs` directly. Nor does
373/// it govern the merkle batch PUT timeout — see
374/// `DEFAULT_MERKLE_STORE_TIMEOUT_SECS`.
375///
376/// 10 s matches the pre-existing `main` default and intentionally
377/// excludes residential-upload tuning, which is Mick's PR #78
378/// territory (splitting GET into its own field).
379const DEFAULT_STORE_TIMEOUT_SECS: u64 = 10;
380
381/// Default timeout for **merkle batch** chunk store operations in seconds.
382///
383/// Separate from `DEFAULT_STORE_TIMEOUT_SECS` because merkle PUTs carry
384/// an extra storer-side cost: the payment verifier runs an iterative
385/// DHT lookup (`CLOSENESS_LOOKUP_TIMEOUT` in `ant-node`, **240 s**
386/// post-PR #89) before accepting the proof.
387///
388/// This timeout MUST be >= the storer-side `CLOSENESS_LOOKUP_TIMEOUT`
389/// plus padding for the store-response round-trip and storer-local
390/// I/O. Otherwise the client gives up while the storer is still
391/// happily verifying, the storer wastes CPU/bandwidth on a chunk the
392/// client has already discarded, and the client re-targets a
393/// different close-K member — potentially double-storing the same
394/// chunk and polluting routing.
395///
396/// 270 s = 240 s (storer lookup) + 30 s padding (network RTT + LMDB
397/// put + fsync + clock skew tolerance).
398///
399/// This invariant must be re-validated if either side's timeout
400/// changes. Empirically surfaced as "every cross-region merkle chunk
401/// times out at 10 s" on a 210-node 7-region testnet run on
402/// 2026-05-12; bumping to 270 s flipped that 0/22 -> 9/9 pass rate.
403const DEFAULT_MERKLE_STORE_TIMEOUT_SECS: u64 = 270;
404
405/// Default timeout for chunk GET response operations in seconds.
406const DEFAULT_CHUNK_GET_TIMEOUT_SECS: u64 = 10;
407
408/// Default quote concurrency: high because quoting is pure network I/O
409/// (DHT lookups + small request/response messages) with no CPU-bound work.
410const DEFAULT_QUOTE_CONCURRENCY: usize = 32;
411
412/// Default store concurrency: moderate because each chunk PUT sends ~4MB
413/// to 7 close-group peers. At 8 concurrent stores, ~225MB of outbound
414/// traffic can be in flight. Users on fast connections can increase this
415/// with --store-concurrency; users on slow connections can decrease it.
416const DEFAULT_STORE_CONCURRENCY: usize = 8;
417
418/// Configuration for the Autonomi client.
419#[derive(Debug, Clone)]
420pub struct ClientConfig {
421    /// Per-op timeout for lightweight network operations (quotes,
422    /// DHT lookups), in seconds. The adaptive controller does NOT
423    /// currently size timeouts; this remains a static knob.
424    pub quote_timeout_secs: u64,
425    /// Per-op timeout, in seconds, for the chunk GET response path
426    /// (`chunk_get_from_peer`) and any other caller that reads this
427    /// field directly.
428    ///
429    /// Note despite the historical name `store_timeout_secs`: this
430    /// knob does **not** govern the non-merkle chunk PUT response
431    /// timeout (that path uses the `STORE_RESPONSE_TIMEOUT` constant
432    /// in `chunk.rs`) and does **not** govern the merkle batch PUT
433    /// timeout (see `merkle_store_timeout_secs`). Rename pending in
434    /// Mick's PR #78 which adds a dedicated `chunk_get_timeout_secs`.
435    ///
436    /// The adaptive controller does NOT currently size timeouts;
437    /// this remains a static knob.
438    pub store_timeout_secs: u64,
439    /// Per-op timeout for **merkle batch** chunk store (PUT)
440    /// operations, in seconds. Separate from `store_timeout_secs`
441    /// because merkle PUTs incur the storer-side
442    /// `CLOSENESS_LOOKUP_TIMEOUT` (240 s post-PR #89) on top of the
443    /// usual store path; the client must wait at least that long
444    /// plus padding, or the storer wastes work on a chunk the client
445    /// has already given up on. Default 270 s.
446    pub merkle_store_timeout_secs: u64,
447    /// Per-peer response timeout for chunk GET operations, in seconds.
448    /// This is intentionally independent from `store_timeout_secs`: PUTs
449    /// and GETs have different payload direction and performance profiles.
450    pub chunk_get_timeout_secs: u64,
451    /// Number of closest peers to consider for routing.
452    pub close_group_size: usize,
453    /// **Deprecated.** Pre-adaptive ceiling for quote concurrency.
454    ///
455    /// The adaptive controller now sizes quote fan-out from observed
456    /// signals. This field, when non-zero and smaller than the
457    /// controller's per-channel default, clamps the **quote channel
458    /// only** (it does NOT bleed into store or fetch). Removed in a
459    /// future release.
460    pub quote_concurrency: usize,
461    /// **Deprecated.** Pre-adaptive ceiling for store concurrency.
462    ///
463    /// The adaptive controller now sizes store fan-out from observed
464    /// signals. This field, when non-zero and smaller than the
465    /// controller's per-channel default, clamps the **store channel
466    /// only** (it does NOT bleed into quote or fetch). Removed in a
467    /// future release.
468    pub store_concurrency: usize,
469    /// Adaptive controller configuration. Defaults are tuned to match
470    /// or exceed the prior static behavior — disabling adaptation
471    /// (`adaptive.enabled = false`) reverts to the controller's
472    /// `initial` values without re-evaluation.
473    pub adaptive: AdaptiveConfig,
474    /// Allow loopback (`127.0.0.1`) connections in the saorsa-transport
475    /// layer. Set to `true` only for devnet / local testing. Production
476    /// peers on the public Autonomi network reject the QUIC handshake
477    /// variant produced when this is `true`, so the default is `false`.
478    ///
479    /// This mirrors the `--allow-loopback` flag in `ant-cli`, which already
480    /// defaults to `false` and threads through to the same
481    /// `CoreNodeConfig::builder().local(...)` call.
482    pub allow_loopback: bool,
483    /// Bind a dual-stack IPv6 socket (`true`) or an IPv4-only socket
484    /// (`false`). Defaults to `true`, matching the CLI default.
485    ///
486    /// Set to `false` only when running on hosts without a working IPv6
487    /// stack, to avoid advertising unreachable v6 addresses to the DHT
488    /// (which causes slow connects and junk DHT address records). This
489    /// mirrors the `--ipv4-only` flag in `ant-cli`.
490    pub ipv6: bool,
491    /// Per-batch leaf cap for **external-signer** merkle preparation,
492    /// clamped to `3..=MAX_LEAVES` when set (a cap of 2 cannot partition odd
493    /// totals — parts of 3 and 2 compose any count, so 3 is the smallest
494    /// safe cap). `None` (the default) uses the contract maximum
495    /// (`MAX_LEAVES` = 256).
496    ///
497    /// This is a test seam (ADR-0003): a small cap makes
498    /// `file_prepare_upload_with_mode` produce a genuine multi-batch
499    /// prepared upload from a kilobyte file, so the N-signature external
500    /// flow is exercisable in E2E without a multi-GiB fixture. Production
501    /// callers should leave it `None` — a lower cap only means more payment
502    /// transactions for the same chunks.
503    pub merkle_external_batch_cap: Option<usize>,
504}
505
506impl Default for ClientConfig {
507    fn default() -> Self {
508        Self {
509            quote_timeout_secs: DEFAULT_QUOTE_TIMEOUT_SECS,
510            store_timeout_secs: DEFAULT_STORE_TIMEOUT_SECS,
511            merkle_store_timeout_secs: DEFAULT_MERKLE_STORE_TIMEOUT_SECS,
512            chunk_get_timeout_secs: DEFAULT_CHUNK_GET_TIMEOUT_SECS,
513            close_group_size: CLOSE_GROUP_SIZE,
514            quote_concurrency: DEFAULT_QUOTE_CONCURRENCY,
515            store_concurrency: DEFAULT_STORE_CONCURRENCY,
516            adaptive: AdaptiveConfig::default(),
517            allow_loopback: false,
518            ipv6: true,
519            merkle_external_batch_cap: None,
520        }
521    }
522}
523
524/// Build the adaptive controller for a `Client`. Loads any persisted
525/// snapshot, clamps cold-start values into the deprecated-flag bounds
526/// **per channel** (so a pin on `--store-concurrency` does NOT bleed
527/// into the fetch / quote channels), and returns the persistence path
528/// so callers can save back at shutdown.
529fn build_controller(config: &ClientConfig) -> (AdaptiveController, Option<PathBuf>) {
530    let mut adaptive_cfg = config.adaptive.clone();
531
532    // Per-channel ceilings: each legacy field is interpreted as a cap
533    // for ONLY its matching channel. The fetch channel has no
534    // pre-existing legacy field; it always uses the controller's
535    // default ceiling.
536    //
537    // The legacy fields are non-zero by ClientConfig::default(), but
538    // we honor them as bounds only when they would actually CONSTRAIN
539    // the controller — i.e. when smaller than the per-channel default
540    // max. A default ClientConfig must not silently lower the
541    // controller's ceilings.
542    // A value equal to the historic legacy default is treated as
543    // "not pinned by the user" — without this, every default
544    // ClientConfig would silently lower the controller's per-channel
545    // ceilings to the prior static values (32/8) and the controller
546    // could never grow above them.
547    let user_quote_max = config.quote_concurrency;
548    let user_store_max = config.store_concurrency;
549    let quote_pinned = user_quote_max > 0 && user_quote_max != DEFAULT_QUOTE_CONCURRENCY;
550    let store_pinned = user_store_max > 0 && user_store_max != DEFAULT_STORE_CONCURRENCY;
551    if quote_pinned && user_quote_max < adaptive_cfg.max.quote {
552        adaptive_cfg.max.quote = user_quote_max;
553    }
554    if store_pinned && user_store_max < adaptive_cfg.max.store {
555        adaptive_cfg.max.store = user_store_max;
556    }
557
558    // Cold-start values: matched to the prior static defaults. If the
559    // legacy field caps the channel below the cold-start, lower the
560    // start to match — never start above the channel's max.
561    let mut start = ChannelStart::default();
562    start.quote = start.quote.min(adaptive_cfg.max.quote);
563    start.store = start.store.min(adaptive_cfg.max.store);
564    start.fetch = start.fetch.min(adaptive_cfg.max.fetch);
565
566    let adaptive_enabled = adaptive_cfg.enabled;
567    let controller = AdaptiveController::new(start, adaptive_cfg);
568    // Skip disk warm-start entirely when adaptation is disabled —
569    // fixed-concurrency mode means the user wants exactly the cold
570    // start, no surprises from prior runs. (warm_start is also a
571    // no-op when disabled, but skipping the load avoids file I/O
572    // and the path-resolution side effects.)
573    let persist_path = if adaptive_enabled {
574        let p = adaptive::default_persist_path();
575        if let Some(ref path) = p {
576            if let Some(snap) = adaptive::load_snapshot(path) {
577                debug!(path = %path.display(), "adaptive: warm-start from disk");
578                controller.warm_start(snap);
579            }
580        }
581        p
582    } else {
583        // Even with adaptation off, persist_path is computed so
584        // explicit save_adaptive_snapshot() calls still work — but
585        // the controller currently never moves, so saving the cold
586        // start is harmless.
587        adaptive::default_persist_path()
588    };
589
590    // File downloads choose a stream-decrypt batch size per download
591    // from the current fetch cap and usable RAM, then pass it into
592    // self_encryption's runtime batch-size API. The adaptive controller
593    // still drives fan-out inside each batch by re-reading
594    // `controller.fetch.current()` in the decrypt callback.
595
596    (controller, persist_path)
597}
598
599/// Client for the Autonomi decentralized network.
600///
601/// Provides high-level APIs for storing and retrieving chunks
602/// and files on the network.
603pub struct Client {
604    config: ClientConfig,
605    network: Network,
606    wallet: Option<Arc<Wallet>>,
607    evm_network: Option<ant_protocol::evm::Network>,
608    chunk_cache: ChunkCache,
609    next_request_id: AtomicU64,
610    /// Adaptive concurrency controller: replaces the static
611    /// quote/store concurrency knobs. See `adaptive` module.
612    controller: AdaptiveController,
613    /// Path the controller persists its snapshot to. `None` disables
614    /// persistence (useful for tests / non-disk environments).
615    persist_path: Option<PathBuf>,
616    /// Path for the persistent client peer cache. `None` disables the cache.
617    peer_cache_path: Option<PathBuf>,
618    /// Peers that did not answer a settlement-versioned quote request, and are
619    /// therefore asked in the legacy shape from now on.
620    ///
621    /// Without this the probe cost is paid on **every** request rather than
622    /// roughly once per peer. Measured on the merkle E2E suite against a fleet
623    /// that predates the versioned requests, re-probing took the run from ~24
624    /// minutes to over 60, because each of the sixteen candidates per pool sat
625    /// out a full `quote_timeout_secs` before the fallback.
626    ///
627    /// Roughly, not exactly: concurrent first contacts are not coalesced, so
628    /// several in-flight requests can all miss the cache for the same peer and
629    /// each probe it once before any of them records the answer. Observed at
630    /// about two probes per peer on a 35-node devnet. Single-flighting them
631    /// would remove the duplicates but not the wall-clock cost, which is set
632    /// by how many *sequential* quote rounds an upload performs rather than by
633    /// how many probes each round contains.
634    ///
635    /// Process-local and never persisted. A peer that upgrades mid-run keeps
636    /// being asked in the legacy shape until the next start, which is
637    /// acceptable while the legacy shape still gets a quote, and stops
638    /// mattering when the fallback is deleted (see
639    /// [`UNVERSIONED_RETRY_REQUIRES_MIN_V1`]).
640    ///
641    /// Entries are only ever added for a peer that has **never** answered a
642    /// versioned request. Without that condition a single lost response would
643    /// pin an upgraded peer to the legacy shape for the rest of the session,
644    /// turning one dropped packet into a standing downgrade; with it, a peer
645    /// that has shown it understands the versioned shape can never be demoted.
646    ///
647    /// A peer that has never answered can still get itself asked without a
648    /// version by staying silent, exactly as it could through the fallback
649    /// alone. Remembering the answer makes that cheaper to sustain, so it is
650    /// not a new capability but it is a wider one, and the compile-time guard
651    /// requires the whole path to be gone before any refusal is possible.
652    unversioned_quote_peers: Arc<Mutex<HashSet<PeerId>>>,
653    /// Peers observed answering a settlement-versioned request. Never
654    /// downgraded, however they behave later.
655    versioned_capable_peers: Arc<Mutex<HashSet<PeerId>>>,
656    /// Distinct peers that have refused this client on settlement-version
657    /// grounds, and the wording of the first such refusal.
658    ///
659    /// Client-wide and sticky, for two reasons that pull in opposite
660    /// directions and are both real.
661    ///
662    /// It must outlive one operation, because the verdict is about this
663    /// **build**, not this upload. Held in a single collector's local state, a
664    /// refusal observed by one in-flight upload says nothing to another that
665    /// is about to submit a payment, and merkle payments cannot be undone.
666    ///
667    /// It must not fire on one peer's say-so, because nothing authenticates a
668    /// refusal. A single hostile or confused peer answering
669    /// `ClientUpdateRequired` to every query would otherwise abort every
670    /// upload the client attempts, converting an over-query design that
671    /// tolerates many bad peers into one that tolerates none. So a refusal
672    /// becomes terminal only once [`SETTLEMENT_REFUSAL_QUORUM`] distinct peers
673    /// agree, which a genuine incompatibility reaches immediately (every
674    /// upgraded peer refuses) and a lone attacker cannot reach at all.
675    settlement_refusals: SettlementRefusals,
676}
677
678impl Client {
679    /// Create a client connected to the given P2P node.
680    #[must_use]
681    pub fn from_node(node: Arc<P2PNode>, config: ClientConfig) -> Self {
682        Self::from_node_with_peer_cache(node, config, None)
683    }
684
685    /// Create a client connected to the given P2P node and attach an optional
686    /// persistent peer cache path.
687    #[must_use]
688    pub fn from_node_with_peer_cache(
689        node: Arc<P2PNode>,
690        config: ClientConfig,
691        peer_cache_path: Option<PathBuf>,
692    ) -> Self {
693        let network = Network::from_node(node);
694        let (controller, persist_path) = build_controller(&config);
695        Self {
696            config,
697            network,
698            wallet: None,
699            evm_network: None,
700            chunk_cache: ChunkCache::default(),
701            next_request_id: AtomicU64::new(1),
702            unversioned_quote_peers: Arc::new(Mutex::new(HashSet::new())),
703            versioned_capable_peers: Arc::new(Mutex::new(HashSet::new())),
704            settlement_refusals: SettlementRefusals::default(),
705            controller,
706            persist_path,
707            peer_cache_path,
708        }
709    }
710
711    /// Create a client connected to bootstrap peers.
712    ///
713    /// Threads `config.allow_loopback` and `config.ipv6` through to
714    /// `Network::new`, which controls the saorsa-transport `local` and
715    /// `ipv6` flags on the underlying `CoreNodeConfig`. See
716    /// `ClientConfig::allow_loopback` and `ClientConfig::ipv6` for details.
717    ///
718    /// # Errors
719    ///
720    /// Returns an error if the P2P node cannot be created or bootstrapping fails.
721    pub async fn connect(
722        bootstrap_peers: &[std::net::SocketAddr],
723        config: ClientConfig,
724    ) -> Result<Self> {
725        debug!(
726            "Connecting to Autonomi network with {} bootstrap peers (allow_loopback={}, ipv6={})",
727            bootstrap_peers.len(),
728            config.allow_loopback,
729            config.ipv6,
730        );
731        let network = Network::new(bootstrap_peers, config.allow_loopback, config.ipv6).await?;
732        let (controller, persist_path) = build_controller(&config);
733        Ok(Self {
734            config,
735            network,
736            wallet: None,
737            evm_network: None,
738            chunk_cache: ChunkCache::default(),
739            next_request_id: AtomicU64::new(1),
740            unversioned_quote_peers: Arc::new(Mutex::new(HashSet::new())),
741            versioned_capable_peers: Arc::new(Mutex::new(HashSet::new())),
742            settlement_refusals: SettlementRefusals::default(),
743            controller,
744            persist_path,
745            peer_cache_path: None,
746        })
747    }
748
749    /// Set the wallet for payment operations.
750    ///
751    /// Also populates the EVM network from the wallet so that
752    /// token approvals work without a separate `with_evm_network` call.
753    #[must_use]
754    pub fn with_wallet(mut self, wallet: Wallet) -> Self {
755        self.evm_network = Some(wallet.network().clone());
756        self.wallet = Some(Arc::new(wallet));
757        self
758    }
759
760    /// Set the EVM network without requiring a wallet.
761    ///
762    /// This enables token approval and contract interactions
763    /// for external-signer flows where the private key lives outside Rust.
764    #[must_use]
765    pub fn with_evm_network(mut self, network: ant_protocol::evm::Network) -> Self {
766        self.evm_network = Some(network);
767        self
768    }
769
770    /// Get the EVM network, falling back to the wallet's network if available.
771    ///
772    /// # Errors
773    ///
774    /// Returns an error if neither `with_evm_network` nor `with_wallet` was called.
775    pub(crate) fn require_evm_network(&self) -> Result<&ant_protocol::evm::Network> {
776        if let Some(ref net) = self.evm_network {
777            return Ok(net);
778        }
779        if let Some(ref wallet) = self.wallet {
780            return Ok(wallet.network());
781        }
782        Err(Error::Payment(
783            "EVM network not configured — call with_evm_network() or with_wallet() first"
784                .to_string(),
785        ))
786    }
787
788    /// Get the client configuration.
789    #[must_use]
790    pub fn config(&self) -> &ClientConfig {
791        &self.config
792    }
793
794    /// Get a mutable reference to the client configuration.
795    pub fn config_mut(&mut self) -> &mut ClientConfig {
796        &mut self.config
797    }
798
799    /// Get a reference to the network layer.
800    #[must_use]
801    pub fn network(&self) -> &Network {
802        &self.network
803    }
804
805    /// Compute the live network-participation snapshot.
806    ///
807    /// Convenience pass-through to [`Network::health`] — the single
808    /// write-readiness implementation shared by all embedded-client
809    /// consumers (antd, ant-gui, ant-ffi, ant-tui).
810    pub async fn network_health(&self) -> NetworkHealth {
811        self.network.health().await
812    }
813
814    /// Get the wallet, if configured.
815    #[must_use]
816    pub fn wallet(&self) -> Option<&Arc<Wallet>> {
817        self.wallet.as_ref()
818    }
819
820    /// Get a reference to the chunk cache.
821    #[must_use]
822    pub fn chunk_cache(&self) -> &ChunkCache {
823        &self.chunk_cache
824    }
825
826    /// Adaptive concurrency controller. Hot loops read
827    /// `controller().<channel>.current()` to size their fan-out and
828    /// call `.observe(...)` on each completion.
829    #[must_use]
830    pub fn controller(&self) -> &AdaptiveController {
831        &self.controller
832    }
833
834    /// Persist the current adaptive snapshot to disk so the next
835    /// `Client::connect` warm-starts at the learned values instead of
836    /// cold defaults. Best effort — failures log and are discarded.
837    /// Idempotent. Safe to call from a Drop impl or an explicit
838    /// shutdown hook.
839    pub fn save_adaptive_snapshot(&self) {
840        if let Some(ref path) = self.persist_path {
841            adaptive::save_snapshot(path, self.controller.snapshot());
842        }
843    }
844
845    /// Persist currently connected peers that have Direct-tagged addresses in
846    /// the DHT. Best effort; failures are logged and do not affect the client
847    /// operation that just completed.
848    pub async fn save_peer_cache(&self) {
849        if let Some(ref path) = self.peer_cache_path {
850            let node = self.network().node();
851            peer_cache::promote_connected_direct_peers(node.as_ref(), path, node.dht().k_value())
852                .await;
853        }
854    }
855
856    /// Get the next request ID for protocol messages.
857    pub(crate) fn next_request_id(&self) -> u64 {
858        self.next_request_id.fetch_add(1, Ordering::Relaxed)
859    }
860
861    /// Handle to the set of peers that cannot answer a settlement-versioned
862    /// quote request, shared with the per-peer request futures on both quote
863    /// paths.
864    ///
865    /// Callers read it before choosing a request shape and insert into it when
866    /// a peer stays silent. A poisoned lock is treated as "nothing known", so
867    /// the worst case is a wasted probe rather than a silently skipped version
868    /// declaration.
869    pub(crate) fn unversioned_quote_peers(&self) -> Arc<Mutex<HashSet<PeerId>>> {
870        Arc::clone(&self.unversioned_quote_peers)
871    }
872
873    /// Handle to the set of peers already seen answering a versioned request.
874    /// Consulted before demoting a peer, so a lost response cannot strand an
875    /// upgraded peer in the legacy shape.
876    pub(crate) fn versioned_quote_capable_handle(&self) -> Arc<Mutex<HashSet<PeerId>>> {
877        Arc::clone(&self.versioned_capable_peers)
878    }
879
880    /// Record that `peer_id` refused this client's settlement version, and
881    /// report whether enough distinct peers now agree for it to be believed.
882    ///
883    /// Returns the refusal wording once [`SETTLEMENT_REFUSAL_QUORUM`] is met,
884    /// and `None` below it, so a lone peer is treated as a peer fault rather
885    /// than a verdict about this build.
886    pub(crate) fn note_settlement_refusal(&self, peer_id: PeerId, message: &str) -> Option<String> {
887        self.settlement_refusals.note(peer_id, message)
888    }
889
890    /// The corroborated refusal, if this client has already been told by
891    /// enough peers that it cannot settle.
892    ///
893    /// Checked before spending money. The verdict concerns this build rather
894    /// than any one upload, so an upload that starts after another has already
895    /// established it must not proceed to pay.
896    pub(crate) fn corroborated_settlement_refusal(&self) -> Option<String> {
897        self.settlement_refusals.corroborated()
898    }
899
900    /// Handle to the shared refusal tracker, for collectors that run outside
901    /// `&self`.
902    pub(crate) fn settlement_refusals(&self) -> SettlementRefusals {
903        self.settlement_refusals.clone()
904    }
905
906    /// Return the chunk PUT-target set: the closest [`PUT_TARGET_WIDTH`] peers
907    /// to the address, each paired with its known network addresses.
908    ///
909    /// Used by the merkle store path, which — unlike single-node payment — has
910    /// no witnessed put-target list to forward, so it fetches the closest-K
911    /// neighbourhood locally.
912    pub(crate) async fn put_target_peers(
913        &self,
914        target: &XorName,
915    ) -> Result<Vec<(PeerId, Vec<MultiAddr>)>> {
916        self.closest_peers(target, PUT_TARGET_WIDTH).await
917    }
918
919    /// Return the requested number of closest peers for a target address.
920    ///
921    /// Queries the DHT for peers by XOR distance. Returns each peer
922    /// paired with its known network addresses.
923    pub(crate) async fn closest_peers(
924        &self,
925        target: &XorName,
926        count: usize,
927    ) -> Result<Vec<(PeerId, Vec<MultiAddr>)>> {
928        let peers = self.network().find_closest_peers(target, count).await?;
929
930        if peers.is_empty() {
931            return Err(Error::InsufficientPeers(
932                "DHT returned no peers for target address".to_string(),
933            ));
934        }
935        Ok(peers)
936    }
937}
938
939/// Persist the adaptive snapshot when the `Client` is dropped, so any
940/// caller — CLI, daemon, library user, integration test — gets
941/// warm-start carry-over for free without remembering to call
942/// `save_adaptive_snapshot()` explicitly. Best effort, sync `std::fs`,
943/// no panic risk on a poisoned mutex (the inner helper handles it).
944///
945/// We deliberately write SYNCHRONOUSLY (not via `spawn_blocking`)
946/// because Drop runs during process shutdown / runtime teardown,
947/// when fire-and-forget background tasks can be dropped before they
948/// complete and the snapshot is silently lost. A small synchronous
949/// stall on a tokio worker (typically <1ms for a local-disk JSON
950/// write of ~50 bytes) is the right tradeoff for guaranteed
951/// persistence — BOUNDED by `DROP_SAVE_TIMEOUT` so a stalled
952/// network-mounted data dir cannot block process shutdown.
953const DROP_SAVE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
954
955impl Drop for Client {
956    fn drop(&mut self) {
957        let Some(path) = self.persist_path.clone() else {
958            return;
959        };
960        let snap = self.controller.snapshot();
961        adaptive::save_snapshot_with_timeout(path, snap, DROP_SAVE_TIMEOUT);
962    }
963}
964
965#[cfg(test)]
966#[allow(clippy::unwrap_used)]
967mod tests {
968    use super::*;
969
970    /// Cover EVERY variant of `data::error::Error`. Build an instance of
971    /// each, classify it, and assert the resulting `Outcome` matches the
972    /// only sensible mapping. If a future commit adds a new error variant
973    /// without updating `classify_error`, this test fails to ensure the
974    /// adaptive controller always sees correct capacity signals.
975    ///
976    /// Mapping policy (mirrors `classify_error` doc):
977    /// - `Timeout` -> `Outcome::Timeout`
978    /// - `Network`, `InsufficientPeers`, `Io`, `Protocol`, `Storage`,
979    ///   `PartialUpload` -> `Outcome::NetworkError` (transport-related
980    ///   or literal capacity failure)
981    /// - everything else -> `Outcome::ApplicationError` (would happen
982    ///   on a perfectly healthy network)
983    #[test]
984    fn classify_error_covers_all_variants() {
985        let cases: Vec<(Error, Outcome)> = vec![
986            (Error::Timeout("t".to_string()), Outcome::Timeout),
987            (Error::Network("n".to_string()), Outcome::NetworkError),
988            (
989                Error::InsufficientPeers("p".to_string()),
990                Outcome::NetworkError,
991            ),
992            (Error::Storage("s".to_string()), Outcome::NetworkError),
993            (Error::Payment("p".to_string()), Outcome::ApplicationError),
994            (Error::Protocol("p".to_string()), Outcome::NetworkError),
995            (
996                Error::InvalidData("d".to_string()),
997                Outcome::ApplicationError,
998            ),
999            // A definitively absent record over a working link — the peers
1000            // answered, nothing was stored there. Must NOT register as a
1001            // capacity signal.
1002            (
1003                Error::NotFound("missing".to_string()),
1004                Outcome::ApplicationError,
1005            ),
1006            (
1007                Error::Serialization("s".to_string()),
1008                Outcome::ApplicationError,
1009            ),
1010            (Error::Crypto("c".to_string()), Outcome::ApplicationError),
1011            (
1012                Error::Io(std::io::Error::other("io")),
1013                Outcome::NetworkError,
1014            ),
1015            (Error::Config("c".to_string()), Outcome::ApplicationError),
1016            (
1017                Error::SignatureVerification("s".to_string()),
1018                Outcome::ApplicationError,
1019            ),
1020            (
1021                Error::Encryption("e".to_string()),
1022                Outcome::ApplicationError,
1023            ),
1024            (Error::AlreadyStored, Outcome::ApplicationError),
1025            (
1026                Error::InsufficientDiskSpace("d".to_string()),
1027                Outcome::ApplicationError,
1028            ),
1029            (
1030                Error::CostEstimationInconclusive("c".to_string()),
1031                Outcome::ApplicationError,
1032            ),
1033            (
1034                Error::PartialUpload {
1035                    stored: vec![],
1036                    stored_count: 0,
1037                    failed: vec![],
1038                    failed_count: 0,
1039                    total_chunks: 0,
1040                    spend: Box::new(crate::data::error::PartialUploadSpend {
1041                        storage_cost_atto: "0".to_string(),
1042                        gas_cost_wei: 0,
1043                    }),
1044                    reason: "r".to_string(),
1045                },
1046                Outcome::NetworkError,
1047            ),
1048            (
1049                Error::BadQuoteBinding {
1050                    peer_id: "peer".to_string(),
1051                    detail: "mismatch".to_string(),
1052                },
1053                Outcome::ApplicationError,
1054            ),
1055            // A remote application rejection: the node responded with a
1056            // structured `ProtocolError`, so the transport succeeded and
1057            // this must NOT register as a capacity signal (V2-468).
1058            (
1059                Error::RemotePut {
1060                    address: "abcd".to_string(),
1061                    source: ant_protocol::ProtocolError::PaymentFailed("stale quote".to_string()),
1062                },
1063                Outcome::ApplicationError,
1064            ),
1065            // A close-group quorum shortfall caused by dial/relay churn with
1066            // no PUT-response timeouts — remote peer churn, not local
1067            // backpressure, so it must NOT register as a capacity signal
1068            // (V2-554). A timeout-bearing shortfall keeps `InsufficientPeers`.
1069            (
1070                Error::CloseGroupShortfall("Stored on 3 peers, need 4".to_string()),
1071                Outcome::ApplicationError,
1072            ),
1073            // Refusing an oversized external-signer merkle batch happens
1074            // before any network work, so it is not a capacity signal.
1075            (
1076                Error::MerkleBatchTooLarge {
1077                    addresses: 257,
1078                    max_leaves: 256,
1079                },
1080                Outcome::ApplicationError,
1081            ),
1082        ];
1083        for (err, expected) in &cases {
1084            let got = classify_error(err);
1085            assert_eq!(
1086                got, *expected,
1087                "classify_error({err:?}) = {got:?}, expected {expected:?}",
1088            );
1089        }
1090    }
1091
1092    /// C4 fix guard: pinning the legacy `quote_concurrency` /
1093    /// `store_concurrency` ClientConfig fields must clamp ONLY the
1094    /// matching channel's max in the resulting controller. The fetch
1095    /// (download) channel must keep its full default ceiling.
1096    #[test]
1097    fn legacy_concurrency_pin_does_not_bleed_across_channels() {
1098        let cfg = ClientConfig {
1099            quote_concurrency: 4,
1100            store_concurrency: 2,
1101            ..ClientConfig::default()
1102        };
1103        let (controller, _) = build_controller(&cfg);
1104        // The store/quote caps must be clamped to the user's pin.
1105        assert_eq!(controller.config.max.quote, 4, "quote pin not respected");
1106        assert_eq!(controller.config.max.store, 2, "store pin not respected");
1107        // The fetch cap must NOT have been lowered — that's the
1108        // regression C4 was about.
1109        let default_fetch_max = adaptive::ChannelMax::default().fetch;
1110        assert_eq!(
1111            controller.config.max.fetch, default_fetch_max,
1112            "fetch cap was lowered by store/quote pin (C4 regression)"
1113        );
1114        // Cold-start values must respect the lowered ceilings.
1115        assert!(
1116            controller.quote.current() <= 4,
1117            "quote start exceeds its cap"
1118        );
1119        assert!(
1120            controller.store.current() <= 2,
1121            "store start exceeds its cap"
1122        );
1123    }
1124
1125    /// Default ClientConfig must NOT silently lower the controller's
1126    /// per-channel ceilings — the adaptive defaults give every channel
1127    /// real headroom to grow. This guards against future commits
1128    /// re-introducing a global clamp.
1129    #[test]
1130    fn default_client_config_does_not_clamp_controller_max() {
1131        let cfg = ClientConfig::default();
1132        let (controller, _) = build_controller(&cfg);
1133        let defaults = adaptive::ChannelMax::default();
1134        // The legacy fields default to 32/8 (the prior static knobs),
1135        // both of which are <= the per-channel adaptive defaults
1136        // (128/64). build_controller must keep the larger, not clobber
1137        // with the legacy values.
1138        assert_eq!(controller.config.max.quote, defaults.quote);
1139        assert_eq!(controller.config.max.store, defaults.store);
1140        assert_eq!(controller.config.max.fetch, defaults.fetch);
1141        // Compile-time-ish guard: if a new variant is added to Error,
1142        // this match forces an update here.
1143        let _ = |e: &Error| match e {
1144            Error::Timeout(_)
1145            | Error::Network(_)
1146            | Error::InsufficientPeers(_)
1147            | Error::Storage(_)
1148            | Error::Payment(_)
1149            | Error::Protocol(_)
1150            | Error::InvalidData(_)
1151            | Error::NotFound(_)
1152            | Error::Serialization(_)
1153            | Error::Crypto(_)
1154            | Error::Io(_)
1155            | Error::Config(_)
1156            | Error::SignatureVerification(_)
1157            | Error::Encryption(_)
1158            | Error::AlreadyStored
1159            | Error::InsufficientDiskSpace(_)
1160            | Error::CostEstimationInconclusive(_)
1161            | Error::Cancelled(_)
1162            | Error::PartialUpload { .. }
1163            | Error::BadQuoteBinding { .. }
1164            | Error::BadQuoteCommitment { .. }
1165            | Error::MerkleBatchTooLarge { .. }
1166            | Error::RemotePut { .. }
1167            | Error::ClientUpdateRequired(_)
1168            | Error::StorerUpdateRequired(_)
1169            | Error::CloseGroupShortfall(_) => (),
1170        };
1171    }
1172}