Skip to main content

ant_core/data/client/
chunk.rs

1//! Chunk storage operations.
2//!
3//! Chunks are immutable, content-addressed data blocks where the address
4//! is the BLAKE3 hash of the content.
5
6use crate::data::client::adaptive::Outcome;
7use crate::data::client::batch::{finalize_batch_payment, PreparedChunk};
8use crate::data::client::diagnostics::{
9    bounded_error, unix_now_ms, DownloadDiagnosticsOutcome, DownloadDiagnosticsRecord,
10    DownloadDiagnosticsSender, DownloadRequestCorrelation,
11};
12use crate::data::client::peer_xor_distance;
13use crate::data::client::Client;
14use crate::data::error::{Error, Result};
15use crate::data::network::ClosestPeerDiagnostics;
16use ant_protocol::evm::{QuoteHash, TxHash};
17use ant_protocol::transport::{MultiAddr, PeerId, PeerRouteKind};
18use ant_protocol::{
19    compute_address, detect_proof_type, send_and_await_chunk_response,
20    send_and_await_chunk_response_with_metadata, ChunkGetRequest, ChunkGetResponse, ChunkMessage,
21    ChunkMessageBody, ChunkProtocolResponse, ChunkPutRequest, ChunkPutResponse, DataChunk,
22    ProofType, ProtocolError, XorName, CLOSE_GROUP_MAJORITY,
23};
24use bytes::Bytes;
25use futures::stream::{self, FuturesUnordered, StreamExt};
26use std::collections::HashMap;
27use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
28use std::time::{Duration, Instant};
29use tracing::{debug, info, warn};
30
31/// Data type identifier for chunks (used in quote requests).
32const CHUNK_DATA_TYPE: u32 = 0;
33
34/// Number of diagnostics-enabled peer requests currently in flight in this
35/// process. The counter is untouched when runtime diagnostics are disabled.
36static ACTIVE_DIAGNOSTIC_REQUESTS: AtomicUsize = AtomicUsize::new(0);
37static NEXT_DIAGNOSTIC_LOOKUP_ID: AtomicUsize = AtomicUsize::new(1);
38
39struct ActiveDiagnosticRequestGuard;
40
41impl ActiveDiagnosticRequestGuard {
42    fn enter() -> (Self, usize) {
43        let active = ACTIVE_DIAGNOSTIC_REQUESTS.fetch_add(1, AtomicOrdering::Relaxed) + 1;
44        (Self, active)
45    }
46}
47
48impl Drop for ActiveDiagnosticRequestGuard {
49    fn drop(&mut self) {
50        ACTIVE_DIAGNOSTIC_REQUESTS.fetch_sub(1, AtomicOrdering::Relaxed);
51    }
52}
53
54fn encode_diagnostic_chunk_get_request(
55    address: &XorName,
56    correlation: &DownloadRequestCorrelation,
57) -> Result<Vec<u8>> {
58    ChunkMessage {
59        request_id: correlation.request_id,
60        body: ChunkMessageBody::GetRequest(ChunkGetRequest::new(*address)),
61    }
62    .encode()
63    .map_err(|e| Error::Protocol(format!("Failed to encode GET request: {e}")))
64}
65
66/// Why a single-peer PUT was declined. Drives the surfaced aggregate error
67/// and keeps the store AIMD limiter honest — only genuine local backpressure
68/// (a PUT-response `Timeout`) is a "client is sending too fast" signal; a node
69/// that responds with a structured rejection is an application-level decline
70/// (ADR-0002 / V2-468), and a bare dial/relay failure is remote peer churn,
71/// not local capacity (V2-554).
72#[derive(Clone, Copy)]
73enum PutRejection {
74    /// Node is out of storage (`ProtocolError::StorageFailed`) — try a
75    /// further peer.
76    Full,
77    /// Payment did not clear the node's local price floor, or the proof's
78    /// issuers are not close enough in this peer's view
79    /// (`ProtocolError::PaymentFailed`), or the node asked for more than was
80    /// paid (`ChunkPutResponse::PaymentRequired` → [`Error::Payment`]) — skip
81    /// this peer, do not re-quote.
82    PriceFloor,
83    /// Some other structured remote rejection.
84    OtherRemote,
85    /// The peer accepted the connection but did not answer the PUT within the
86    /// deadline (`Error::Timeout`). This is the genuine local-backpressure
87    /// signal: under real congestion the client's own requests time out, so a
88    /// shortfall carrying any timeout stays a capacity signal (V2-554).
89    Timeout,
90    /// A dial/relay/transport failure — the peer could not be reached at all
91    /// (`Error::Network` and other non-response errors), typically a dead or
92    /// stale relayed DHT address. This is remote peer churn, not local
93    /// backpressure, so a shortfall made up purely of these must not push the
94    /// store AIMD limiter down (V2-554).
95    Dial,
96}
97
98/// Classify a failed single-peer PUT (ADR-0002 / V2-468 / V2-554). A
99/// `RemotePut` carries the node's structured `ProtocolError`; a
100/// `PaymentRequired` response surfaces as [`Error::Payment`]; a
101/// [`Error::Timeout`] is genuine local backpressure; anything else is a
102/// dial/relay failure (remote churn).
103fn classify_put_failure(error: &Error) -> PutRejection {
104    match error {
105        Error::RemotePut { source, .. } => match source {
106            ProtocolError::StorageFailed(_) => PutRejection::Full,
107            ProtocolError::PaymentFailed(_) => PutRejection::PriceFloor,
108            _ => PutRejection::OtherRemote,
109        },
110        // A `PaymentRequired` PUT response (the node wants more than was paid)
111        // arrives as `Error::Payment`. It is a structured application-level
112        // decline — skip the peer and advance fallback, exactly like a
113        // price-floor `PaymentFailed` — not a transport shortfall, so it must
114        // not push the store AIMD limiter down (ADR-0002 / V2-468).
115        Error::Payment(_) => PutRejection::PriceFloor,
116        // The peer did not answer in time: genuine local backpressure.
117        Error::Timeout(_) => PutRejection::Timeout,
118        // Could not reach the peer at all: dial/relay churn (remote), not a
119        // local-capacity signal.
120        _ => PutRejection::Dial,
121    }
122}
123
124/// Decide the error for a close-group store that fell short of quorum.
125///
126/// Only genuine local backpressure should push the store AIMD limiter down.
127/// The failure mix decides which signal to surface:
128///
129/// 1. **Any PUT-response timeout** (`timeout > 0`): the client's own requests
130///    are timing out — genuine local congestion. Surface `InsufficientPeers`
131///    (classified `NetworkError`) so the limiter still backs off (V2-554).
132/// 2. **No timeouts, no dial failures** — every failure was an application
133///    decline (full / price-floor / `PaymentRequired` / other remote
134///    rejection): surface the representative application error so the shortfall
135///    classifies `ApplicationError` and does not suppress the limiter
136///    (ADR-0002 / V2-468).
137/// 3. **No timeouts, but dial/relay failures present**: the shortfall is
138///    close-group dial churn (dead/stale relayed peer addresses) — remote peer
139///    churn, not local capacity. Surface [`Error::CloseGroupShortfall`]
140///    (classified `ApplicationError`) so it does NOT push the limiter down
141///    (V2-554). Still recoverable/retryable.
142fn put_shortfall_error(
143    timeout: usize,
144    dial: usize,
145    first_app_rejection: Option<Error>,
146    shortfall_message: String,
147) -> Error {
148    if timeout > 0 {
149        return Error::InsufficientPeers(shortfall_message);
150    }
151    if dial == 0 {
152        if let Some(app_rejection) = first_app_rejection {
153            return app_rejection;
154        }
155    }
156    Error::CloseGroupShortfall(shortfall_message)
157}
158
159/// Result of one sweep over a chunk's close group.
160///
161/// Either we got the chunk from some peer, or every peer in the group
162/// returned NotFound, timed out, or hit a transport / protocol error.
163/// The counts feed the retry decision (`is_authoritative_not_found`):
164/// only a *unanimous* NotFound from a *well-sampled* close group counts
165/// as authoritative data absence — anything else (a non-unanimous
166/// result, or a thin/under-sampled DHT walk) leaves room for the actual
167/// storer to be in the timeout / network-error / protocol-error bucket
168/// or outside the sampled view, and is worth a retry against a freshly
169/// re-walked close group.
170struct CloseGroupOutcome {
171    chunk: Option<DataChunk>,
172    queried: usize,
173    not_found: usize,
174    timeout: usize,
175    network_err: usize,
176    /// Counts peers that responded with a remote `Error` (e.g.
177    /// "Chunk verification failed") or any other protocol-level error
178    /// that classifies as `Error::Protocol`. Treated the same as
179    /// `timeout` / `network_err` for retry decisions: one peer's bad
180    /// response must not abort the whole close-group sweep — the
181    /// remaining peers might still have a clean copy.
182    protocol_err: usize,
183}
184
185/// `true` if the close-group sweep is strong enough evidence to
186/// conclude the chunk is genuinely absent, so retrying is pointless.
187///
188/// Two conditions, both required:
189///
190/// 1. *Unanimous*: every peer we managed to query responded with an
191///    authoritative NotFound (`not_found == queried`). An earlier
192///    version used a majority quorum (`not_found >= close_group_size /
193///    2 + 1`), but production traffic disproved that: storage
194///    replicates to `CLOSE_GROUP_MAJORITY` (4) of the K=7 close-group
195///    peers, so up to 3 peers legitimately don't store any given chunk
196///    and a `not_found=4 timeout=3` result is "3 storers we couldn't
197///    reach" plus "4 non-storers," not data loss.
198///
199/// 2. *Well-sampled*: at least `CLOSE_GROUP_MAJORITY` peers were
200///    queried. `closest_peers` (via `find_closest_peers`) accepts
201///    any non-empty DHT result, so a thin/under-sampled walk can return
202///    1 or 2 peers. A `1/1` or `3/3` NotFound from such a walk is NOT
203///    authoritative — the real replica majority may sit entirely
204///    outside that narrow view. Requiring a majority-sized sample means
205///    a thin lookup falls through to the retry (which re-walks the DHT)
206///    instead of being declared a final absence.
207fn is_authoritative_not_found(not_found: usize, queried: usize) -> bool {
208    queried >= CLOSE_GROUP_MAJORITY && not_found == queried
209}
210
211/// Store-response timeout for non-merkle chunk PUTs.
212const STORE_RESPONSE_TIMEOUT: Duration = Duration::from_secs(10);
213
214/// Extra waves allowed after the computed diagnostic peer-sweep deadline.
215const DIAGNOSTIC_TIMEOUT_PADDING_WAVES: usize = 1;
216
217/// Result of fetching one chunk address from one close-group peer.
218pub struct ChunkPeerGetResult {
219    /// Peer queried for the chunk.
220    pub peer_id: PeerId,
221    /// Known network addresses used for the peer.
222    pub peer_addrs: Vec<MultiAddr>,
223    /// XOR distance from `peer_id` to the chunk address.
224    pub xor_distance: [u8; 32],
225    /// Per-peer fetch result.
226    pub chunk_result: Result<Option<DataChunk>>,
227}
228
229#[derive(Clone)]
230struct ChunkPeerGetTarget {
231    index: usize,
232    peer_id: PeerId,
233    peer_addrs: Vec<MultiAddr>,
234    xor_distance: [u8; 32],
235}
236
237/// Shared context for emitting per-chunk download diagnostics records from
238/// the normal-path chunk fetch. Constructed only when the caller supplied a
239/// [`DownloadDiagnosticsSender`] (i.e. `--download-diagnostics` was passed);
240/// otherwise `None` and no records are allocated.
241///
242/// `sweep` is set per `chunk_get_try_closest_peers` call: `"initial"` for the
243/// first close-group sweep, `"retry"` for the internal retry sweep.
244pub(crate) struct ChunkFetchDiagnostics<'a> {
245    sender: &'a DownloadDiagnosticsSender,
246    file_attempt: usize,
247    chunk_index: usize,
248    chunk_address: [u8; 32],
249    fetch_cap: usize,
250}
251
252impl<'a> ChunkFetchDiagnostics<'a> {
253    pub(crate) fn new(
254        sender: &'a DownloadDiagnosticsSender,
255        file_attempt: usize,
256        chunk_index: usize,
257        chunk_address: [u8; 32],
258        fetch_cap: usize,
259    ) -> Self {
260        Self {
261            sender,
262            file_attempt,
263            chunk_index,
264            chunk_address,
265            fetch_cap,
266        }
267    }
268
269    /// Emit a per-peer-attempt record. `lookup_duration_ms` is attached only
270    /// for the first peer attempt of the sweep.
271    #[allow(clippy::too_many_arguments)]
272    fn emit_peer_attempt(
273        &self,
274        sweep: &'static str,
275        peer_attempt: usize,
276        lookup_duration_ms: Option<u64>,
277        lookup_correlation_id: &str,
278        peer_context: &ClosestPeerDiagnostics,
279        expected_peer: &PeerId,
280        source_peer: Option<&PeerId>,
281        transport_source: Option<&MultiAddr>,
282        route: PeerRouteKind,
283        peer_connected_before_request: bool,
284        active_requests_at_start: usize,
285        request_started_unix_ms: u64,
286        request_completed_unix_ms: u64,
287        correlation: &DownloadRequestCorrelation,
288        response_elapsed_ms: u64,
289        bytes: u64,
290        outcome: DownloadDiagnosticsOutcome,
291        error: Option<String>,
292    ) {
293        self.sender
294            .try_emit(DownloadDiagnosticsRecord::peer_attempt(
295                self.file_attempt,
296                self.chunk_index,
297                &self.chunk_address,
298                sweep,
299                peer_attempt,
300                lookup_duration_ms,
301                lookup_correlation_id,
302                &expected_peer.to_string(),
303                peer_context
304                    .addresses
305                    .iter()
306                    .map(ToString::to_string)
307                    .collect(),
308                peer_context.address_types.clone(),
309                peer_context.local_last_seen_age_ms,
310                peer_context.publisher_address_set_age_ms,
311                peer_context.publisher_address_set_unix_ns,
312                source_peer.map(ToString::to_string).as_deref(),
313                transport_source.map(ToString::to_string).as_deref(),
314                route.as_str(),
315                (route == PeerRouteKind::Unknown)
316                    .then_some(DownloadDiagnosticsRecord::ROUTE_UNKNOWN_NOTE),
317                Some(peer_connected_before_request),
318                Some(active_requests_at_start),
319                Some(self.fetch_cap),
320                request_started_unix_ms,
321                request_completed_unix_ms,
322                correlation,
323                response_elapsed_ms,
324                bytes,
325                outcome,
326                error,
327            ));
328    }
329
330    /// Emit a chunk-level record (cache hit, lookup error, or exhausted).
331    fn emit_chunk_level(
332        &self,
333        sweep: &'static str,
334        bytes: u64,
335        outcome: DownloadDiagnosticsOutcome,
336        error: Option<String>,
337    ) {
338        self.sender.try_emit(DownloadDiagnosticsRecord::chunk_level(
339            self.file_attempt,
340            self.chunk_index,
341            &self.chunk_address,
342            sweep,
343            Some(self.fetch_cap),
344            bytes,
345            outcome,
346            error,
347        ));
348    }
349}
350
351/// Classify a `chunk_get_from_peer` result into a diagnostics outcome plus the
352/// returned byte count and whether a response was received (so `source_peer`
353/// can be attributed). No secrets: the bounded error string uses only the
354/// error's `Display` form.
355fn classify_peer_attempt(
356    result: &Result<Option<DataChunk>>,
357) -> (DownloadDiagnosticsOutcome, u64, bool, Option<String>) {
358    match result {
359        Ok(Some(chunk)) => (
360            DownloadDiagnosticsOutcome::Found,
361            chunk.content.len() as u64,
362            true,
363            None,
364        ),
365        Ok(None) => (DownloadDiagnosticsOutcome::NotFound, 0, true, None),
366        Err(Error::Timeout(msg)) => (
367            DownloadDiagnosticsOutcome::Timeout,
368            0,
369            false,
370            Some(bounded_error("timeout", msg)),
371        ),
372        Err(Error::Network(msg)) => (
373            DownloadDiagnosticsOutcome::NetworkError,
374            0,
375            false,
376            Some(bounded_error("network", msg)),
377        ),
378        // Invalid data can only be constructed after a response body was
379        // received and validated, so attributing the matched peer is sound.
380        Err(Error::InvalidData(msg)) => (
381            DownloadDiagnosticsOutcome::ProtocolError,
382            0,
383            true,
384            Some(bounded_error("protocol", msg)),
385        ),
386        // `Protocol` includes both a remote GET error and a local request
387        // encoding failure. Without a distinct provenance bit, conservatively
388        // avoid claiming a response peer for either case.
389        Err(Error::Protocol(msg)) => (
390            DownloadDiagnosticsOutcome::ProtocolError,
391            0,
392            false,
393            Some(bounded_error("protocol", msg)),
394        ),
395        Err(e) => (
396            DownloadDiagnosticsOutcome::ProtocolError,
397            0,
398            false,
399            Some(bounded_error("protocol", &e.to_string())),
400        ),
401    }
402}
403
404fn chunk_peer_get_targets(
405    peers: Vec<(PeerId, Vec<MultiAddr>)>,
406    address: &XorName,
407) -> Vec<ChunkPeerGetTarget> {
408    peers
409        .into_iter()
410        .enumerate()
411        .map(|(index, (peer_id, peer_addrs))| ChunkPeerGetTarget {
412            index,
413            peer_id,
414            peer_addrs,
415            xor_distance: peer_xor_distance(&peer_id, address),
416        })
417        .collect()
418}
419
420fn sort_chunk_peer_get_results(results: &mut [ChunkPeerGetResult]) {
421    results.sort_by_key(|result| result.xor_distance);
422}
423
424fn diagnostic_peer_get_concurrency(peer_count: usize, close_group_size: usize) -> usize {
425    peer_count.min(close_group_size.max(1))
426}
427
428fn diagnostic_peer_get_overall_timeout(
429    per_peer_timeout: Duration,
430    target_count: usize,
431    concurrency_limit: usize,
432) -> Duration {
433    let concurrency_limit = concurrency_limit.max(1);
434    let peer_get_waves = target_count.div_ceil(concurrency_limit);
435    let timeout_waves = peer_get_waves.saturating_add(DIAGNOSTIC_TIMEOUT_PADDING_WAVES);
436    let timeout_waves = u32::try_from(timeout_waves).unwrap_or(u32::MAX);
437
438    per_peer_timeout.saturating_mul(timeout_waves)
439}
440
441fn timed_out_chunk_peer_get_result(
442    target: &ChunkPeerGetTarget,
443    address: &XorName,
444    timeout: Duration,
445) -> ChunkPeerGetResult {
446    let addr_hex = hex::encode(address);
447    let timeout_secs = timeout.as_secs();
448    ChunkPeerGetResult {
449        peer_id: target.peer_id,
450        peer_addrs: target.peer_addrs.clone(),
451        xor_distance: target.xor_distance,
452        chunk_result: Err(Error::Timeout(format!(
453            "Diagnostic chunk GET sweep timed out before peer {} completed for chunk {addr_hex} after {timeout_secs}s",
454            target.peer_id
455        ))),
456    }
457}
458
459fn store_response_timeout_for_proof(proof: &[u8], merkle_timeout_secs: u64) -> Duration {
460    match detect_proof_type(proof) {
461        Some(ProofType::Merkle) => Duration::from_secs(merkle_timeout_secs),
462        _ => STORE_RESPONSE_TIMEOUT,
463    }
464}
465
466impl Client {
467    /// Run `chunk_get` and feed one byte-aware observation per call to
468    /// the adaptive fetch limiter. Use this from any consumer that
469    /// drives chunk-fetch concurrency from `controller().fetch.current()`
470    /// — the controller's window relies on every call along the hot
471    /// path producing an observation.
472    ///
473    /// Classifier semantics: see `chunk_get_outcome`. Most importantly,
474    /// `Ok(None)` is treated as `Outcome::Timeout`, not Success, so a
475    /// sustained run of close-group exhaustions correctly drives the
476    /// cap down rather than silently inflating it.
477    pub(crate) async fn chunk_get_observed(&self, address: &XorName) -> Result<Option<DataChunk>> {
478        self.chunk_get_observed_from_closest_peers(address, self.config().close_group_size, None)
479            .await
480    }
481
482    pub(crate) async fn chunk_get_observed_from_closest_peers(
483        &self,
484        address: &XorName,
485        peer_count: usize,
486        diag: Option<&ChunkFetchDiagnostics<'_>>,
487    ) -> Result<Option<DataChunk>> {
488        let started = Instant::now();
489        let result = self
490            .chunk_get_from_closest_peers_with_diagnostics(address, peer_count, diag)
491            .await;
492        let latency = started.elapsed();
493        let bytes = result
494            .as_ref()
495            .ok()
496            .and_then(Option::as_ref)
497            .map_or(0, |chunk| chunk.content.len() as u64);
498        self.controller()
499            .fetch
500            .observe_with_bytes(chunk_get_outcome(&result), latency, bytes);
501        result
502    }
503}
504
505/// Map a `chunk_get` outcome to an adaptive controller `Outcome`.
506///
507/// This is the result-aware classifier used by the file-download paths.
508/// It differs from `classify_error` in one critical way: an `Ok(None)`
509/// from `chunk_get` is `Outcome::Timeout`, not `Outcome::Success`. By
510/// the time `chunk_get` returns `Ok(None)` it has already exhausted
511/// the close group across its first attempt + retry sweep, so
512/// `Ok(None)` is the controller's load-shedding signal — a sustained
513/// run of them on a saturated home link is exactly the case where the
514/// cap should shrink.
515///
516/// Healthy returns (`Ok(Some(_))`) are Success regardless of how many
517/// internal peer attempts the chunk_get had to make. The controller
518/// does not need to see internal peer noise; that's noise about the
519/// production network's natural peer-side variability, not about the
520/// client's effective capacity.
521pub(crate) fn chunk_get_outcome(result: &Result<Option<DataChunk>>) -> Outcome {
522    match result {
523        Ok(Some(_)) => Outcome::Success,
524        Ok(None) => Outcome::Timeout,
525        Err(Error::Timeout(_)) => Outcome::Timeout,
526        Err(Error::Network(_)) => Outcome::NetworkError,
527        Err(_) => Outcome::ApplicationError,
528    }
529}
530
531impl Client {
532    /// Store a chunk on the Autonomi network with payment.
533    ///
534    /// Checks if the chunk already exists before paying. If it does,
535    /// returns the address immediately without incurring on-chain costs.
536    /// Otherwise collects quotes, pays on-chain, then stores with proof
537    /// to `CLOSE_GROUP_MAJORITY` peers.
538    ///
539    /// # Errors
540    ///
541    /// Returns an error if payment or the network operation fails.
542    pub async fn chunk_put(&self, content: Bytes) -> Result<XorName> {
543        let address = compute_address(&content);
544        let data_size = u64::try_from(content.len())
545            .map_err(|e| Error::InvalidData(format!("content size too large: {e}")))?;
546
547        match self
548            .pay_for_storage(&address, data_size, CHUNK_DATA_TYPE)
549            .await
550        {
551            Ok((proof, peers)) => self.chunk_put_to_close_group(content, proof, &peers).await,
552            Err(Error::AlreadyStored) => {
553                debug!(
554                    "Chunk {} already stored on network, skipping payment",
555                    hex::encode(address)
556                );
557                Ok(address)
558            }
559            Err(e) => Err(e),
560        }
561    }
562
563    /// Test-only: pay for `content`, then store it with `dead_count`
564    /// unreachable peers prepended to the real put-target set.
565    ///
566    /// Every initial send hits a dead peer and fails, so the store can only
567    /// reach quorum by falling back through the real put-targets (the closest-K
568    /// set the quote plan already returned), reusing the same `ProofOfPayment`.
569    /// Pass `dead_count >= CLOSE_GROUP_MAJORITY` so a full quorum's worth of
570    /// replacements must come from the fallback; a success proves the fallback
571    /// works end-to-end.
572    ///
573    /// # Errors
574    ///
575    /// Returns an error if payment fails or quorum cannot be reached.
576    #[cfg(feature = "test-utils")]
577    pub async fn chunk_put_with_dead_initial_peers(
578        &self,
579        content: Bytes,
580        dead_count: usize,
581    ) -> Result<XorName> {
582        let address = compute_address(&content);
583        let data_size = u64::try_from(content.len())
584            .map_err(|e| Error::InvalidData(format!("content size too large: {e}")))?;
585        let (proof, real_peers) = self
586            .pay_for_storage(&address, data_size, CHUNK_DATA_TYPE)
587            .await?;
588        // Unreachable peers (random id, no addresses) first: every initial send
589        // fails, so quorum can only be reached by falling back through the real
590        // put-target set that follows.
591        let mut peers: Vec<(PeerId, Vec<MultiAddr>)> = (0..dead_count)
592            .map(|_| (PeerId::random(), Vec::new()))
593            .collect();
594        peers.extend(real_peers);
595        self.chunk_put_to_close_group(content, proof, &peers).await
596    }
597
598    /// Store a chunk to `CLOSE_GROUP_MAJORITY` peers, falling back past full or
599    /// over-priced members of the supplied put-target set (ADR-0002).
600    ///
601    /// Sends the PUT concurrently to the first `CLOSE_GROUP_MAJORITY` peers. On
602    /// each failure it advances to the next peer in `peers` — which the caller
603    /// supplies as the chunk's closest ~K neighbourhood, so no further DHT
604    /// lookup is needed. Every peer reuses the same payment proof: a node
605    /// accepts it as long as one of the proof's quote issuers is within that
606    /// peer's own local closest view, so the client never needs to re-quote or
607    /// re-pay to route around a full node.
608    ///
609    /// # Errors
610    ///
611    /// Returns an error if fewer than `CLOSE_GROUP_MAJORITY` peers accept
612    /// the chunk.
613    pub(crate) async fn chunk_put_to_close_group(
614        &self,
615        content: Bytes,
616        proof: Vec<u8>,
617        peers: &[(PeerId, Vec<MultiAddr>)],
618    ) -> Result<XorName> {
619        let address = compute_address(&content);
620
621        let initial_count = peers.len().min(CLOSE_GROUP_MAJORITY);
622        let (initial_peers, fallback_peers) = peers.split_at(initial_count);
623        let mut fallback_iter = fallback_peers.iter();
624
625        let mut put_futures = FuturesUnordered::new();
626        for (peer_id, addrs) in initial_peers {
627            put_futures.push(self.spawn_chunk_put(
628                content.clone(),
629                proof.clone(),
630                *peer_id,
631                addrs.clone(),
632            ));
633        }
634
635        let mut success_count = 0usize;
636        let mut failures: Vec<String> = Vec::new();
637        // Tally the *cause* of each failure. The store AIMD limiter must only be
638        // pushed down by a transport shortfall (V2-468): a node that responds —
639        // a structured `RemotePut` decline, or `PaymentRequired` surfacing as
640        // `Error::Payment` — declined at the application layer and is not
641        // evidence the client is sending too fast. The per-cause counts also
642        // surface a legible aggregate reason; hold the first application-level
643        // rejection as the representative error.
644        let mut full = 0usize;
645        let mut price_floor = 0usize;
646        let mut other_remote = 0usize;
647        let mut timeout = 0usize;
648        let mut dial = 0usize;
649        let mut first_app_rejection: Option<Error> = None;
650
651        while let Some((peer_id, result)) = put_futures.next().await {
652            match result {
653                Ok(_) => {
654                    success_count += 1;
655                    if success_count >= CLOSE_GROUP_MAJORITY {
656                        debug!(
657                            "Chunk {} stored on {success_count} peers (majority reached)",
658                            hex::encode(address)
659                        );
660                        return Ok(address);
661                    }
662                }
663                Err(e) => {
664                    warn!("Failed to store chunk on {peer_id}: {e}");
665                    failures.push(format!("{peer_id}: {e}"));
666                    match classify_put_failure(&e) {
667                        PutRejection::Full => full += 1,
668                        PutRejection::PriceFloor => price_floor += 1,
669                        PutRejection::OtherRemote => other_remote += 1,
670                        PutRejection::Timeout => timeout += 1,
671                        PutRejection::Dial => dial += 1,
672                    }
673                    // An application-level decline is `RemotePut` (a structured
674                    // node rejection) or `Error::Payment` (`PaymentRequired`):
675                    // capture the first so an all-application shortfall surfaces
676                    // as `ApplicationError`, not `InsufficientPeers`
677                    // (`NetworkError`), and never suppresses the limiter.
678                    if matches!(e, Error::RemotePut { .. } | Error::Payment(_))
679                        && first_app_rejection.is_none()
680                    {
681                        first_app_rejection = Some(e);
682                    }
683
684                    // Advance to the next peer in the put-target set, reusing
685                    // the same proof.
686                    if let Some((fb_peer, fb_addrs)) = fallback_iter.next() {
687                        debug!(
688                            "Falling back to peer {fb_peer} for chunk {}",
689                            hex::encode(address)
690                        );
691                        put_futures.push(self.spawn_chunk_put(
692                            content.clone(),
693                            proof.clone(),
694                            *fb_peer,
695                            fb_addrs.clone(),
696                        ));
697                    }
698                }
699            }
700        }
701
702        // Quorum not reached. A timeout-bearing shortfall is genuine local
703        // backpressure (capacity signal); an application-only shortfall surfaces
704        // the representative app error; a pure dial-churn shortfall surfaces a
705        // neutral `CloseGroupShortfall` (V2-554). See `put_shortfall_error`.
706        let aggregate = format!(
707            "Stored on {success_count} peers, need {CLOSE_GROUP_MAJORITY} \
708             (full: {full}, price-floor: {price_floor}, other-rejection: {other_remote}, \
709             timeout: {timeout}, dial: {dial}). Failures: [{}]",
710            failures.join("; ")
711        );
712        Err(put_shortfall_error(
713            timeout,
714            dial,
715            first_app_rejection,
716            aggregate,
717        ))
718    }
719
720    /// Build a chunk PUT future for a single peer. Takes owned peer data so
721    /// the future can outlive a fallback queue entry popped per iteration.
722    async fn spawn_chunk_put(
723        &self,
724        content: Bytes,
725        proof: Vec<u8>,
726        peer_id: PeerId,
727        addrs: Vec<MultiAddr>,
728    ) -> (PeerId, Result<XorName>) {
729        let result = self
730            .chunk_put_with_proof(content, proof, &peer_id, &addrs)
731            .await;
732        (peer_id, result)
733    }
734
735    /// Store a chunk on the Autonomi network with a pre-built payment proof.
736    ///
737    /// Sends to a single peer. Callers that need replication across the
738    /// close group should use `chunk_put_to_close_group` instead.
739    ///
740    /// # Errors
741    ///
742    /// Returns an error if the network operation fails.
743    pub async fn chunk_put_with_proof(
744        &self,
745        content: Bytes,
746        proof: Vec<u8>,
747        target_peer: &PeerId,
748        peer_addrs: &[MultiAddr],
749    ) -> Result<XorName> {
750        let address = compute_address(&content);
751        let node = self.network().node();
752        let timeout =
753            store_response_timeout_for_proof(&proof, self.config().merkle_store_timeout_secs);
754        let timeout_secs = timeout.as_secs();
755
756        let request_id = self.next_request_id();
757        // `content` is a refcounted `Bytes` shared with the sibling
758        // close-group sends; pass it through directly so each peer shares
759        // the same backing buffer instead of deep-copying the 4 MB payload.
760        let request = ChunkPutRequest::with_payment(address, content, proof);
761        let message = ChunkMessage {
762            request_id,
763            body: ChunkMessageBody::PutRequest(request),
764        };
765        let message_bytes = message
766            .encode()
767            .map_err(|e| Error::Protocol(format!("Failed to encode PUT request: {e}")))?;
768
769        let addr_hex = hex::encode(address);
770
771        let result = send_and_await_chunk_response(
772            node,
773            target_peer,
774            message_bytes,
775            request_id,
776            timeout,
777            peer_addrs,
778            |body| match body {
779                ChunkMessageBody::PutResponse(ChunkPutResponse::Success { address: addr }) => {
780                    debug!("Chunk stored at {}", hex::encode(addr));
781                    Some(Ok(addr))
782                }
783                ChunkMessageBody::PutResponse(ChunkPutResponse::AlreadyExists {
784                    address: addr,
785                }) => {
786                    debug!("Chunk already exists at {}", hex::encode(addr));
787                    Some(Ok(addr))
788                }
789                ChunkMessageBody::PutResponse(ChunkPutResponse::PaymentRequired { message }) => {
790                    Some(Err(Error::Payment(format!("Payment required: {message}"))))
791                }
792                ChunkMessageBody::PutResponse(ChunkPutResponse::Error(e)) => {
793                    // Preserve the structured remote reason instead of
794                    // flattening it into `Error::Protocol`. The node
795                    // responded, so the transport round-trip succeeded —
796                    // this is an application-level rejection and must not
797                    // suppress the store AIMD limiter (V2-468).
798                    Some(Err(Error::RemotePut {
799                        address: addr_hex.clone(),
800                        source: e,
801                    }))
802                }
803                _ => None,
804            },
805            |e| Error::Network(format!("Failed to send PUT to peer: {e}")),
806            || {
807                Error::Timeout(format!(
808                    "Timeout waiting for store response after {timeout_secs}s"
809                ))
810            },
811        )
812        .await;
813
814        result
815    }
816
817    /// Retrieve a chunk from the Autonomi network.
818    ///
819    /// Queries all peers in the close group for the chunk address,
820    /// returning the first successful response. This handles the case
821    /// where the storing peer differs from the first peer returned by
822    /// DHT routing.
823    ///
824    /// ## Adaptive controller feedback
825    ///
826    /// Each per-peer GET attempt is fed individually to the adaptive
827    /// fetch limiter via `controller().fetch.observe(...)`. This is
828    /// deliberately finer-grained than wrapping the outer `chunk_get`
829    /// with `observe_op`: when a chunk takes 6 peer tries to land,
830    /// 5 of them are real capacity signals (timeouts / network errors)
831    /// that should pull the cap down even if the chunk eventually
832    /// succeeds. The outer `Ok(_)` would mask all five as a single
833    /// `Outcome::Success`. See `adaptive::Outcome` for the per-attempt
834    /// classification rules used below.
835    ///
836    /// Callers should therefore NOT wrap `chunk_get` in `observe_op`.
837    ///
838    /// # Errors
839    ///
840    /// Returns an error if the network operation fails.
841    pub async fn chunk_get(&self, address: &XorName) -> Result<Option<DataChunk>> {
842        self.chunk_get_from_closest_peers(address, self.config().close_group_size)
843            .await
844    }
845
846    /// Retrieve a chunk from the requested number of closest peers.
847    ///
848    /// Queries peers in XOR-distance order for the chunk address,
849    /// returning the first successful response. This handles the case
850    /// where the storing peer differs from the first peer returned by
851    /// DHT routing.
852    ///
853    /// # Errors
854    ///
855    /// Returns an error if the network operation fails.
856    pub async fn chunk_get_from_closest_peers(
857        &self,
858        address: &XorName,
859        peer_count: usize,
860    ) -> Result<Option<DataChunk>> {
861        self.chunk_get_from_closest_peers_with_diagnostics(address, peer_count, None)
862            .await
863    }
864
865    async fn chunk_get_from_closest_peers_with_diagnostics(
866        &self,
867        address: &XorName,
868        peer_count: usize,
869        diag: Option<&ChunkFetchDiagnostics<'_>>,
870    ) -> Result<Option<DataChunk>> {
871        // Check cache first, with integrity verification.
872        if let Some(cached) = self.chunk_cache().get(address) {
873            let computed = compute_address(&cached);
874            if computed == *address {
875                debug!("Cache hit for chunk {}", hex::encode(address));
876                if let Some(diag) = diag {
877                    diag.emit_chunk_level(
878                        "initial",
879                        cached.len() as u64,
880                        DownloadDiagnosticsOutcome::CacheHit,
881                        None,
882                    );
883                }
884                return Ok(Some(DataChunk::new(*address, cached)));
885            }
886            // Cache entry corrupted — evict and fall through to network fetch.
887            debug!(
888                "Cache corruption detected for {}: evicting",
889                hex::encode(address)
890            );
891            self.chunk_cache().remove(address);
892        }
893
894        let addr_hex = hex::encode(address);
895
896        // First attempt against the current close-group view. A
897        // lookup/transport error here (e.g. closest_peers' DHT walk
898        // momentarily returning an error, or InsufficientPeers from a
899        // thin routing table) is NOT fatal: fall through to the retry
900        // path exactly as a non-authoritative miss would. Otherwise one
901        // transient error on the *initial* close-group walk for a single
902        // chunk would fail an entire multi-hundred-chunk download. A
903        // zeroed outcome (queried=0) is never authoritative, so it flows
904        // straight to the retry below.
905        let first = match self
906            .chunk_get_try_closest_peers(address, peer_count, diag, "initial")
907            .await
908        {
909            Ok(outcome) => outcome,
910            Err(e) => {
911                info!("chunk_get first close-group lookup failed for {addr_hex}: {e}; will retry");
912                CloseGroupOutcome {
913                    chunk: None,
914                    queried: 0,
915                    not_found: 0,
916                    timeout: 0,
917                    network_err: 0,
918                    protocol_err: 0,
919                }
920            }
921        };
922        if let Some(chunk) = first.chunk {
923            self.chunk_cache().put(chunk.address, chunk.content.clone());
924            return Ok(Some(chunk));
925        }
926
927        // Only treat as authoritative absence when *every* queried peer
928        // responded NotFound. Anything less leaves the actual storer
929        // possibly in the timeout / network-error bucket, which a retry
930        // could reach.
931        if is_authoritative_not_found(first.not_found, first.queried) {
932            info!(
933                "chunk_get giving up on {addr_hex} (unanimous NotFound): \
934                 queried={} not_found={} timeout={} network_err={} protocol_err={}",
935                first.queried,
936                first.not_found,
937                first.timeout,
938                first.network_err,
939                first.protocol_err,
940            );
941            return Ok(None);
942        }
943
944        // Otherwise the failure looks like reachability (most peers timed out
945        // or hit transport errors). The chunk is most likely still on the
946        // network but the current close-group view either (a) caught a
947        // transient transport blip or (b) converged on the wrong neighbourhood
948        // because the routing table is thin. One retry against a freshly
949        // re-walked close group is the cheapest defence against both.
950        info!(
951            "chunk_get retrying {addr_hex} after reachability failure: \
952             queried={} not_found={} timeout={} network_err={} protocol_err={}",
953            first.queried, first.not_found, first.timeout, first.network_err, first.protocol_err,
954        );
955
956        // Brief settle so any in-flight transport state can quiesce before
957        // we re-walk the DHT. Keep this small so we don't add meaningful
958        // latency to the genuinely-lost case (we already paid for one full
959        // close-group sweep before getting here).
960        tokio::time::sleep(Duration::from_secs(1)).await;
961
962        // If the retry's DHT lookup itself fails, treat that as "still
963        // couldn't find" rather than escalating the error — matches the
964        // semantics of the first attempt when peers are unreachable.
965        let retry = match self
966            .chunk_get_try_closest_peers(address, peer_count, diag, "retry")
967            .await
968        {
969            Ok(o) => o,
970            Err(e) => {
971                info!(
972                    "chunk_get retry close-group lookup failed for {addr_hex}: {e}; \
973                     first(queried={} not_found={} timeout={} network_err={} protocol_err={})",
974                    first.queried,
975                    first.not_found,
976                    first.timeout,
977                    first.network_err,
978                    first.protocol_err,
979                );
980                return Ok(None);
981            }
982        };
983        if let Some(chunk) = retry.chunk {
984            info!("chunk_get retry succeeded for {addr_hex}");
985            self.chunk_cache().put(chunk.address, chunk.content.clone());
986            return Ok(Some(chunk));
987        }
988
989        info!(
990            "chunk_get exhausted close group after retry for {addr_hex}: \
991             first(queried={} not_found={} timeout={} network_err={} protocol_err={}) \
992             retry(queried={} not_found={} timeout={} network_err={} protocol_err={})",
993            first.queried,
994            first.not_found,
995            first.timeout,
996            first.network_err,
997            first.protocol_err,
998            retry.queried,
999            retry.not_found,
1000            retry.timeout,
1001            retry.network_err,
1002            retry.protocol_err,
1003        );
1004        Ok(None)
1005    }
1006
1007    /// One sweep of the requested closest peers: fetch the closest peers
1008    /// for `address` from the DHT and ask each for the chunk in turn,
1009    /// returning on the first success.
1010    ///
1011    /// `sweep` is `"initial"` for the first close-group attempt and
1012    /// `"retry"` for the internal retry sweep; it is only used as a label
1013    /// on diagnostic records when `diag` is `Some`.
1014    async fn chunk_get_try_closest_peers(
1015        &self,
1016        address: &XorName,
1017        peer_count: usize,
1018        diag: Option<&ChunkFetchDiagnostics<'_>>,
1019        sweep: &'static str,
1020    ) -> Result<CloseGroupOutcome> {
1021        let lookup_start = Instant::now();
1022        let (peers, peer_contexts) = if diag.is_some() {
1023            match self
1024                .network()
1025                .find_closest_peers_with_diagnostics(address, peer_count)
1026                .await
1027            {
1028                Ok(contexts) => {
1029                    let peers = contexts
1030                        .iter()
1031                        .map(|context| (context.peer_id, context.addresses.clone()))
1032                        .collect();
1033                    (peers, Some(contexts))
1034                }
1035                Err(e) => {
1036                    if let Some(diag) = diag {
1037                        diag.emit_chunk_level(
1038                            sweep,
1039                            0,
1040                            DownloadDiagnosticsOutcome::LookupError,
1041                            Some(bounded_error("lookup", &e.to_string())),
1042                        );
1043                    }
1044                    return Err(e);
1045                }
1046            }
1047        } else {
1048            // Preserve the pre-instrumentation lookup path exactly when
1049            // diagnostics are disabled.
1050            match self.closest_peers(address, peer_count).await {
1051                Ok(peers) => (peers, None),
1052                Err(e) => return Err(e),
1053            }
1054        };
1055        let lookup_duration_ms =
1056            u64::try_from(lookup_start.elapsed().as_millis()).unwrap_or(u64::MAX);
1057        let lookup_duration_opt = Some(lookup_duration_ms);
1058        let addr_hex = hex::encode(address);
1059        let lookup_correlation_id = diag.map(|diag| {
1060            let sequence = NEXT_DIAGNOSTIC_LOOKUP_ID.fetch_add(1, AtomicOrdering::Relaxed);
1061            format!(
1062                "{}-{}-{}-{}-{sequence}",
1063                diag.file_attempt, diag.chunk_index, sweep, addr_hex
1064            )
1065        });
1066        let queried = peers.len();
1067        let mut not_found = 0usize;
1068        let mut timeout = 0usize;
1069        let mut network_err = 0usize;
1070        let mut protocol_err = 0usize;
1071
1072        for (peer_attempt, (peer, addrs)) in peers.iter().enumerate() {
1073            let peer_attempt_no = peer_attempt + 1;
1074            let result = if let Some(diag) = diag {
1075                let Some(peer_context) = peer_contexts
1076                    .as_ref()
1077                    .and_then(|contexts| contexts.get(peer_attempt))
1078                else {
1079                    return Err(Error::Network(
1080                        "diagnostics peer context missing for selected peer".to_string(),
1081                    ));
1082                };
1083                let Some(lookup_correlation_id) = lookup_correlation_id.as_deref() else {
1084                    return Err(Error::Network(
1085                        "diagnostics lookup correlation ID missing".to_string(),
1086                    ));
1087                };
1088                let node = self.network().node();
1089                let peer_connected_before_request = node.is_peer_connected(peer).await;
1090                let (active_guard, active_requests_at_start) =
1091                    ActiveDiagnosticRequestGuard::enter();
1092                let request_started_unix_ms = unix_now_ms();
1093                let resp_start = Instant::now();
1094                let correlation =
1095                    DownloadRequestCorrelation::new(self.next_request_id(), node.peer_id());
1096                let observed = self
1097                    .chunk_get_from_peer_with_metadata(address, peer, addrs, &correlation)
1098                    .await;
1099                let response_elapsed_ms =
1100                    u64::try_from(resp_start.elapsed().as_millis()).unwrap_or(u64::MAX);
1101                let request_completed_unix_ms = unix_now_ms();
1102                // Count only the network request itself; route classification
1103                // and sidecar emission are diagnostic bookkeeping.
1104                drop(active_guard);
1105
1106                let (result, source_peer, transport_source, route) = match observed {
1107                    Ok(response) => {
1108                        let route = node
1109                            .classify_peer_transport_route(
1110                                &response.source_peer,
1111                                response.transport_source.as_ref(),
1112                            )
1113                            .await;
1114                        (
1115                            response.result,
1116                            Some(response.source_peer),
1117                            response.transport_source,
1118                            route,
1119                        )
1120                    }
1121                    Err(error) => (Err(error), None, None, PeerRouteKind::Unknown),
1122                };
1123                let (outcome, bytes, _got_response, error) = classify_peer_attempt(&result);
1124                let lookup = if peer_attempt_no == 1 {
1125                    lookup_duration_opt
1126                } else {
1127                    None
1128                };
1129                diag.emit_peer_attempt(
1130                    sweep,
1131                    peer_attempt_no,
1132                    lookup,
1133                    lookup_correlation_id,
1134                    peer_context,
1135                    peer,
1136                    source_peer.as_ref(),
1137                    transport_source.as_ref(),
1138                    route,
1139                    peer_connected_before_request,
1140                    active_requests_at_start,
1141                    request_started_unix_ms,
1142                    request_completed_unix_ms,
1143                    &correlation,
1144                    response_elapsed_ms,
1145                    bytes,
1146                    outcome,
1147                    error,
1148                );
1149                result
1150            } else {
1151                // Preserve the existing request path exactly when diagnostics
1152                // are disabled: no metadata lookup, clock read, or counter.
1153                self.chunk_get_from_peer(address, peer, addrs).await
1154            };
1155            match result {
1156                Ok(Some(chunk)) => {
1157                    return Ok(CloseGroupOutcome {
1158                        chunk: Some(chunk),
1159                        queried,
1160                        not_found,
1161                        timeout,
1162                        network_err,
1163                        protocol_err,
1164                    });
1165                }
1166                Ok(None) => {
1167                    not_found += 1;
1168                    debug!("Chunk {addr_hex} not found on peer {peer}, trying next");
1169                }
1170                Err(Error::Timeout(_)) => {
1171                    timeout += 1;
1172                    debug!("Peer {peer} timed out for chunk {addr_hex}, trying next");
1173                }
1174                Err(Error::Network(_)) => {
1175                    network_err += 1;
1176                    debug!("Peer {peer} unreachable for chunk {addr_hex}, trying next");
1177                }
1178                // A `Protocol` error here is the storer responding with
1179                // `ChunkGetResponse::Error(...)` — e.g. "Chunk verification
1180                // failed" from a peer that has a corrupted local copy.
1181                // That's a per-peer problem, not a per-chunk one: the
1182                // remaining peers might still have a clean copy, so
1183                // continue the sweep rather than aborting it. Counted
1184                // separately from network_err so the summary log still
1185                // distinguishes "peer corrupted" from "peer unreachable".
1186                Err(Error::Protocol(ref e)) => {
1187                    protocol_err += 1;
1188                    debug!(
1189                        "Peer {peer} returned protocol error for chunk {addr_hex} ({e}), trying next"
1190                    );
1191                }
1192                Err(e) => return Err(e),
1193            }
1194        }
1195
1196        // The sweep queried every selected peer without success. Emit an
1197        // explicit exhausted record so the peer-set exhaustion is a record
1198        // rather than a silent gap.
1199        if let Some(diag) = diag {
1200            diag.emit_chunk_level(sweep, 0, DownloadDiagnosticsOutcome::Exhausted, None);
1201        }
1202
1203        Ok(CloseGroupOutcome {
1204            chunk: None,
1205            queried,
1206            not_found,
1207            timeout,
1208            network_err,
1209            protocol_err,
1210        })
1211    }
1212
1213    /// Retrieve a chunk from every peer in the close group.
1214    ///
1215    /// Unlike [`Client::chunk_get`], this method does not return early
1216    /// after the first successful response. It returns one result per
1217    /// close-group peer, sorted from closest XOR distance to furthest.
1218    ///
1219    /// # Errors
1220    ///
1221    /// Returns an error if the close-group lookup fails.
1222    pub async fn chunk_get_from_close_group(
1223        &self,
1224        address: &XorName,
1225    ) -> Result<Vec<ChunkPeerGetResult>> {
1226        self.chunk_get_from_closest_peer_group(address, self.config().close_group_size)
1227            .await
1228    }
1229
1230    /// Retrieve a chunk from the requested number of closest peers.
1231    ///
1232    /// Unlike [`Client::chunk_get_from_closest_peers`], this method does
1233    /// not return early after the first successful response. It returns
1234    /// one result per queried peer, sorted from closest XOR distance to
1235    /// furthest.
1236    ///
1237    /// # Errors
1238    ///
1239    /// Returns an error if the DHT lookup fails.
1240    pub async fn chunk_get_from_closest_peer_group(
1241        &self,
1242        address: &XorName,
1243        peer_count: usize,
1244    ) -> Result<Vec<ChunkPeerGetResult>> {
1245        let peers = self.closest_peers(address, peer_count).await?;
1246        let targets = chunk_peer_get_targets(peers, address);
1247        let concurrency_limit =
1248            diagnostic_peer_get_concurrency(peer_count, self.config().close_group_size);
1249        let per_peer_timeout = Duration::from_secs(self.config().chunk_get_timeout_secs);
1250        let overall_timeout =
1251            diagnostic_peer_get_overall_timeout(per_peer_timeout, targets.len(), concurrency_limit);
1252
1253        let mut completed = vec![false; targets.len()];
1254        let mut results = Vec::with_capacity(targets.len());
1255        let mut get_results = stream::iter(targets.iter().cloned())
1256            .map(|target| async move {
1257                let chunk_result = self
1258                    .chunk_get_from_peer(address, &target.peer_id, &target.peer_addrs)
1259                    .await;
1260
1261                if let Ok(Some(chunk)) = &chunk_result {
1262                    self.chunk_cache().put(chunk.address, chunk.content.clone());
1263                }
1264
1265                (
1266                    target.index,
1267                    ChunkPeerGetResult {
1268                        peer_id: target.peer_id,
1269                        peer_addrs: target.peer_addrs,
1270                        xor_distance: target.xor_distance,
1271                        chunk_result,
1272                    },
1273                )
1274            })
1275            .buffer_unordered(concurrency_limit);
1276
1277        let collect_results = async {
1278            while let Some((index, result)) = get_results.next().await {
1279                completed[index] = true;
1280                results.push(result);
1281            }
1282        };
1283
1284        if tokio::time::timeout(overall_timeout, collect_results)
1285            .await
1286            .is_err()
1287        {
1288            for target in &targets {
1289                if !completed[target.index] {
1290                    results.push(timed_out_chunk_peer_get_result(
1291                        target,
1292                        address,
1293                        overall_timeout,
1294                    ));
1295                }
1296            }
1297        }
1298
1299        sort_chunk_peer_get_results(&mut results);
1300        Ok(results)
1301    }
1302
1303    /// Fetch a chunk from a specific peer.
1304    async fn chunk_get_from_peer(
1305        &self,
1306        address: &XorName,
1307        peer: &PeerId,
1308        peer_addrs: &[MultiAddr],
1309    ) -> Result<Option<DataChunk>> {
1310        let node = self.network().node();
1311        let request_id = self.next_request_id();
1312        let request = ChunkGetRequest::new(*address);
1313        let message = ChunkMessage {
1314            request_id,
1315            body: ChunkMessageBody::GetRequest(request),
1316        };
1317        let message_bytes = message
1318            .encode()
1319            .map_err(|e| Error::Protocol(format!("Failed to encode GET request: {e}")))?;
1320
1321        let timeout = Duration::from_secs(self.config().chunk_get_timeout_secs);
1322        let addr_hex = hex::encode(address);
1323        let timeout_secs = self.config().chunk_get_timeout_secs;
1324
1325        let result = send_and_await_chunk_response(
1326            node,
1327            peer,
1328            message_bytes,
1329            request_id,
1330            timeout,
1331            peer_addrs,
1332            |body| match body {
1333                ChunkMessageBody::GetResponse(ChunkGetResponse::Success {
1334                    address: addr,
1335                    content,
1336                }) => {
1337                    if addr != *address {
1338                        return Some(Err(Error::InvalidData(format!(
1339                            "Mismatched chunk address: expected {addr_hex}, got {}",
1340                            hex::encode(addr)
1341                        ))));
1342                    }
1343
1344                    let computed = compute_address(&content);
1345                    if computed != addr {
1346                        return Some(Err(Error::InvalidData(format!(
1347                            "Invalid chunk content: expected hash {addr_hex}, got {}",
1348                            hex::encode(computed)
1349                        ))));
1350                    }
1351
1352                    debug!(
1353                        "Retrieved chunk {} ({} bytes) from peer {peer}",
1354                        hex::encode(addr),
1355                        content.len()
1356                    );
1357                    Some(Ok(Some(DataChunk::new(addr, Bytes::from(content)))))
1358                }
1359                ChunkMessageBody::GetResponse(ChunkGetResponse::NotFound { .. }) => Some(Ok(None)),
1360                ChunkMessageBody::GetResponse(ChunkGetResponse::Error(e)) => Some(Err(
1361                    Error::Protocol(format!("Remote GET error for {addr_hex}: {e}")),
1362                )),
1363                _ => None,
1364            },
1365            |e| Error::Network(format!("Failed to send GET to peer {peer}: {e}")),
1366            || {
1367                Error::Timeout(format!(
1368                    "Timeout waiting for chunk {addr_hex} from {peer} after {timeout_secs}s"
1369                ))
1370            },
1371        )
1372        .await;
1373
1374        result
1375    }
1376
1377    /// Diagnostics-only variant of [`Self::chunk_get_from_peer`] that retains
1378    /// the authenticated response peer and observed transport source. Request
1379    /// construction, validation, timeouts, and error mapping mirror the normal
1380    /// helper exactly.
1381    async fn chunk_get_from_peer_with_metadata(
1382        &self,
1383        address: &XorName,
1384        peer: &PeerId,
1385        peer_addrs: &[MultiAddr],
1386        correlation: &DownloadRequestCorrelation,
1387    ) -> Result<ChunkProtocolResponse<Option<DataChunk>, Error>> {
1388        let node = self.network().node();
1389        let message_bytes = encode_diagnostic_chunk_get_request(address, correlation)?;
1390
1391        let timeout = Duration::from_secs(self.config().chunk_get_timeout_secs);
1392        let addr_hex = hex::encode(address);
1393        let timeout_secs = self.config().chunk_get_timeout_secs;
1394
1395        send_and_await_chunk_response_with_metadata(
1396            node,
1397            peer,
1398            message_bytes,
1399            correlation.request_id,
1400            timeout,
1401            peer_addrs,
1402            |body| match body {
1403                ChunkMessageBody::GetResponse(ChunkGetResponse::Success {
1404                    address: addr,
1405                    content,
1406                }) => {
1407                    if addr != *address {
1408                        return Some(Err(Error::InvalidData(format!(
1409                            "Mismatched chunk address: expected {addr_hex}, got {}",
1410                            hex::encode(addr)
1411                        ))));
1412                    }
1413                    let computed = compute_address(&content);
1414                    if computed != addr {
1415                        return Some(Err(Error::InvalidData(format!(
1416                            "Invalid chunk content: expected hash {addr_hex}, got {}",
1417                            hex::encode(computed)
1418                        ))));
1419                    }
1420                    debug!(
1421                        "Retrieved chunk {} ({} bytes) from peer {peer}",
1422                        hex::encode(addr),
1423                        content.len()
1424                    );
1425                    Some(Ok(Some(DataChunk::new(addr, Bytes::from(content)))))
1426                }
1427                ChunkMessageBody::GetResponse(ChunkGetResponse::NotFound { .. }) => Some(Ok(None)),
1428                ChunkMessageBody::GetResponse(ChunkGetResponse::Error(e)) => Some(Err(
1429                    Error::Protocol(format!("Remote GET error for {addr_hex}: {e}")),
1430                )),
1431                _ => None,
1432            },
1433            |e| Error::Network(format!("Failed to send GET to peer {peer}: {e}")),
1434            || {
1435                Error::Timeout(format!(
1436                    "Timeout waiting for chunk {addr_hex} from {peer} after {timeout_secs}s"
1437                ))
1438            },
1439        )
1440        .await
1441    }
1442
1443    /// Check if a chunk exists on the network.
1444    ///
1445    /// # Errors
1446    ///
1447    /// Returns an error if the network operation fails.
1448    pub async fn chunk_exists(&self, address: &XorName) -> Result<bool> {
1449        self.chunk_get(address).await.map(|opt| opt.is_some())
1450    }
1451
1452    /// Finalize a single-chunk publish after an external signer has paid.
1453    ///
1454    /// Single-chunk analogue of [`Client::finalize_upload`]. Takes a
1455    /// [`PreparedChunk`] (from [`Client::prepare_chunk_payment`]) and a
1456    /// `quote_hash -> tx_hash` map containing receipts for every non-zero
1457    /// quote in the chunk's payment. Builds the `PaymentProof` and stores
1458    /// the chunk on `CLOSE_GROUP_MAJORITY` peers, returning its address.
1459    ///
1460    /// Wave-batch payment shape only. Single-chunk publishes don't need
1461    /// Merkle batching: one chunk's worth of quotes is well below the
1462    /// wave-batch threshold.
1463    ///
1464    /// # Errors
1465    ///
1466    /// Returns an error if the proof construction fails (e.g. missing
1467    /// `tx_hash` for a non-zero quote) or if fewer than
1468    /// `CLOSE_GROUP_MAJORITY` peers accept the chunk.
1469    pub async fn finalize_chunk(
1470        &self,
1471        prepared: PreparedChunk,
1472        tx_hash_map: &HashMap<QuoteHash, TxHash>,
1473    ) -> Result<XorName> {
1474        let mut paid = finalize_batch_payment(vec![prepared], tx_hash_map)?;
1475        // finalize_batch_payment returns one PaidChunk per PreparedChunk
1476        // input; we passed exactly one. If that invariant is ever violated
1477        // it's an upstream bug — fail loudly rather than silently address-0.
1478        let chunk = paid.pop().ok_or_else(|| {
1479            Error::Payment(
1480                "finalize_batch_payment returned no paid chunks for a single \
1481                 prepared chunk — internal invariant violated"
1482                    .into(),
1483            )
1484        })?;
1485        self.chunk_put_to_close_group(chunk.content, chunk.proof_bytes, &chunk.quoted_peers)
1486            .await
1487    }
1488}
1489
1490#[cfg(test)]
1491mod tests {
1492    use super::*;
1493    use ant_protocol::{PROOF_TAG_MERKLE, PROOF_TAG_SINGLE_NODE};
1494
1495    /// Arbitrary configured Merkle store timeout used by the timeout-selection tests.
1496    const TEST_MERKLE_TIMEOUT_SECS: u64 = 60;
1497    /// Sentinel byte used to represent an unknown/unrecognized proof tag.
1498    const UNKNOWN_PROOF_TAG: u8 = 0xff;
1499    /// XorName byte width used by test peer IDs and distances.
1500    const TEST_XORNAME_BYTE_LEN: usize = 32;
1501    /// Last byte position in the test XOR distance arrays.
1502    const TEST_DISTANCE_TAIL_INDEX: usize = TEST_XORNAME_BYTE_LEN - 1;
1503
1504    #[test]
1505    fn diagnostic_correlation_is_identical_on_wire_and_in_record() {
1506        let address = [7u8; 32];
1507        let correlation = DownloadRequestCorrelation::new(
1508            9_903,
1509            &PeerId::from_bytes([42; TEST_XORNAME_BYTE_LEN]),
1510        );
1511        let encoded = encode_diagnostic_chunk_get_request(&address, &correlation).unwrap();
1512        let wire = ChunkMessage::decode(&encoded).unwrap();
1513        assert_eq!(wire.request_id, correlation.request_id);
1514        assert!(matches!(wire.body, ChunkMessageBody::GetRequest(_)));
1515
1516        let record = DownloadDiagnosticsRecord::peer_attempt(
1517            1,
1518            1,
1519            &address,
1520            "initial",
1521            1,
1522            None,
1523            "lookup-1",
1524            "expected-peer",
1525            Vec::new(),
1526            Vec::new(),
1527            None,
1528            None,
1529            None,
1530            None,
1531            None,
1532            "unknown",
1533            None,
1534            Some(false),
1535            Some(1),
1536            Some(8),
1537            100,
1538            200,
1539            &correlation,
1540            100,
1541            0,
1542            DownloadDiagnosticsOutcome::Timeout,
1543            Some("timeout".to_string()),
1544        );
1545        assert_eq!(record.request_id, Some(wire.request_id));
1546        assert_eq!(record.local_peer_id, Some(correlation.local_peer_id));
1547    }
1548
1549    #[test]
1550    fn classify_peer_attempt_pins_outcomes_and_response_attribution() {
1551        let chunk = DataChunk::new([0u8; 32], Bytes::from_static(b"payload"));
1552        let cases = [
1553            (
1554                Ok(Some(chunk)),
1555                DownloadDiagnosticsOutcome::Found,
1556                7,
1557                true,
1558                None,
1559            ),
1560            (
1561                Ok(None),
1562                DownloadDiagnosticsOutcome::NotFound,
1563                0,
1564                true,
1565                None,
1566            ),
1567            (
1568                Err(Error::Timeout("late".to_string())),
1569                DownloadDiagnosticsOutcome::Timeout,
1570                0,
1571                false,
1572                Some("timeout: late"),
1573            ),
1574            (
1575                Err(Error::Network("dial".to_string())),
1576                DownloadDiagnosticsOutcome::NetworkError,
1577                0,
1578                false,
1579                Some("network: dial"),
1580            ),
1581            (
1582                Err(Error::InvalidData("hash".to_string())),
1583                DownloadDiagnosticsOutcome::ProtocolError,
1584                0,
1585                true,
1586                Some("protocol: hash"),
1587            ),
1588            (
1589                Err(Error::Protocol("remote".to_string())),
1590                DownloadDiagnosticsOutcome::ProtocolError,
1591                0,
1592                false,
1593                Some("protocol: remote"),
1594            ),
1595        ];
1596
1597        for (result, expected_outcome, expected_bytes, expected_response, expected_error) in cases {
1598            let (outcome, bytes, got_response, error) = classify_peer_attempt(&result);
1599            assert_eq!(outcome, expected_outcome);
1600            assert_eq!(bytes, expected_bytes);
1601            assert_eq!(got_response, expected_response);
1602            assert_eq!(error.as_deref(), expected_error);
1603        }
1604    }
1605
1606    #[test]
1607    fn classify_put_failure_maps_remote_timeout_and_dial_reasons() {
1608        let remote = |source| Error::RemotePut {
1609            address: "test-addr".to_string(),
1610            source,
1611        };
1612        assert!(matches!(
1613            classify_put_failure(&remote(ProtocolError::StorageFailed("full".to_string()))),
1614            PutRejection::Full
1615        ));
1616        assert!(matches!(
1617            classify_put_failure(&remote(ProtocolError::PaymentFailed(
1618                "below floor".to_string()
1619            ))),
1620            PutRejection::PriceFloor
1621        ));
1622        assert!(matches!(
1623            classify_put_failure(&remote(ProtocolError::Internal("boom".to_string()))),
1624            PutRejection::OtherRemote
1625        ));
1626        // A `PaymentRequired` PUT response surfaces as `Error::Payment` and is an
1627        // application-level decline, not a transport shortfall (ADR-0002).
1628        assert!(matches!(
1629            classify_put_failure(&Error::Payment("Payment required: more".to_string())),
1630            PutRejection::PriceFloor
1631        ));
1632        // A PUT-response timeout is genuine local backpressure (V2-554).
1633        assert!(matches!(
1634            classify_put_failure(&Error::Timeout("no response".to_string())),
1635            PutRejection::Timeout
1636        ));
1637        // A dial/relay failure (dead/stale relayed address) is remote churn.
1638        assert!(matches!(
1639            classify_put_failure(&Error::Network("dial failed".to_string())),
1640            PutRejection::Dial
1641        ));
1642    }
1643
1644    #[test]
1645    fn put_shortfall_routes_by_failure_mix() {
1646        let app = || Error::Payment("Payment required: more".to_string());
1647        let msg = || "shortfall".to_string();
1648
1649        // Every failure was an application-level decline (no timeout, no dial):
1650        // surface the app error so the limiter isn't driven down as a false
1651        // capacity signal (ADR-0002 / V2-468).
1652        assert!(matches!(
1653            put_shortfall_error(0, 0, Some(app()), msg()),
1654            Error::Payment(_)
1655        ));
1656        // Any PUT-response timeout in the mix is genuine local backpressure:
1657        // keep it a capacity signal so the store limiter still backs off (V2-554).
1658        assert!(matches!(
1659            put_shortfall_error(1, 0, Some(app()), msg()),
1660            Error::InsufficientPeers(_)
1661        ));
1662        assert!(matches!(
1663            put_shortfall_error(1, 3, None, msg()),
1664            Error::InsufficientPeers(_)
1665        ));
1666        // No timeouts but dial/relay churn present: remote peer churn, not local
1667        // capacity — surface a neutral CloseGroupShortfall (V2-554).
1668        assert!(matches!(
1669            put_shortfall_error(0, 2, None, msg()),
1670            Error::CloseGroupShortfall(_)
1671        ));
1672        // Dial churn alongside an app rejection, still no timeout: neutral.
1673        assert!(matches!(
1674            put_shortfall_error(0, 1, Some(app()), msg()),
1675            Error::CloseGroupShortfall(_)
1676        ));
1677    }
1678
1679    fn chunk_peer_get_result(peer_seed: u8, distance_tail: u8) -> ChunkPeerGetResult {
1680        let mut xor_distance = [0; TEST_XORNAME_BYTE_LEN];
1681        xor_distance[TEST_DISTANCE_TAIL_INDEX] = distance_tail;
1682
1683        ChunkPeerGetResult {
1684            peer_id: PeerId::from_bytes([peer_seed; TEST_XORNAME_BYTE_LEN]),
1685            peer_addrs: Vec::new(),
1686            xor_distance,
1687            chunk_result: Ok(None),
1688        }
1689    }
1690
1691    #[test]
1692    fn authoritative_not_found_requires_unanimous_well_sampled_response() {
1693        // Unanimous AND well-sampled: every queried peer of a full
1694        // close group said NotFound. The only safe stop.
1695        assert!(is_authoritative_not_found(7, 7));
1696        // Unanimous with exactly a majority-sized sample is also
1697        // authoritative.
1698        assert!(is_authoritative_not_found(
1699            CLOSE_GROUP_MAJORITY,
1700            CLOSE_GROUP_MAJORITY
1701        ));
1702
1703        // Unanimous but UNDER-sampled: a thin DHT walk returning 1 or 3
1704        // peers, all NotFound, is NOT authoritative — the real replica
1705        // majority may sit entirely outside that narrow view. Must
1706        // retry (re-walk the DHT).
1707        assert!(!is_authoritative_not_found(1, 1));
1708        assert!(!is_authoritative_not_found(3, 3));
1709        assert!(!is_authoritative_not_found(
1710            CLOSE_GROUP_MAJORITY - 1,
1711            CLOSE_GROUP_MAJORITY - 1
1712        ));
1713
1714        // Not unanimous: 4-of-7 / 6-of-7 NotFound leaves storers in the
1715        // timeout bucket. Must retry.
1716        assert!(!is_authoritative_not_found(4, 7));
1717        assert!(!is_authoritative_not_found(6, 7));
1718
1719        // Pure-reachability failure — must retry.
1720        assert!(!is_authoritative_not_found(0, 7));
1721
1722        // Defensive: a zeroed outcome (e.g. the first attempt's
1723        // close-group lookup errored) is never authoritative.
1724        assert!(!is_authoritative_not_found(0, 0));
1725    }
1726
1727    #[test]
1728    fn chunk_get_outcome_classifies_each_result_kind() {
1729        // Success: chunk_get returned a chunk, regardless of how many
1730        // internal peer attempts it took.
1731        let chunk = DataChunk::new([0u8; 32], Bytes::from_static(b"x"));
1732        assert_eq!(
1733            chunk_get_outcome(&Ok(Some(chunk))),
1734            Outcome::Success,
1735            "found-chunk must be Success",
1736        );
1737
1738        // Ok(None): chunk_get exhausted the close group across first
1739        // attempt + retry. This is the load-shedding signal — count it
1740        // as Timeout so a sustained run of them on a saturated link
1741        // shrinks the cap.
1742        assert_eq!(
1743            chunk_get_outcome(&Ok(None)),
1744            Outcome::Timeout,
1745            "Ok(None) must be Timeout — that's the controller's load-shedding signal",
1746        );
1747
1748        // Capacity signals from explicit error variants.
1749        assert_eq!(
1750            chunk_get_outcome(&Err(Error::Timeout("t".into()))),
1751            Outcome::Timeout,
1752        );
1753        assert_eq!(
1754            chunk_get_outcome(&Err(Error::Network("n".into()))),
1755            Outcome::NetworkError,
1756        );
1757
1758        // Unexpected error variant (e.g. Protocol) — propagates out of
1759        // chunk_get to the caller and is not a capacity signal.
1760        assert_eq!(
1761            chunk_get_outcome(&Err(Error::Protocol("p".into()))),
1762            Outcome::ApplicationError,
1763        );
1764    }
1765
1766    #[test]
1767    fn single_node_proof_uses_store_response_timeout() {
1768        let timeout =
1769            store_response_timeout_for_proof(&[PROOF_TAG_SINGLE_NODE], TEST_MERKLE_TIMEOUT_SECS);
1770
1771        assert_eq!(timeout, STORE_RESPONSE_TIMEOUT);
1772    }
1773
1774    #[test]
1775    fn unknown_proof_uses_store_response_timeout() {
1776        let timeout =
1777            store_response_timeout_for_proof(&[UNKNOWN_PROOF_TAG], TEST_MERKLE_TIMEOUT_SECS);
1778
1779        assert_eq!(timeout, STORE_RESPONSE_TIMEOUT);
1780    }
1781
1782    #[test]
1783    fn merkle_proof_uses_configured_store_timeout() {
1784        let timeout =
1785            store_response_timeout_for_proof(&[PROOF_TAG_MERKLE], TEST_MERKLE_TIMEOUT_SECS);
1786
1787        assert_eq!(timeout, Duration::from_secs(TEST_MERKLE_TIMEOUT_SECS));
1788    }
1789
1790    #[test]
1791    fn chunk_peer_get_results_sort_by_xor_distance() {
1792        let mut results = vec![
1793            chunk_peer_get_result(3, 3),
1794            chunk_peer_get_result(1, 1),
1795            chunk_peer_get_result(2, 2),
1796        ];
1797
1798        sort_chunk_peer_get_results(&mut results);
1799
1800        let ordered_distances = results
1801            .iter()
1802            .map(|result| result.xor_distance[TEST_DISTANCE_TAIL_INDEX])
1803            .collect::<Vec<_>>();
1804        assert_eq!(ordered_distances, vec![1, 2, 3]);
1805    }
1806
1807    #[test]
1808    fn diagnostic_peer_get_overall_timeout_allows_one_wave_plus_padding() {
1809        const PER_PEER_TIMEOUT_SECS: u64 = 10;
1810        const EXPECTED_WAVES_WITH_PADDING: u64 = 2;
1811        const TARGET_COUNT: usize = 7;
1812        const CONCURRENCY_LIMIT: usize = 7;
1813
1814        let timeout = diagnostic_peer_get_overall_timeout(
1815            Duration::from_secs(PER_PEER_TIMEOUT_SECS),
1816            TARGET_COUNT,
1817            CONCURRENCY_LIMIT,
1818        );
1819
1820        assert_eq!(
1821            timeout,
1822            Duration::from_secs(PER_PEER_TIMEOUT_SECS * EXPECTED_WAVES_WITH_PADDING)
1823        );
1824    }
1825
1826    #[test]
1827    fn diagnostic_peer_get_overall_timeout_scales_with_peer_count() {
1828        const PER_PEER_TIMEOUT_SECS: u64 = 10;
1829        const TARGET_COUNT: usize = 20;
1830        const CLOSE_GROUP_SIZE: usize = 7;
1831        const EXPECTED_WAVES_WITH_PADDING: u64 = 4;
1832
1833        let concurrency_limit = diagnostic_peer_get_concurrency(TARGET_COUNT, CLOSE_GROUP_SIZE);
1834        let timeout = diagnostic_peer_get_overall_timeout(
1835            Duration::from_secs(PER_PEER_TIMEOUT_SECS),
1836            TARGET_COUNT,
1837            concurrency_limit,
1838        );
1839
1840        assert_eq!(
1841            timeout,
1842            Duration::from_secs(PER_PEER_TIMEOUT_SECS * EXPECTED_WAVES_WITH_PADDING)
1843        );
1844    }
1845
1846    /// Regression: the default `merkle_store_timeout_secs` must be at
1847    /// least the storer-side `CLOSENESS_LOOKUP_TIMEOUT` (240 s) plus
1848    /// padding. If either side moves and this invariant breaks, the
1849    /// client will give up on chunks the storer is still verifying.
1850    /// See `DEFAULT_MERKLE_STORE_TIMEOUT_SECS` doc comment for the
1851    /// derivation.
1852    #[test]
1853    fn default_merkle_store_timeout_satisfies_storer_invariant() {
1854        use crate::data::client::ClientConfig;
1855        const STORER_CLOSENESS_LOOKUP_TIMEOUT_SECS: u64 = 240;
1856        const MIN_PADDING_SECS: u64 = 30;
1857        let config = ClientConfig::default();
1858        assert!(
1859            config.merkle_store_timeout_secs
1860                >= STORER_CLOSENESS_LOOKUP_TIMEOUT_SECS + MIN_PADDING_SECS,
1861            "merkle_store_timeout_secs ({}) must be >= storer CLOSENESS_LOOKUP_TIMEOUT ({}) + padding ({})",
1862            config.merkle_store_timeout_secs,
1863            STORER_CLOSENESS_LOOKUP_TIMEOUT_SECS,
1864            MIN_PADDING_SECS,
1865        );
1866    }
1867
1868    /// Regression: the non-merkle PUT path uses the hardcoded
1869    /// `STORE_RESPONSE_TIMEOUT` constant, not the per-config
1870    /// `merkle_store_timeout_secs`. If a future refactor accidentally
1871    /// routes non-merkle PUTs through the merkle field they'd inherit
1872    /// the 270 s value and silently regress non-merkle latency.
1873    /// `store_response_timeout_for_proof` with a non-merkle proof tag
1874    /// must return the const regardless of what merkle timeout is
1875    /// passed.
1876    #[test]
1877    fn non_merkle_put_ignores_merkle_timeout_value() {
1878        let absurd_merkle_timeout = 9_999;
1879        for tag in [PROOF_TAG_SINGLE_NODE, UNKNOWN_PROOF_TAG] {
1880            let timeout = store_response_timeout_for_proof(&[tag], absurd_merkle_timeout);
1881            assert_eq!(
1882                timeout, STORE_RESPONSE_TIMEOUT,
1883                "non-merkle proof tag {tag:#x} should ignore merkle timeout {absurd_merkle_timeout}",
1884            );
1885        }
1886    }
1887}