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