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}