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 file;
14pub mod merkle;
15pub mod payment;
16pub mod quote;
17
18use crate::data::client::adaptive::{AdaptiveConfig, AdaptiveController, ChannelStart, Outcome};
19use crate::data::client::cache::ChunkCache;
20use crate::data::error::{Error, Result};
21use crate::data::network::{Network, NetworkHealth};
22use crate::data::peer_cache;
23use ant_protocol::evm::Wallet;
24use ant_protocol::transport::{MultiAddr, P2PNode, PeerId};
25use ant_protocol::{XorName, CLOSE_GROUP_SIZE};
26use std::path::PathBuf;
27use std::sync::atomic::{AtomicU64, Ordering};
28use std::sync::Arc;
29use tracing::debug;
30
31/// Width of the chunk PUT-target set (initial writes plus fallback): the
32/// closest `PUT_TARGET_WIDTH` peers to the address.
33///
34/// Mirrors the node-side `K_BUCKET_SIZE` / `PAID_QUOTE_ISSUER_CLOSENESS_WIDTH`
35/// (20): a node accepts a reused payment proof only when one of the proof's
36/// closest-`CLOSE_GROUP_SIZE` quote issuers is within its own local 20-closest,
37/// so trying peers past this width is pointless.
38pub(crate) const PUT_TARGET_WIDTH: usize = 20;
39
40/// Classify a `data::error::Error` into a controller `Outcome`.
41///
42/// Capacity signals (Timeout / NetworkError) drive the controller
43/// down; application errors do not. The mapping is conservative:
44/// anything that COULD be transport-related is treated as a network
45/// signal, because under-classifying a real network failure as
46/// "application error" makes the controller blind to genuine stress.
47///
48/// Mapping policy:
49/// - `Timeout` -> `Timeout` (per-op deadline elapsed)
50/// - `Network`, `InsufficientPeers`, `Io` -> `NetworkError` (transport
51///   layer reported failure)
52/// - `Protocol`, `Storage` -> `NetworkError` (these wrap remote errors
53///   that frequently include peer disconnects mid-stream — under
54///   network stress these are how transport failures surface)
55/// - `PartialUpload` -> `NetworkError` (literal capacity signal: some
56///   chunks could not be stored)
57/// - `AlreadyStored`, `Encryption`, `Crypto`, `Payment`,
58///   `Serialization`, `InvalidData`, `NotFound`, `SignatureVerification`,
59///   `Config`, `InsufficientDiskSpace`, `CostEstimationInconclusive`,
60///   `Cancelled` -> `ApplicationError` (would happen on a perfectly
61///   healthy link; `Cancelled` is caller-initiated and must not be retried
62///   as a transport failure; `NotFound` is a definitively absent record
63///   reported over a working link, not a transport symptom)
64/// - `RemotePut` -> `ApplicationError` (the remote node responded with a
65///   structured rejection — the transport succeeded, so the node declined
66///   at the application layer; not a local capacity signal)
67/// - `CloseGroupShortfall` -> `ApplicationError` (a quorum shortfall caused
68///   by close-group dial/relay churn with no PUT-response timeouts — remote
69///   peer churn, not local backpressure; a timeout-bearing shortfall keeps
70///   `InsufficientPeers`/`NetworkError` instead, so genuine congestion still
71///   cuts the cap — V2-554)
72pub(crate) fn classify_error(err: &Error) -> Outcome {
73    match err {
74        Error::Timeout(_) => Outcome::Timeout,
75        Error::Network(_)
76        | Error::InsufficientPeers(_)
77        | Error::Io(_)
78        | Error::Protocol(_)
79        | Error::Storage(_)
80        | Error::PartialUpload { .. } => Outcome::NetworkError,
81        Error::AlreadyStored
82        | Error::Encryption(_)
83        | Error::Crypto(_)
84        | Error::Payment(_)
85        | Error::Serialization(_)
86        | Error::InvalidData(_)
87        // A definitively absent record, reported over a working link —
88        // the peers answered, there was just nothing stored there. Not a
89        // transport symptom, so it must not push the limiter down.
90        | Error::NotFound(_)
91        | Error::SignatureVerification(_)
92        | Error::Config(_)
93        | Error::InsufficientDiskSpace(_)
94        | Error::CostEstimationInconclusive(_)
95        | Error::Cancelled(_)
96        | Error::BadQuoteBinding { .. }
97        | Error::BadQuoteCommitment { .. }
98        // An external-signer merkle batch larger than one tree can hold —
99        // a caller-shape refusal raised before any network work, so it says
100        // nothing about link capacity.
101        | Error::MerkleBatchTooLarge { .. }
102        // A remote node responded with a structured rejection — the
103        // transport round-trip succeeded, so the node declined at the
104        // application layer (payment/disk/quote/pool). Not a local
105        // capacity signal; recorded but must not push the limiter down.
106        | Error::RemotePut { .. }
107        // A close-group PUT shortfall caused purely by dial/relay churn
108        // (dead/stale relayed peer addresses), with no PUT-response
109        // timeouts to signal local backpressure. Remote peer churn, not
110        // "client sending too fast" — must not push the limiter down
111        // (V2-554). A shortfall that DID time out keeps `InsufficientPeers`
112        // (`NetworkError`) so real congestion still cuts the cap.
113        | Error::CloseGroupShortfall(_) => Outcome::ApplicationError,
114    }
115}
116
117/// Compute XOR distance between a peer's ID bytes and a target address.
118///
119/// Uses the first 32 bytes of the peer ID (or fewer if shorter) XORed
120/// with the target address. The returned byte array sorts
121/// lexicographically from closest to furthest.
122pub(crate) fn peer_xor_distance(peer_id: &PeerId, target: &[u8; 32]) -> [u8; 32] {
123    let peer_bytes = peer_id.as_bytes();
124    let mut distance = [0u8; 32];
125    for (i, d) in distance.iter_mut().enumerate() {
126        let peer_byte = peer_bytes.get(i).copied().unwrap_or(0);
127        *d = peer_byte ^ target[i];
128    }
129    distance
130}
131
132/// Default timeout for lightweight network operations (quotes, DHT lookups) in seconds.
133const DEFAULT_QUOTE_TIMEOUT_SECS: u64 = 10;
134
135/// Default timeout for the per-peer chunk GET response and any other
136/// caller that explicitly reads `store_timeout_secs`, in seconds.
137///
138/// Note despite the name: this knob does **not** govern the non-merkle
139/// chunk PUT response timeout — that path uses the
140/// `STORE_RESPONSE_TIMEOUT` constant in `chunk.rs` directly. Nor does
141/// it govern the merkle batch PUT timeout — see
142/// `DEFAULT_MERKLE_STORE_TIMEOUT_SECS`.
143///
144/// 10 s matches the pre-existing `main` default and intentionally
145/// excludes residential-upload tuning, which is Mick's PR #78
146/// territory (splitting GET into its own field).
147const DEFAULT_STORE_TIMEOUT_SECS: u64 = 10;
148
149/// Default timeout for **merkle batch** chunk store operations in seconds.
150///
151/// Separate from `DEFAULT_STORE_TIMEOUT_SECS` because merkle PUTs carry
152/// an extra storer-side cost: the payment verifier runs an iterative
153/// DHT lookup (`CLOSENESS_LOOKUP_TIMEOUT` in `ant-node`, **240 s**
154/// post-PR #89) before accepting the proof.
155///
156/// This timeout MUST be >= the storer-side `CLOSENESS_LOOKUP_TIMEOUT`
157/// plus padding for the store-response round-trip and storer-local
158/// I/O. Otherwise the client gives up while the storer is still
159/// happily verifying, the storer wastes CPU/bandwidth on a chunk the
160/// client has already discarded, and the client re-targets a
161/// different close-K member — potentially double-storing the same
162/// chunk and polluting routing.
163///
164/// 270 s = 240 s (storer lookup) + 30 s padding (network RTT + LMDB
165/// put + fsync + clock skew tolerance).
166///
167/// This invariant must be re-validated if either side's timeout
168/// changes. Empirically surfaced as "every cross-region merkle chunk
169/// times out at 10 s" on a 210-node 7-region testnet run on
170/// 2026-05-12; bumping to 270 s flipped that 0/22 -> 9/9 pass rate.
171const DEFAULT_MERKLE_STORE_TIMEOUT_SECS: u64 = 270;
172
173/// Default timeout for chunk GET response operations in seconds.
174const DEFAULT_CHUNK_GET_TIMEOUT_SECS: u64 = 10;
175
176/// Default quote concurrency: high because quoting is pure network I/O
177/// (DHT lookups + small request/response messages) with no CPU-bound work.
178const DEFAULT_QUOTE_CONCURRENCY: usize = 32;
179
180/// Default store concurrency: moderate because each chunk PUT sends ~4MB
181/// to 7 close-group peers. At 8 concurrent stores, ~225MB of outbound
182/// traffic can be in flight. Users on fast connections can increase this
183/// with --store-concurrency; users on slow connections can decrease it.
184const DEFAULT_STORE_CONCURRENCY: usize = 8;
185
186/// Configuration for the Autonomi client.
187#[derive(Debug, Clone)]
188pub struct ClientConfig {
189    /// Per-op timeout for lightweight network operations (quotes,
190    /// DHT lookups), in seconds. The adaptive controller does NOT
191    /// currently size timeouts; this remains a static knob.
192    pub quote_timeout_secs: u64,
193    /// Per-op timeout, in seconds, for the chunk GET response path
194    /// (`chunk_get_from_peer`) and any other caller that reads this
195    /// field directly.
196    ///
197    /// Note despite the historical name `store_timeout_secs`: this
198    /// knob does **not** govern the non-merkle chunk PUT response
199    /// timeout (that path uses the `STORE_RESPONSE_TIMEOUT` constant
200    /// in `chunk.rs`) and does **not** govern the merkle batch PUT
201    /// timeout (see `merkle_store_timeout_secs`). Rename pending in
202    /// Mick's PR #78 which adds a dedicated `chunk_get_timeout_secs`.
203    ///
204    /// The adaptive controller does NOT currently size timeouts;
205    /// this remains a static knob.
206    pub store_timeout_secs: u64,
207    /// Per-op timeout for **merkle batch** chunk store (PUT)
208    /// operations, in seconds. Separate from `store_timeout_secs`
209    /// because merkle PUTs incur the storer-side
210    /// `CLOSENESS_LOOKUP_TIMEOUT` (240 s post-PR #89) on top of the
211    /// usual store path; the client must wait at least that long
212    /// plus padding, or the storer wastes work on a chunk the client
213    /// has already given up on. Default 270 s.
214    pub merkle_store_timeout_secs: u64,
215    /// Per-peer response timeout for chunk GET operations, in seconds.
216    /// This is intentionally independent from `store_timeout_secs`: PUTs
217    /// and GETs have different payload direction and performance profiles.
218    pub chunk_get_timeout_secs: u64,
219    /// Number of closest peers to consider for routing.
220    pub close_group_size: usize,
221    /// **Deprecated.** Pre-adaptive ceiling for quote concurrency.
222    ///
223    /// The adaptive controller now sizes quote fan-out from observed
224    /// signals. This field, when non-zero and smaller than the
225    /// controller's per-channel default, clamps the **quote channel
226    /// only** (it does NOT bleed into store or fetch). Removed in a
227    /// future release.
228    pub quote_concurrency: usize,
229    /// **Deprecated.** Pre-adaptive ceiling for store concurrency.
230    ///
231    /// The adaptive controller now sizes store fan-out from observed
232    /// signals. This field, when non-zero and smaller than the
233    /// controller's per-channel default, clamps the **store channel
234    /// only** (it does NOT bleed into quote or fetch). Removed in a
235    /// future release.
236    pub store_concurrency: usize,
237    /// Adaptive controller configuration. Defaults are tuned to match
238    /// or exceed the prior static behavior — disabling adaptation
239    /// (`adaptive.enabled = false`) reverts to the controller's
240    /// `initial` values without re-evaluation.
241    pub adaptive: AdaptiveConfig,
242    /// Allow loopback (`127.0.0.1`) connections in the saorsa-transport
243    /// layer. Set to `true` only for devnet / local testing. Production
244    /// peers on the public Autonomi network reject the QUIC handshake
245    /// variant produced when this is `true`, so the default is `false`.
246    ///
247    /// This mirrors the `--allow-loopback` flag in `ant-cli`, which already
248    /// defaults to `false` and threads through to the same
249    /// `CoreNodeConfig::builder().local(...)` call.
250    pub allow_loopback: bool,
251    /// Bind a dual-stack IPv6 socket (`true`) or an IPv4-only socket
252    /// (`false`). Defaults to `true`, matching the CLI default.
253    ///
254    /// Set to `false` only when running on hosts without a working IPv6
255    /// stack, to avoid advertising unreachable v6 addresses to the DHT
256    /// (which causes slow connects and junk DHT address records). This
257    /// mirrors the `--ipv4-only` flag in `ant-cli`.
258    pub ipv6: bool,
259    /// Per-batch leaf cap for **external-signer** merkle preparation,
260    /// clamped to `3..=MAX_LEAVES` when set (a cap of 2 cannot partition odd
261    /// totals — parts of 3 and 2 compose any count, so 3 is the smallest
262    /// safe cap). `None` (the default) uses the contract maximum
263    /// (`MAX_LEAVES` = 256).
264    ///
265    /// This is a test seam (ADR-0003): a small cap makes
266    /// `file_prepare_upload_with_mode` produce a genuine multi-batch
267    /// prepared upload from a kilobyte file, so the N-signature external
268    /// flow is exercisable in E2E without a multi-GiB fixture. Production
269    /// callers should leave it `None` — a lower cap only means more payment
270    /// transactions for the same chunks.
271    pub merkle_external_batch_cap: Option<usize>,
272}
273
274impl Default for ClientConfig {
275    fn default() -> Self {
276        Self {
277            quote_timeout_secs: DEFAULT_QUOTE_TIMEOUT_SECS,
278            store_timeout_secs: DEFAULT_STORE_TIMEOUT_SECS,
279            merkle_store_timeout_secs: DEFAULT_MERKLE_STORE_TIMEOUT_SECS,
280            chunk_get_timeout_secs: DEFAULT_CHUNK_GET_TIMEOUT_SECS,
281            close_group_size: CLOSE_GROUP_SIZE,
282            quote_concurrency: DEFAULT_QUOTE_CONCURRENCY,
283            store_concurrency: DEFAULT_STORE_CONCURRENCY,
284            adaptive: AdaptiveConfig::default(),
285            allow_loopback: false,
286            ipv6: true,
287            merkle_external_batch_cap: None,
288        }
289    }
290}
291
292/// Build the adaptive controller for a `Client`. Loads any persisted
293/// snapshot, clamps cold-start values into the deprecated-flag bounds
294/// **per channel** (so a pin on `--store-concurrency` does NOT bleed
295/// into the fetch / quote channels), and returns the persistence path
296/// so callers can save back at shutdown.
297fn build_controller(config: &ClientConfig) -> (AdaptiveController, Option<PathBuf>) {
298    let mut adaptive_cfg = config.adaptive.clone();
299
300    // Per-channel ceilings: each legacy field is interpreted as a cap
301    // for ONLY its matching channel. The fetch channel has no
302    // pre-existing legacy field; it always uses the controller's
303    // default ceiling.
304    //
305    // The legacy fields are non-zero by ClientConfig::default(), but
306    // we honor them as bounds only when they would actually CONSTRAIN
307    // the controller — i.e. when smaller than the per-channel default
308    // max. A default ClientConfig must not silently lower the
309    // controller's ceilings.
310    // A value equal to the historic legacy default is treated as
311    // "not pinned by the user" — without this, every default
312    // ClientConfig would silently lower the controller's per-channel
313    // ceilings to the prior static values (32/8) and the controller
314    // could never grow above them.
315    let user_quote_max = config.quote_concurrency;
316    let user_store_max = config.store_concurrency;
317    let quote_pinned = user_quote_max > 0 && user_quote_max != DEFAULT_QUOTE_CONCURRENCY;
318    let store_pinned = user_store_max > 0 && user_store_max != DEFAULT_STORE_CONCURRENCY;
319    if quote_pinned && user_quote_max < adaptive_cfg.max.quote {
320        adaptive_cfg.max.quote = user_quote_max;
321    }
322    if store_pinned && user_store_max < adaptive_cfg.max.store {
323        adaptive_cfg.max.store = user_store_max;
324    }
325
326    // Cold-start values: matched to the prior static defaults. If the
327    // legacy field caps the channel below the cold-start, lower the
328    // start to match — never start above the channel's max.
329    let mut start = ChannelStart::default();
330    start.quote = start.quote.min(adaptive_cfg.max.quote);
331    start.store = start.store.min(adaptive_cfg.max.store);
332    start.fetch = start.fetch.min(adaptive_cfg.max.fetch);
333
334    let adaptive_enabled = adaptive_cfg.enabled;
335    let controller = AdaptiveController::new(start, adaptive_cfg);
336    // Skip disk warm-start entirely when adaptation is disabled —
337    // fixed-concurrency mode means the user wants exactly the cold
338    // start, no surprises from prior runs. (warm_start is also a
339    // no-op when disabled, but skipping the load avoids file I/O
340    // and the path-resolution side effects.)
341    let persist_path = if adaptive_enabled {
342        let p = adaptive::default_persist_path();
343        if let Some(ref path) = p {
344            if let Some(snap) = adaptive::load_snapshot(path) {
345                debug!(path = %path.display(), "adaptive: warm-start from disk");
346                controller.warm_start(snap);
347            }
348        }
349        p
350    } else {
351        // Even with adaptation off, persist_path is computed so
352        // explicit save_adaptive_snapshot() calls still work — but
353        // the controller currently never moves, so saving the cold
354        // start is harmless.
355        adaptive::default_persist_path()
356    };
357
358    // File downloads choose a stream-decrypt batch size per download
359    // from the current fetch cap and usable RAM, then pass it into
360    // self_encryption's runtime batch-size API. The adaptive controller
361    // still drives fan-out inside each batch by re-reading
362    // `controller.fetch.current()` in the decrypt callback.
363
364    (controller, persist_path)
365}
366
367/// Client for the Autonomi decentralized network.
368///
369/// Provides high-level APIs for storing and retrieving chunks
370/// and files on the network.
371pub struct Client {
372    config: ClientConfig,
373    network: Network,
374    wallet: Option<Arc<Wallet>>,
375    evm_network: Option<ant_protocol::evm::Network>,
376    chunk_cache: ChunkCache,
377    next_request_id: AtomicU64,
378    /// Adaptive concurrency controller: replaces the static
379    /// quote/store concurrency knobs. See `adaptive` module.
380    controller: AdaptiveController,
381    /// Path the controller persists its snapshot to. `None` disables
382    /// persistence (useful for tests / non-disk environments).
383    persist_path: Option<PathBuf>,
384    /// Path for the persistent client peer cache. `None` disables the cache.
385    peer_cache_path: Option<PathBuf>,
386}
387
388impl Client {
389    /// Create a client connected to the given P2P node.
390    #[must_use]
391    pub fn from_node(node: Arc<P2PNode>, config: ClientConfig) -> Self {
392        Self::from_node_with_peer_cache(node, config, None)
393    }
394
395    /// Create a client connected to the given P2P node and attach an optional
396    /// persistent peer cache path.
397    #[must_use]
398    pub fn from_node_with_peer_cache(
399        node: Arc<P2PNode>,
400        config: ClientConfig,
401        peer_cache_path: Option<PathBuf>,
402    ) -> Self {
403        let network = Network::from_node(node);
404        let (controller, persist_path) = build_controller(&config);
405        Self {
406            config,
407            network,
408            wallet: None,
409            evm_network: None,
410            chunk_cache: ChunkCache::default(),
411            next_request_id: AtomicU64::new(1),
412            controller,
413            persist_path,
414            peer_cache_path,
415        }
416    }
417
418    /// Create a client connected to bootstrap peers.
419    ///
420    /// Threads `config.allow_loopback` and `config.ipv6` through to
421    /// `Network::new`, which controls the saorsa-transport `local` and
422    /// `ipv6` flags on the underlying `CoreNodeConfig`. See
423    /// `ClientConfig::allow_loopback` and `ClientConfig::ipv6` for details.
424    ///
425    /// # Errors
426    ///
427    /// Returns an error if the P2P node cannot be created or bootstrapping fails.
428    pub async fn connect(
429        bootstrap_peers: &[std::net::SocketAddr],
430        config: ClientConfig,
431    ) -> Result<Self> {
432        debug!(
433            "Connecting to Autonomi network with {} bootstrap peers (allow_loopback={}, ipv6={})",
434            bootstrap_peers.len(),
435            config.allow_loopback,
436            config.ipv6,
437        );
438        let network = Network::new(bootstrap_peers, config.allow_loopback, config.ipv6).await?;
439        let (controller, persist_path) = build_controller(&config);
440        Ok(Self {
441            config,
442            network,
443            wallet: None,
444            evm_network: None,
445            chunk_cache: ChunkCache::default(),
446            next_request_id: AtomicU64::new(1),
447            controller,
448            persist_path,
449            peer_cache_path: None,
450        })
451    }
452
453    /// Set the wallet for payment operations.
454    ///
455    /// Also populates the EVM network from the wallet so that
456    /// token approvals work without a separate `with_evm_network` call.
457    #[must_use]
458    pub fn with_wallet(mut self, wallet: Wallet) -> Self {
459        self.evm_network = Some(wallet.network().clone());
460        self.wallet = Some(Arc::new(wallet));
461        self
462    }
463
464    /// Set the EVM network without requiring a wallet.
465    ///
466    /// This enables token approval and contract interactions
467    /// for external-signer flows where the private key lives outside Rust.
468    #[must_use]
469    pub fn with_evm_network(mut self, network: ant_protocol::evm::Network) -> Self {
470        self.evm_network = Some(network);
471        self
472    }
473
474    /// Get the EVM network, falling back to the wallet's network if available.
475    ///
476    /// # Errors
477    ///
478    /// Returns an error if neither `with_evm_network` nor `with_wallet` was called.
479    pub(crate) fn require_evm_network(&self) -> Result<&ant_protocol::evm::Network> {
480        if let Some(ref net) = self.evm_network {
481            return Ok(net);
482        }
483        if let Some(ref wallet) = self.wallet {
484            return Ok(wallet.network());
485        }
486        Err(Error::Payment(
487            "EVM network not configured — call with_evm_network() or with_wallet() first"
488                .to_string(),
489        ))
490    }
491
492    /// Get the client configuration.
493    #[must_use]
494    pub fn config(&self) -> &ClientConfig {
495        &self.config
496    }
497
498    /// Get a mutable reference to the client configuration.
499    pub fn config_mut(&mut self) -> &mut ClientConfig {
500        &mut self.config
501    }
502
503    /// Get a reference to the network layer.
504    #[must_use]
505    pub fn network(&self) -> &Network {
506        &self.network
507    }
508
509    /// Compute the live network-participation snapshot.
510    ///
511    /// Convenience pass-through to [`Network::health`] — the single
512    /// write-readiness implementation shared by all embedded-client
513    /// consumers (antd, ant-gui, ant-ffi, ant-tui).
514    pub async fn network_health(&self) -> NetworkHealth {
515        self.network.health().await
516    }
517
518    /// Get the wallet, if configured.
519    #[must_use]
520    pub fn wallet(&self) -> Option<&Arc<Wallet>> {
521        self.wallet.as_ref()
522    }
523
524    /// Get a reference to the chunk cache.
525    #[must_use]
526    pub fn chunk_cache(&self) -> &ChunkCache {
527        &self.chunk_cache
528    }
529
530    /// Adaptive concurrency controller. Hot loops read
531    /// `controller().<channel>.current()` to size their fan-out and
532    /// call `.observe(...)` on each completion.
533    #[must_use]
534    pub fn controller(&self) -> &AdaptiveController {
535        &self.controller
536    }
537
538    /// Persist the current adaptive snapshot to disk so the next
539    /// `Client::connect` warm-starts at the learned values instead of
540    /// cold defaults. Best effort — failures log and are discarded.
541    /// Idempotent. Safe to call from a Drop impl or an explicit
542    /// shutdown hook.
543    pub fn save_adaptive_snapshot(&self) {
544        if let Some(ref path) = self.persist_path {
545            adaptive::save_snapshot(path, self.controller.snapshot());
546        }
547    }
548
549    /// Persist currently connected peers that have Direct-tagged addresses in
550    /// the DHT. Best effort; failures are logged and do not affect the client
551    /// operation that just completed.
552    pub async fn save_peer_cache(&self) {
553        if let Some(ref path) = self.peer_cache_path {
554            let node = self.network().node();
555            peer_cache::promote_connected_direct_peers(node.as_ref(), path, node.dht().k_value())
556                .await;
557        }
558    }
559
560    /// Get the next request ID for protocol messages.
561    pub(crate) fn next_request_id(&self) -> u64 {
562        self.next_request_id.fetch_add(1, Ordering::Relaxed)
563    }
564
565    /// Return the chunk PUT-target set: the closest [`PUT_TARGET_WIDTH`] peers
566    /// to the address, each paired with its known network addresses.
567    ///
568    /// Used by the merkle store path, which — unlike single-node payment — has
569    /// no witnessed put-target list to forward, so it fetches the closest-K
570    /// neighbourhood locally.
571    pub(crate) async fn put_target_peers(
572        &self,
573        target: &XorName,
574    ) -> Result<Vec<(PeerId, Vec<MultiAddr>)>> {
575        self.closest_peers(target, PUT_TARGET_WIDTH).await
576    }
577
578    /// Return the requested number of closest peers for a target address.
579    ///
580    /// Queries the DHT for peers by XOR distance. Returns each peer
581    /// paired with its known network addresses.
582    pub(crate) async fn closest_peers(
583        &self,
584        target: &XorName,
585        count: usize,
586    ) -> Result<Vec<(PeerId, Vec<MultiAddr>)>> {
587        let peers = self.network().find_closest_peers(target, count).await?;
588
589        if peers.is_empty() {
590            return Err(Error::InsufficientPeers(
591                "DHT returned no peers for target address".to_string(),
592            ));
593        }
594        Ok(peers)
595    }
596}
597
598/// Persist the adaptive snapshot when the `Client` is dropped, so any
599/// caller — CLI, daemon, library user, integration test — gets
600/// warm-start carry-over for free without remembering to call
601/// `save_adaptive_snapshot()` explicitly. Best effort, sync `std::fs`,
602/// no panic risk on a poisoned mutex (the inner helper handles it).
603///
604/// We deliberately write SYNCHRONOUSLY (not via `spawn_blocking`)
605/// because Drop runs during process shutdown / runtime teardown,
606/// when fire-and-forget background tasks can be dropped before they
607/// complete and the snapshot is silently lost. A small synchronous
608/// stall on a tokio worker (typically <1ms for a local-disk JSON
609/// write of ~50 bytes) is the right tradeoff for guaranteed
610/// persistence — BOUNDED by `DROP_SAVE_TIMEOUT` so a stalled
611/// network-mounted data dir cannot block process shutdown.
612const DROP_SAVE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
613
614impl Drop for Client {
615    fn drop(&mut self) {
616        let Some(path) = self.persist_path.clone() else {
617            return;
618        };
619        let snap = self.controller.snapshot();
620        adaptive::save_snapshot_with_timeout(path, snap, DROP_SAVE_TIMEOUT);
621    }
622}
623
624#[cfg(test)]
625#[allow(clippy::unwrap_used)]
626mod tests {
627    use super::*;
628
629    /// Cover EVERY variant of `data::error::Error`. Build an instance of
630    /// each, classify it, and assert the resulting `Outcome` matches the
631    /// only sensible mapping. If a future commit adds a new error variant
632    /// without updating `classify_error`, this test fails to ensure the
633    /// adaptive controller always sees correct capacity signals.
634    ///
635    /// Mapping policy (mirrors `classify_error` doc):
636    /// - `Timeout` -> `Outcome::Timeout`
637    /// - `Network`, `InsufficientPeers`, `Io`, `Protocol`, `Storage`,
638    ///   `PartialUpload` -> `Outcome::NetworkError` (transport-related
639    ///   or literal capacity failure)
640    /// - everything else -> `Outcome::ApplicationError` (would happen
641    ///   on a perfectly healthy network)
642    #[test]
643    fn classify_error_covers_all_variants() {
644        let cases: Vec<(Error, Outcome)> = vec![
645            (Error::Timeout("t".to_string()), Outcome::Timeout),
646            (Error::Network("n".to_string()), Outcome::NetworkError),
647            (
648                Error::InsufficientPeers("p".to_string()),
649                Outcome::NetworkError,
650            ),
651            (Error::Storage("s".to_string()), Outcome::NetworkError),
652            (Error::Payment("p".to_string()), Outcome::ApplicationError),
653            (Error::Protocol("p".to_string()), Outcome::NetworkError),
654            (
655                Error::InvalidData("d".to_string()),
656                Outcome::ApplicationError,
657            ),
658            // A definitively absent record over a working link — the peers
659            // answered, nothing was stored there. Must NOT register as a
660            // capacity signal.
661            (
662                Error::NotFound("missing".to_string()),
663                Outcome::ApplicationError,
664            ),
665            (
666                Error::Serialization("s".to_string()),
667                Outcome::ApplicationError,
668            ),
669            (Error::Crypto("c".to_string()), Outcome::ApplicationError),
670            (
671                Error::Io(std::io::Error::other("io")),
672                Outcome::NetworkError,
673            ),
674            (Error::Config("c".to_string()), Outcome::ApplicationError),
675            (
676                Error::SignatureVerification("s".to_string()),
677                Outcome::ApplicationError,
678            ),
679            (
680                Error::Encryption("e".to_string()),
681                Outcome::ApplicationError,
682            ),
683            (Error::AlreadyStored, Outcome::ApplicationError),
684            (
685                Error::InsufficientDiskSpace("d".to_string()),
686                Outcome::ApplicationError,
687            ),
688            (
689                Error::CostEstimationInconclusive("c".to_string()),
690                Outcome::ApplicationError,
691            ),
692            (
693                Error::PartialUpload {
694                    stored: vec![],
695                    stored_count: 0,
696                    failed: vec![],
697                    failed_count: 0,
698                    total_chunks: 0,
699                    spend: Box::new(crate::data::error::PartialUploadSpend {
700                        storage_cost_atto: "0".to_string(),
701                        gas_cost_wei: 0,
702                    }),
703                    reason: "r".to_string(),
704                },
705                Outcome::NetworkError,
706            ),
707            (
708                Error::BadQuoteBinding {
709                    peer_id: "peer".to_string(),
710                    detail: "mismatch".to_string(),
711                },
712                Outcome::ApplicationError,
713            ),
714            // A remote application rejection: the node responded with a
715            // structured `ProtocolError`, so the transport succeeded and
716            // this must NOT register as a capacity signal (V2-468).
717            (
718                Error::RemotePut {
719                    address: "abcd".to_string(),
720                    source: ant_protocol::ProtocolError::PaymentFailed("stale quote".to_string()),
721                },
722                Outcome::ApplicationError,
723            ),
724            // A close-group quorum shortfall caused by dial/relay churn with
725            // no PUT-response timeouts — remote peer churn, not local
726            // backpressure, so it must NOT register as a capacity signal
727            // (V2-554). A timeout-bearing shortfall keeps `InsufficientPeers`.
728            (
729                Error::CloseGroupShortfall("Stored on 3 peers, need 4".to_string()),
730                Outcome::ApplicationError,
731            ),
732            // Refusing an oversized external-signer merkle batch happens
733            // before any network work, so it is not a capacity signal.
734            (
735                Error::MerkleBatchTooLarge {
736                    addresses: 257,
737                    max_leaves: 256,
738                },
739                Outcome::ApplicationError,
740            ),
741        ];
742        for (err, expected) in &cases {
743            let got = classify_error(err);
744            assert_eq!(
745                got, *expected,
746                "classify_error({err:?}) = {got:?}, expected {expected:?}",
747            );
748        }
749    }
750
751    /// C4 fix guard: pinning the legacy `quote_concurrency` /
752    /// `store_concurrency` ClientConfig fields must clamp ONLY the
753    /// matching channel's max in the resulting controller. The fetch
754    /// (download) channel must keep its full default ceiling.
755    #[test]
756    fn legacy_concurrency_pin_does_not_bleed_across_channels() {
757        let cfg = ClientConfig {
758            quote_concurrency: 4,
759            store_concurrency: 2,
760            ..ClientConfig::default()
761        };
762        let (controller, _) = build_controller(&cfg);
763        // The store/quote caps must be clamped to the user's pin.
764        assert_eq!(controller.config.max.quote, 4, "quote pin not respected");
765        assert_eq!(controller.config.max.store, 2, "store pin not respected");
766        // The fetch cap must NOT have been lowered — that's the
767        // regression C4 was about.
768        let default_fetch_max = adaptive::ChannelMax::default().fetch;
769        assert_eq!(
770            controller.config.max.fetch, default_fetch_max,
771            "fetch cap was lowered by store/quote pin (C4 regression)"
772        );
773        // Cold-start values must respect the lowered ceilings.
774        assert!(
775            controller.quote.current() <= 4,
776            "quote start exceeds its cap"
777        );
778        assert!(
779            controller.store.current() <= 2,
780            "store start exceeds its cap"
781        );
782    }
783
784    /// Default ClientConfig must NOT silently lower the controller's
785    /// per-channel ceilings — the adaptive defaults give every channel
786    /// real headroom to grow. This guards against future commits
787    /// re-introducing a global clamp.
788    #[test]
789    fn default_client_config_does_not_clamp_controller_max() {
790        let cfg = ClientConfig::default();
791        let (controller, _) = build_controller(&cfg);
792        let defaults = adaptive::ChannelMax::default();
793        // The legacy fields default to 32/8 (the prior static knobs),
794        // both of which are <= the per-channel adaptive defaults
795        // (128/64). build_controller must keep the larger, not clobber
796        // with the legacy values.
797        assert_eq!(controller.config.max.quote, defaults.quote);
798        assert_eq!(controller.config.max.store, defaults.store);
799        assert_eq!(controller.config.max.fetch, defaults.fetch);
800        // Compile-time-ish guard: if a new variant is added to Error,
801        // this match forces an update here.
802        let _ = |e: &Error| match e {
803            Error::Timeout(_)
804            | Error::Network(_)
805            | Error::InsufficientPeers(_)
806            | Error::Storage(_)
807            | Error::Payment(_)
808            | Error::Protocol(_)
809            | Error::InvalidData(_)
810            | Error::NotFound(_)
811            | Error::Serialization(_)
812            | Error::Crypto(_)
813            | Error::Io(_)
814            | Error::Config(_)
815            | Error::SignatureVerification(_)
816            | Error::Encryption(_)
817            | Error::AlreadyStored
818            | Error::InsufficientDiskSpace(_)
819            | Error::CostEstimationInconclusive(_)
820            | Error::Cancelled(_)
821            | Error::PartialUpload { .. }
822            | Error::BadQuoteBinding { .. }
823            | Error::BadQuoteCommitment { .. }
824            | Error::MerkleBatchTooLarge { .. }
825            | Error::RemotePut { .. }
826            | Error::CloseGroupShortfall(_) => (),
827        };
828    }
829}