Skip to main content

ant_core/data/client/
merkle.rs

1//! Merkle batch payment support for the Autonomi client.
2//!
3//! When uploading batches of 64+ chunks, merkle payments reduce gas costs
4//! by paying for the entire batch in a single on-chain transaction instead
5//! of one transaction per chunk.
6
7use crate::data::client::adaptive::{observe_op, Outcome};
8use crate::data::client::classify_error;
9use crate::data::client::file::UploadEvent;
10use crate::data::client::Client;
11use crate::data::error::{Error, Result};
12use ant_protocol::evm::{
13    Amount, MerklePaymentCandidateNode, MerklePaymentCandidatePool, MerklePaymentProof, MerkleTree,
14    MidpointProof, PoolCommitment, CANDIDATES_PER_POOL, MAX_LEAVES,
15};
16use ant_protocol::payment::commitment::{
17    commitment_hash, verify_commitment_signature, StorageCommitment, MAX_COMMITMENT_KEY_COUNT,
18    MAX_COMMITMENT_SIDECAR_BYTES,
19};
20use ant_protocol::payment::{
21    calculate_price, serialize_merkle_proof, verify_merkle_candidate_signature,
22};
23use ant_protocol::transport::PeerId;
24use ant_protocol::{
25    compute_address, send_and_await_chunk_response, ChunkMessage, ChunkMessageBody,
26    MerkleCandidateQuoteRequest, MerkleCandidateQuoteResponse,
27};
28use bytes::Bytes;
29use futures::stream::{self, FuturesUnordered, StreamExt};
30use rand::Rng;
31use std::collections::{HashMap, VecDeque};
32use std::time::Duration;
33use tokio::sync::mpsc;
34use tracing::{debug, info, warn};
35use xor_name::XorName;
36
37/// Default threshold: use merkle payments when chunk count >= this value.
38pub const DEFAULT_MERKLE_THRESHOLD: usize = 64;
39
40/// ADR-0004 resolve-before-pay gate for a merkle candidate — the merkle-path
41/// equivalent of the single-node `quote_commitment_binding_is_valid`. Runs the
42/// FULL binding check (shape, cap, exact price, and for bound candidates the
43/// commitment parse, peer-binding, signature, `hash == pin`, and
44/// `count == key_count`) before the candidate is allowed into a pool the client
45/// may pay. `peer_id` is derived from the candidate's `pub_key`
46/// (`BLAKE3(pub_key)`), matching the storer.
47///
48/// Returns `Ok(())` if the binding fully resolves, else `Err(detail)`.
49fn merkle_candidate_binding_is_valid(
50    peer_id: &PeerId,
51    candidate: &MerklePaymentCandidateNode,
52    commitment: &Option<Vec<u8>>,
53) -> std::result::Result<(), String> {
54    let count = candidate.committed_key_count;
55    let pin = candidate.commitment_pin;
56    match (count, pin.is_some()) {
57        (0, false) | (1.., true) => {}
58        (1.., false) => {
59            return Err(format!(
60                "committed_key_count={count} > 0 but commitment_pin is None (unauditable count)"
61            ));
62        }
63        (0, true) => {
64            return Err("committed_key_count=0 with a commitment_pin (incoherent baseline)".into());
65        }
66    }
67    if count > MAX_COMMITMENT_KEY_COUNT {
68        return Err(format!(
69            "committed_key_count={count} exceeds MAX_COMMITMENT_KEY_COUNT={MAX_COMMITMENT_KEY_COUNT}"
70        ));
71    }
72    let expected = calculate_price(count as usize);
73    if candidate.price != expected {
74        return Err(format!(
75            "price {} does not equal calculate_price(committed_key_count={count}) = {expected}",
76            candidate.price
77        ));
78    }
79
80    let Some(pin) = pin else {
81        return Ok(()); // baseline candidate pins nothing
82    };
83    let Some(blob) = commitment else {
84        return Err("bound candidate did not ship its commitment; pin is unresolvable".into());
85    };
86    if blob.len() > MAX_COMMITMENT_SIDECAR_BYTES {
87        return Err(format!(
88            "shipped commitment is {} bytes, exceeds MAX_COMMITMENT_SIDECAR_BYTES={MAX_COMMITMENT_SIDECAR_BYTES}",
89            blob.len()
90        ));
91    }
92    let commitment: StorageCommitment = rmp_serde::from_slice(blob)
93        .map_err(|e| format!("shipped commitment did not deserialize: {e}"))?;
94    if compute_address(&commitment.sender_public_key) != *peer_id.as_bytes()
95        || commitment.sender_peer_id != *peer_id.as_bytes()
96    {
97        return Err("shipped commitment is not bound to the candidate peer".into());
98    }
99    if !verify_commitment_signature(&commitment) {
100        return Err("shipped commitment has an invalid signature".into());
101    }
102    if commitment_hash(&commitment) != Some(pin) {
103        return Err("shipped commitment does not hash to the candidate's pin".into());
104    }
105    if commitment.key_count != count {
106        return Err(format!(
107            "shipped commitment attests key_count={} but the candidate claims {count}",
108            commitment.key_count
109        ));
110    }
111    Ok(())
112}
113
114/// Payment mode for uploads.
115#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum PaymentMode {
118    /// Automatically choose: merkle for batches >= threshold, single otherwise.
119    #[default]
120    Auto,
121    /// Force merkle batch payment regardless of batch size (min 2 chunks).
122    Merkle,
123    /// Force single-node payment (one tx per chunk).
124    Single,
125}
126
127/// Result of a merkle batch payment.
128///
129/// Serializable so it can be persisted across runs for resume after a
130/// partial-upload failure. See `crate::data::client::cached_merkle`.
131#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
132pub struct MerkleBatchPaymentResult {
133    /// Map of `XorName` to serialized tagged proof bytes (ready to use in PUT requests).
134    pub proofs: HashMap<[u8; 32], Vec<u8>>,
135    /// Number of chunks in the batch.
136    pub chunk_count: usize,
137    /// Total storage cost in atto (token smallest unit).
138    pub storage_cost_atto: String,
139    /// Total gas cost in wei.
140    pub gas_cost_wei: u128,
141    /// Unix timestamp (seconds) used for the on-chain merkle payment.
142    /// Persisted so resume can check whether the on-chain payment has
143    /// aged out beyond the merkle expiration window and the cached
144    /// receipt must be discarded.
145    #[serde(default)]
146    pub merkle_payment_timestamp: u64,
147}
148
149/// Prepared merkle batch ready for external payment.
150///
151/// Contains everything needed to submit the on-chain merkle payment
152/// and then finalize proof generation without a wallet.
153pub struct PreparedMerkleBatch {
154    /// Merkle tree depth (needed for the on-chain call).
155    pub depth: u8,
156    /// Pool commitments for the on-chain call.
157    pub pool_commitments: Vec<PoolCommitment>,
158    /// Timestamp used for the merkle payment.
159    pub merkle_payment_timestamp: u64,
160    /// Internal: candidate pools (needed for proof generation after payment).
161    candidate_pools: Vec<MerklePaymentCandidatePool>,
162    /// Internal: the merkle tree (needed for proof generation).
163    tree: MerkleTree,
164    /// Internal: chunk addresses in order.
165    addresses: Vec<[u8; 32]>,
166}
167
168/// Result of checking a merkle upload batch before payment.
169#[derive(Debug, Clone, Default)]
170pub(crate) struct MerkleUploadPlan {
171    /// Chunks already confirmed by their close group.
172    pub already_stored: Vec<[u8; 32]>,
173    /// Chunks that still need payment and storage.
174    pub to_upload: Vec<[u8; 32]>,
175    /// Total byte size of chunks in `to_upload`.
176    to_upload_total_bytes: u64,
177}
178
179impl MerkleUploadPlan {
180    /// Average byte size of chunks that still need upload.
181    #[must_use]
182    pub fn to_upload_avg_size(&self) -> u64 {
183        if self.to_upload.is_empty() {
184            return 0;
185        }
186
187        self.to_upload_total_bytes / self.to_upload.len() as u64
188    }
189}
190
191impl std::fmt::Debug for PreparedMerkleBatch {
192    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193        f.debug_struct("PreparedMerkleBatch")
194            .field("depth", &self.depth)
195            .field("pool_commitments", &self.pool_commitments.len())
196            .field("merkle_payment_timestamp", &self.merkle_payment_timestamp)
197            .field("candidate_pools", &self.candidate_pools.len())
198            .field("addresses", &self.addresses.len())
199            .finish()
200    }
201}
202
203/// Select chunk contents that correspond to `addresses`, preserving address order.
204///
205/// Extra chunk contents are ignored; missing contents for any requested address
206/// are treated as corrupted upload state.
207pub(crate) fn chunk_contents_for_upload_addresses(
208    chunk_contents: Vec<Bytes>,
209    addresses: &[[u8; 32]],
210) -> Result<Vec<Bytes>> {
211    if addresses.is_empty() {
212        return Ok(Vec::new());
213    }
214
215    let mut needed_by_address: HashMap<[u8; 32], usize> = HashMap::new();
216    for address in addresses {
217        *needed_by_address.entry(*address).or_default() += 1;
218    }
219
220    let mut chunks_by_address: HashMap<[u8; 32], VecDeque<Bytes>> =
221        HashMap::with_capacity(needed_by_address.len());
222    let mut remaining = addresses.len();
223    for chunk in chunk_contents {
224        let address = compute_address(&chunk);
225        if let Some(needed) = needed_by_address.get_mut(&address) {
226            if *needed > 0 {
227                chunks_by_address
228                    .entry(address)
229                    .or_default()
230                    .push_back(chunk);
231                *needed -= 1;
232                remaining -= 1;
233                if remaining == 0 {
234                    break;
235                }
236            }
237        }
238    }
239
240    for (address, needed) in &needed_by_address {
241        if *needed == 0 {
242            continue;
243        }
244
245        if chunks_by_address.contains_key(address) {
246            return Err(Error::InvalidData(format!(
247                "missing duplicate chunk content for merkle address {}",
248                hex::encode(address)
249            )));
250        }
251
252        return Err(Error::InvalidData(format!(
253            "missing chunk content for merkle address {}",
254            hex::encode(address)
255        )));
256    }
257
258    let mut selected = Vec::with_capacity(addresses.len());
259    for address in addresses {
260        let chunks = chunks_by_address.get_mut(address).ok_or_else(|| {
261            Error::InvalidData(format!(
262                "missing chunk content for merkle address {}",
263                hex::encode(address)
264            ))
265        })?;
266        let chunk = chunks.pop_front().ok_or_else(|| {
267            Error::InvalidData(format!(
268                "missing duplicate chunk content for merkle address {}",
269                hex::encode(address)
270            ))
271        })?;
272        selected.push(chunk);
273    }
274
275    Ok(selected)
276}
277
278/// Map a per-chunk quote-collection result to its merkle-preflight stored
279/// status, degrading gracefully on transient failures.
280///
281/// The preflight only needs to answer "is this chunk already on the network?"
282/// so it can be skipped before payment — it is an optimisation, never a gate.
283/// Therefore:
284/// - `Ok(_)` (quotes gathered) → `Ok(false)`: chunk is not stored, upload it.
285/// - `Err(AlreadyStored)` → `Ok(true)`: skip it.
286/// - a transient failure (timeout / insufficient peers / transport error) →
287///   `Ok(false)`: we could not confirm, so queue it for upload rather than
288///   aborting the whole batch. Re-storing an existing chunk is idempotent.
289/// - any other (application) error → propagate; it would recur on a healthy
290///   link and is not something the preflight should paper over.
291///
292/// Without this, a single chunk's transient quote timeout failed the entire
293/// upload — fatal for forced `--merkle` uploads, which (unlike `Auto`) have no
294/// wave-batch fallback, and catastrophic for large files via the multiplicative
295/// per-chunk effect.
296fn preflight_stored_status<T>(result: Result<T>) -> Result<bool> {
297    match result {
298        Ok(_) => Ok(false),
299        Err(Error::AlreadyStored) => Ok(true),
300        Err(e) if matches!(classify_error(&e), Outcome::Timeout | Outcome::NetworkError) => {
301            Ok(false)
302        }
303        Err(e) => Err(e),
304    }
305}
306
307/// Determine whether to use merkle payments for a given batch size.
308/// Free function — no Client needed.
309#[must_use]
310pub fn should_use_merkle(chunk_count: usize, mode: PaymentMode) -> bool {
311    match mode {
312        PaymentMode::Auto => chunk_count >= DEFAULT_MERKLE_THRESHOLD,
313        PaymentMode::Merkle => chunk_count >= 2,
314        PaymentMode::Single => false,
315    }
316}
317
318impl Client {
319    /// Determine whether to use merkle payments for a given batch size.
320    #[must_use]
321    pub fn should_use_merkle(&self, chunk_count: usize, mode: PaymentMode) -> bool {
322        should_use_merkle(chunk_count, mode)
323    }
324
325    /// Pay for a batch of chunks using merkle batch payment.
326    ///
327    /// Builds a merkle tree, collects candidate pools, pays on-chain in one tx,
328    /// and returns per-chunk proofs. Splits into sub-batches if > `MAX_LEAVES`.
329    ///
330    /// This low-level helper assumes the caller has already selected the
331    /// addresses that need payment. User-facing upload paths first run the
332    /// merkle upload planner to skip chunks already stored on the network.
333    ///
334    /// # Errors
335    ///
336    /// Returns an error if the batch is too small, candidate collection fails,
337    /// on-chain payment fails, or proof generation fails.
338    pub async fn pay_for_merkle_batch(
339        &self,
340        addresses: &[[u8; 32]],
341        data_type: u32,
342        data_size: u64,
343    ) -> Result<MerkleBatchPaymentResult> {
344        let chunk_count = addresses.len();
345        if chunk_count < 2 {
346            return Err(Error::Payment(
347                "Merkle batch payment requires at least 2 chunks".to_string(),
348            ));
349        }
350
351        if chunk_count > MAX_LEAVES {
352            return self
353                .pay_for_merkle_multi_batch(addresses, data_type, data_size)
354                .await;
355        }
356
357        self.pay_for_merkle_single_batch(addresses, data_type, data_size)
358            .await
359    }
360
361    /// Check which chunks in a merkle upload still need payment/storage.
362    ///
363    /// Uses the normal per-chunk quote path because it already has the
364    /// close-group majority rule for `AlreadyStored`. Non-stored chunks only
365    /// use the quote response as a probe; their actual payment still happens
366    /// through the merkle batch.
367    ///
368    /// `chunks` contains `(address, data_size)` pairs.
369    pub(crate) async fn plan_merkle_upload(
370        &self,
371        chunks: Vec<([u8; 32], u64)>,
372        data_type: u32,
373        progress: Option<&mpsc::Sender<UploadEvent>>,
374    ) -> Result<MerkleUploadPlan> {
375        let total_chunks = chunks.len();
376        if total_chunks == 0 {
377            return Ok(MerkleUploadPlan::default());
378        }
379
380        info!("Checking {total_chunks} merkle chunks for existing storage before payment");
381
382        let quote_limiter = self.controller().quote.clone();
383        let quote_concurrency = quote_limiter.current().min(total_chunks.max(1));
384        let mut check_stream = stream::iter(chunks.into_iter().enumerate())
385            .map(|(index, (address, data_size))| {
386                let limiter = quote_limiter.clone();
387                async move {
388                    let result = observe_op(
389                        &limiter,
390                        || async move {
391                            self.chunk_already_stored_for_merkle(&address, data_type, data_size)
392                                .await
393                        },
394                        classify_error,
395                    )
396                    .await;
397                    (index, address, data_size, result)
398                }
399            })
400            .buffer_unordered(quote_concurrency);
401
402        let mut already_stored: Vec<(usize, [u8; 32])> = Vec::new();
403        let mut to_upload: Vec<(usize, [u8; 32], u64)> = Vec::new();
404        let mut checked = 0usize;
405
406        while let Some((index, address, data_size, result)) = check_stream.next().await {
407            let is_already_stored = result?;
408            checked += 1;
409
410            if let Some(tx) = progress {
411                let _ = tx.try_send(UploadEvent::ChunkQuoted {
412                    quoted: checked,
413                    total: total_chunks,
414                });
415            }
416
417            if is_already_stored {
418                debug!(
419                    "Merkle preflight {checked}/{total_chunks}: chunk {} already stored",
420                    hex::encode(address)
421                );
422                already_stored.push((index, address));
423                if let Some(tx) = progress {
424                    let _ = tx.try_send(UploadEvent::ChunkStored {
425                        stored: already_stored.len(),
426                        total: total_chunks,
427                    });
428                }
429            } else {
430                debug!(
431                    "Merkle preflight {checked}/{total_chunks}: chunk {} needs upload",
432                    hex::encode(address)
433                );
434                to_upload.push((index, address, data_size));
435            }
436        }
437
438        already_stored.sort_by_key(|(index, _)| *index);
439        to_upload.sort_by_key(|(index, _, _)| *index);
440
441        let to_upload_total_bytes = to_upload.iter().fold(0u64, |acc, (_, _, data_size)| {
442            acc.saturating_add(*data_size)
443        });
444
445        let already_stored = already_stored
446            .into_iter()
447            .map(|(_, address)| address)
448            .collect::<Vec<_>>();
449        let to_upload = to_upload
450            .into_iter()
451            .map(|(_, address, _)| address)
452            .collect::<Vec<_>>();
453
454        info!(
455            "Merkle preflight complete: {} already stored, {} need upload",
456            already_stored.len(),
457            to_upload.len()
458        );
459
460        Ok(MerkleUploadPlan {
461            already_stored,
462            to_upload,
463            to_upload_total_bytes,
464        })
465    }
466
467    async fn chunk_already_stored_for_merkle(
468        &self,
469        address: &[u8; 32],
470        data_type: u32,
471        data_size: u64,
472    ) -> Result<bool> {
473        let result = self
474            .get_store_quotes_with_fault_tolerance(address, data_size, data_type)
475            .await;
476        if let Err(e) = &result {
477            if matches!(classify_error(e), Outcome::Timeout | Outcome::NetworkError) {
478                debug!(
479                    "Merkle preflight: could not determine stored status for {} ({e}); \
480                     treating as not stored and queuing for upload",
481                    hex::encode(address)
482                );
483            }
484        }
485        preflight_stored_status(result)
486    }
487
488    /// Phase 1 of external-signer merkle payment: prepare batch without paying.
489    ///
490    /// Builds the merkle tree, collects candidate pools from the network,
491    /// and returns the data needed for the on-chain payment call.
492    /// Requires `EvmNetwork` but NOT a wallet.
493    pub async fn prepare_merkle_batch_external(
494        &self,
495        addresses: &[[u8; 32]],
496        data_type: u32,
497        data_size: u64,
498    ) -> Result<PreparedMerkleBatch> {
499        let chunk_count = addresses.len();
500        let xornames: Vec<XorName> = addresses.iter().map(|a| XorName(*a)).collect();
501
502        debug!("Building merkle tree for {chunk_count} chunks");
503
504        // 1. Build merkle tree
505        let tree = MerkleTree::from_xornames(xornames)
506            .map_err(|e| Error::Payment(format!("Failed to build merkle tree: {e}")))?;
507
508        let depth = tree.depth();
509        let merkle_payment_timestamp = std::time::SystemTime::now()
510            .duration_since(std::time::UNIX_EPOCH)
511            .map_err(|e| Error::Payment(format!("System time error: {e}")))?
512            .as_secs();
513
514        debug!("Merkle tree: depth={depth}, leaves={chunk_count}, ts={merkle_payment_timestamp}");
515
516        // 2. Get reward candidates (midpoint proofs)
517        let midpoint_proofs = tree
518            .reward_candidates(merkle_payment_timestamp)
519            .map_err(|e| Error::Payment(format!("Failed to generate reward candidates: {e}")))?;
520
521        debug!(
522            "Collecting candidate pools from {} midpoints (concurrent)",
523            midpoint_proofs.len()
524        );
525
526        // 3. Collect candidate pools from the network (all pools in parallel).
527        //    Each candidate's ADR-0004 binding is fully verified during
528        //    collection (shape, cap, exact price, commitment resolution); the
529        //    sidecars themselves are consumed by that validation and NOT
530        //    forwarded in the PUT bundles (see `finalize_merkle_batch`).
531        let candidate_pools = self
532            .build_candidate_pools(
533                &midpoint_proofs,
534                data_type,
535                data_size,
536                merkle_payment_timestamp,
537            )
538            .await?;
539
540        // 4. Build pool commitments for on-chain payment
541        let pool_commitments: Vec<PoolCommitment> = candidate_pools
542            .iter()
543            .map(MerklePaymentCandidatePool::to_commitment)
544            .collect();
545
546        Ok(PreparedMerkleBatch {
547            depth,
548            pool_commitments,
549            merkle_payment_timestamp,
550            candidate_pools,
551            tree,
552            addresses: addresses.to_vec(),
553        })
554    }
555
556    /// Pay for a single batch (up to `MAX_LEAVES` chunks).
557    async fn pay_for_merkle_single_batch(
558        &self,
559        addresses: &[[u8; 32]],
560        data_type: u32,
561        data_size: u64,
562    ) -> Result<MerkleBatchPaymentResult> {
563        let wallet = self.require_wallet()?;
564        let prepared = self
565            .prepare_merkle_batch_external(addresses, data_type, data_size)
566            .await?;
567
568        info!(
569            "Submitting merkle batch payment on-chain (depth={})",
570            prepared.depth
571        );
572        let (winner_pool_hash, amount, gas_info) = wallet
573            .pay_for_merkle_tree(
574                prepared.depth,
575                prepared.pool_commitments.clone(),
576                prepared.merkle_payment_timestamp,
577            )
578            .await
579            .map_err(|e| Error::Payment(format!("Merkle batch payment failed: {e}")))?;
580
581        info!(
582            "Merkle payment succeeded: winner pool {}",
583            hex::encode(winner_pool_hash)
584        );
585
586        let mut result = finalize_merkle_batch(prepared, winner_pool_hash)?;
587        result.storage_cost_atto = amount.to_string();
588        result.gas_cost_wei = gas_info.gas_cost_wei;
589        Ok(result)
590    }
591
592    /// Handle batches larger than `MAX_LEAVES` by splitting into sub-batches.
593    async fn pay_for_merkle_multi_batch(
594        &self,
595        addresses: &[[u8; 32]],
596        data_type: u32,
597        data_size: u64,
598    ) -> Result<MerkleBatchPaymentResult> {
599        let sub_batches: Vec<&[[u8; 32]]> = addresses.chunks(MAX_LEAVES).collect();
600        let total_sub_batches = sub_batches.len();
601        let mut all_proofs = HashMap::with_capacity(addresses.len());
602        let mut total_storage = Amount::ZERO;
603        let mut total_gas: u128 = 0;
604        // Track the oldest sub-batch timestamp so the overall receipt
605        // expires when the *first* sub-batch's on-chain payment ages
606        // out (worst case for resume).
607        let mut oldest_ts: u64 = 0;
608
609        for (i, chunk) in sub_batches.into_iter().enumerate() {
610            match self
611                .pay_for_merkle_single_batch(chunk, data_type, data_size)
612                .await
613            {
614                Ok(sub_result) => {
615                    if let Ok(cost) = sub_result.storage_cost_atto.parse::<Amount>() {
616                        total_storage += cost;
617                    }
618                    total_gas = total_gas.saturating_add(sub_result.gas_cost_wei);
619                    if oldest_ts == 0
620                        || (sub_result.merkle_payment_timestamp > 0
621                            && sub_result.merkle_payment_timestamp < oldest_ts)
622                    {
623                        oldest_ts = sub_result.merkle_payment_timestamp;
624                    }
625                    all_proofs.extend(sub_result.proofs);
626                }
627                Err(e) => {
628                    if all_proofs.is_empty() {
629                        // First sub-batch failed, nothing paid yet -- propagate directly.
630                        return Err(e);
631                    }
632                    // Return partial result so caller can still store already-paid chunks.
633                    warn!(
634                        "Merkle sub-batch {}/{total_sub_batches} failed: {e}. \
635                         Returning {} proofs from prior sub-batches",
636                        i + 1,
637                        all_proofs.len()
638                    );
639                    return Ok(MerkleBatchPaymentResult {
640                        chunk_count: all_proofs.len(),
641                        proofs: all_proofs,
642                        storage_cost_atto: total_storage.to_string(),
643                        gas_cost_wei: total_gas,
644                        merkle_payment_timestamp: oldest_ts,
645                    });
646                }
647            }
648        }
649
650        Ok(MerkleBatchPaymentResult {
651            chunk_count: addresses.len(),
652            proofs: all_proofs,
653            storage_cost_atto: total_storage.to_string(),
654            gas_cost_wei: total_gas,
655            merkle_payment_timestamp: oldest_ts,
656        })
657    }
658
659    /// Build candidate pools by querying the network for each midpoint (concurrently).
660    async fn build_candidate_pools(
661        &self,
662        midpoint_proofs: &[MidpointProof],
663        data_type: u32,
664        data_size: u64,
665        merkle_payment_timestamp: u64,
666    ) -> Result<Vec<MerklePaymentCandidatePool>> {
667        let mut pool_futures = FuturesUnordered::new();
668
669        for midpoint_proof in midpoint_proofs {
670            let pool_address = midpoint_proof.address();
671            let mp = midpoint_proof.clone();
672            pool_futures.push(async move {
673                let candidate_nodes = self
674                    .get_merkle_candidate_pool(
675                        &pool_address.0,
676                        data_type,
677                        data_size,
678                        merkle_payment_timestamp,
679                    )
680                    .await?;
681                Ok::<_, Error>(MerklePaymentCandidatePool {
682                    midpoint_proof: mp,
683                    candidate_nodes,
684                })
685            });
686        }
687
688        let mut pools = Vec::with_capacity(midpoint_proofs.len());
689        while let Some(result) = pool_futures.next().await {
690            pools.push(result?);
691        }
692
693        Ok(pools)
694    }
695
696    /// Collect `CANDIDATES_PER_POOL` (16) merkle candidate quotes from the network.
697    #[allow(clippy::too_many_lines)]
698    async fn get_merkle_candidate_pool(
699        &self,
700        address: &[u8; 32],
701        data_type: u32,
702        data_size: u64,
703        merkle_payment_timestamp: u64,
704    ) -> Result<[MerklePaymentCandidateNode; CANDIDATES_PER_POOL]> {
705        let node = self.network().node();
706        let timeout = Duration::from_secs(self.config().quote_timeout_secs);
707
708        // Query extra peers to handle validation failures (bad sigs, wrong type, etc.)
709        let query_count = CANDIDATES_PER_POOL * 2;
710        let mut remote_peers = self
711            .network()
712            .find_closest_peers(address, query_count)
713            .await?;
714
715        // If DHT closest-nodes didn't return enough, supplement with connected peers.
716        // On small networks the DHT iterative lookup may not discover enough peers
717        // close to a random pool address, but we know more peers via direct connections.
718        if remote_peers.len() < CANDIDATES_PER_POOL {
719            let connected = self.network().connected_peers().await;
720            for peer in connected {
721                if !remote_peers.iter().any(|(id, _)| *id == peer) {
722                    remote_peers.push((peer, vec![]));
723                }
724            }
725        }
726
727        if remote_peers.len() < CANDIDATES_PER_POOL {
728            return Err(Error::InsufficientPeers(format!(
729                "Found {} peers, need {CANDIDATES_PER_POOL} for merkle candidate pool. \
730                 Use --no-merkle or a larger network.",
731                remote_peers.len()
732            )));
733        }
734
735        let mut candidate_futures = FuturesUnordered::new();
736
737        for (peer_id, peer_addrs) in &remote_peers {
738            let request_id = self.next_request_id();
739            let request = MerkleCandidateQuoteRequest {
740                address: *address,
741                data_type,
742                data_size,
743                merkle_payment_timestamp,
744            };
745            let message = ChunkMessage {
746                request_id,
747                body: ChunkMessageBody::MerkleCandidateQuoteRequest(request),
748            };
749
750            let message_bytes = match message.encode() {
751                Ok(bytes) => bytes,
752                Err(e) => {
753                    warn!("Failed to encode merkle candidate request for {peer_id}: {e}");
754                    continue;
755                }
756            };
757
758            let peer_id_clone = *peer_id;
759            let addrs_clone = peer_addrs.clone();
760            let node_clone = node.clone();
761
762            let fut = async move {
763                let result = send_and_await_chunk_response(
764                    &node_clone,
765                    &peer_id_clone,
766                    message_bytes,
767                    request_id,
768                    timeout,
769                    &addrs_clone,
770                    |body| match body {
771                        ChunkMessageBody::MerkleCandidateQuoteResponse(
772                            MerkleCandidateQuoteResponse::Success {
773                                candidate_node,
774                                commitment,
775                            },
776                        ) => {
777                            match rmp_serde::from_slice::<MerklePaymentCandidateNode>(
778                                &candidate_node,
779                            ) {
780                                Ok(node) => Some(Ok((node, commitment))),
781                                Err(e) => Some(Err(Error::Serialization(format!(
782                                    "Failed to deserialize candidate node from {peer_id_clone}: {e}"
783                                )))),
784                            }
785                        }
786                        ChunkMessageBody::MerkleCandidateQuoteResponse(
787                            MerkleCandidateQuoteResponse::Error(e),
788                        ) => Some(Err(Error::Protocol(format!(
789                            "Merkle quote error from {peer_id_clone}: {e}"
790                        )))),
791                        _ => None,
792                    },
793                    |e| {
794                        Error::Network(format!(
795                            "Failed to send merkle candidate request to {peer_id_clone}: {e}"
796                        ))
797                    },
798                    || {
799                        Error::Timeout(format!(
800                            "Timeout waiting for merkle candidate from {peer_id_clone}"
801                        ))
802                    },
803                )
804                .await;
805
806                (peer_id_clone, result)
807            };
808
809            candidate_futures.push(fut);
810        }
811
812        self.collect_validated_candidates(&mut candidate_futures, address, merkle_payment_timestamp)
813            .await
814    }
815
816    /// Collect and validate merkle candidate responses, then return the
817    /// `CANDIDATES_PER_POOL` valid responders that are XOR-closest to the
818    /// pool midpoint.
819    ///
820    /// Why distance-sort instead of "first N to respond":
821    /// the storing-node verifier re-runs a network closest-peers lookup of
822    /// the pool midpoint and rejects the pool if fewer than 13 of the 16
823    /// candidate `pub_keys` appear in that authoritative close-set. Pools
824    /// built from the fastest-to-respond quoters fail this check whenever
825    /// truly-close peers are slower (NAT/relay paths) than farther peers.
826    async fn collect_validated_candidates(
827        &self,
828        futures: &mut FuturesUnordered<
829            impl std::future::Future<
830                Output = (
831                    PeerId,
832                    std::result::Result<(MerklePaymentCandidateNode, Option<Vec<u8>>), Error>,
833                ),
834            >,
835        >,
836        target_address: &[u8; 32],
837        merkle_payment_timestamp: u64,
838    ) -> Result<[MerklePaymentCandidateNode; CANDIDATES_PER_POOL]> {
839        let mut valid: Vec<(PeerId, MerklePaymentCandidateNode)> = Vec::new();
840        let mut failures: Vec<String> = Vec::new();
841
842        while let Some((peer_id, result)) = futures.next().await {
843            match result {
844                Ok((candidate, commitment)) => {
845                    if !verify_merkle_candidate_signature(&candidate) {
846                        warn!("Invalid ML-DSA-65 signature from merkle candidate {peer_id}");
847                        failures.push(format!("{peer_id}: invalid signature"));
848                        continue;
849                    }
850                    if candidate.merkle_payment_timestamp != merkle_payment_timestamp {
851                        warn!("Timestamp mismatch from merkle candidate {peer_id}");
852                        failures.push(format!("{peer_id}: timestamp mismatch"));
853                        continue;
854                    }
855                    // The candidate's identity is `BLAKE3(candidate.pub_key)` —
856                    // this is what the storer derives (verifier.rs). Require it
857                    // to equal the network responder so a two-identity operator
858                    // cannot answer as B while shipping A's commitment.
859                    let candidate_peer = PeerId::from_bytes(compute_address(&candidate.pub_key));
860                    if candidate_peer != peer_id {
861                        warn!(
862                            "Dropping merkle candidate {peer_id} — pub_key derives {candidate_peer}, \
863                             not the responding peer"
864                        );
865                        failures.push(format!("{peer_id}: candidate pub_key/peer mismatch"));
866                        continue;
867                    }
868                    // ADR-0004: the FULL resolve-before-pay binding check, same as
869                    // the single-node path — a candidate priced off its committed
870                    // count, or shipping an unresolvable/forged commitment, is
871                    // dropped before it can enter a pool the client pays. Checked
872                    // against the CANDIDATE peer (the one the storer audits). The
873                    // shipped commitment is consumed here (resolution only) and
874                    // not forwarded in the PUT bundles (see
875                    // `finalize_merkle_batch`).
876                    if let Err(detail) =
877                        merkle_candidate_binding_is_valid(&candidate_peer, &candidate, &commitment)
878                    {
879                        warn!("Dropping merkle candidate {peer_id} — ADR-0004 binding invalid: {detail}");
880                        failures.push(format!("{peer_id}: bad commitment binding ({detail})"));
881                        continue;
882                    }
883                    valid.push((candidate_peer, candidate));
884                }
885                Err(e) => {
886                    debug!("Failed to get merkle candidate from {peer_id}: {e}");
887                    failures.push(format!("{peer_id}: {e}"));
888                }
889            }
890        }
891
892        if valid.len() < CANDIDATES_PER_POOL {
893            return Err(Error::InsufficientPeers(format!(
894                "Got {} merkle candidates, need {CANDIDATES_PER_POOL}. Failures: [{}]",
895                valid.len(),
896                failures.join("; ")
897            )));
898        }
899
900        let target_peer = PeerId::from_bytes(*target_address);
901        valid.sort_by_key(|(peer_id, _)| peer_id.xor_distance(&target_peer));
902
903        let candidates: Vec<MerklePaymentCandidateNode> = valid
904            .into_iter()
905            .take(CANDIDATES_PER_POOL)
906            .map(|(_, candidate)| candidate)
907            .collect();
908
909        let array: [MerklePaymentCandidateNode; CANDIDATES_PER_POOL] =
910            candidates.try_into().map_err(|_| {
911                Error::Payment("Failed to convert candidates to fixed array".to_string())
912            })?;
913        Ok(array)
914    }
915
916    /// Upload chunks using pre-computed merkle proofs from a batch payment.
917    ///
918    /// Each chunk is matched to its proof from `batch_result.proofs`, then
919    /// stored to its close group concurrently. A per-chunk quorum shortfall
920    /// (`InsufficientPeers`) does **not** abort the file: such chunks are
921    /// collected and retried — with the same reusable proof and a freshly
922    /// re-collected close group — for up to [`MERKLE_STORE_MAX_ATTEMPTS`]
923    /// attempts. `stored_offset` carries chunks already confirmed by an earlier
924    /// preflight (used for progress numbering and the returned `stored` count),
925    /// and `total_chunks` is the whole-file total for progress events.
926    ///
927    /// Returns how many chunks are stored (including `stored_offset`), how many
928    /// remained short of quorum after all retries, and the aggregate store
929    /// stats.
930    ///
931    /// # Errors
932    ///
933    /// Returns an error only for non-quorum failures (e.g. a missing proof, or a
934    /// chunk-count/address mismatch); quorum shortfalls are reported via
935    /// [`MerkleStoreOutcome::failed`].
936    pub(crate) async fn merkle_upload_chunks(
937        &self,
938        chunk_contents: Vec<Bytes>,
939        addresses: Vec<[u8; 32]>,
940        batch_result: &MerkleBatchPaymentResult,
941        progress: Option<&mpsc::Sender<UploadEvent>>,
942        stored_offset: usize,
943        total_chunks: usize,
944    ) -> Result<MerkleStoreOutcome> {
945        let store_limiter = self.controller().store.clone();
946        // Clamp fan-out to batch size — partial batches should not
947        // pay for unused slots (see PERF-RESULTS.md).
948        let batch_size = chunk_contents.len();
949        if batch_size != addresses.len() {
950            return Err(Error::InvalidData(format!(
951                "merkle upload has {batch_size} chunk contents but {} addresses",
952                addresses.len()
953            )));
954        }
955        // Cap closure re-read per scheduler refill so mid-flight limiter growth
956        // is applied to the rest of the batch (V2-554). Clamped to batch size —
957        // partial batches should not pay for unused slots (see PERF-RESULTS.md).
958        let cap = || store_limiter.current().min(batch_size.max(1));
959
960        // External-signer path: the chunk bodies are already resident in memory,
961        // so `store_one` clones each from this map on demand. (The whole-file
962        // path instead reads bodies from the on-disk spill — same `store_one(addr)`
963        // shape, so the store scheduler never carries a resident set of bodies.)
964        let bodies: std::collections::HashMap<[u8; 32], Bytes> =
965            addresses.iter().copied().zip(chunk_contents).collect();
966        let addrs = addresses;
967
968        // Store one chunk to its (freshly re-collected) close group. Called
969        // once per chunk per attempt, so a retry round naturally lands on a
970        // converged routing table. Only `InsufficientPeers` is recoverable;
971        // a missing proof stays fatal.
972        let store_one = |addr: [u8; 32]| {
973            let limiter = store_limiter.clone();
974            let content = bodies.get(&addr).cloned();
975            let proof_bytes = batch_result.proofs.get(&addr).cloned();
976            async move {
977                let started = std::time::Instant::now();
978                let content = content.ok_or_else(|| {
979                    Error::InvalidData(format!("missing chunk body for {}", hex::encode(addr)))
980                })?;
981                let proof = proof_bytes.ok_or_else(|| {
982                    Error::Payment(format!(
983                        "Missing merkle proof for chunk {}",
984                        hex::encode(addr)
985                    ))
986                })?;
987                let peers = self.put_target_peers(&addr).await?;
988                observe_op(
989                    &limiter,
990                    || async move { self.chunk_put_to_close_group(content, proof, &peers).await },
991                    classify_error,
992                )
993                .await
994                .map(|_| started)
995            }
996        };
997
998        let outcome = merkle_store_with_retry(
999            addrs,
1000            cap,
1001            MERKLE_STORE_MAX_ATTEMPTS,
1002            MERKLE_RETRY_BACKOFF,
1003            progress,
1004            stored_offset,
1005            total_chunks,
1006            store_one,
1007        )
1008        .await?;
1009
1010        // The external-signer path treats a non-quorum error as terminal (it
1011        // returns a single all-or-nothing `FileUploadResult`), so re-raise the
1012        // fatal that `merkle_store_with_retry` now carries in the outcome. The
1013        // CLI/spill paths, which can surface `PartialUpload`, read `fatal`
1014        // directly instead.
1015        if let Some(e) = outcome.fatal {
1016            return Err(e);
1017        }
1018        Ok(outcome)
1019    }
1020}
1021
1022/// Total store-attempt budget for a merkle batch: the initial attempt plus up
1023/// to three retries. Chosen to match the wave path's contract
1024/// (`batch.rs` iterates `0..=MAX_RETRIES` with `MAX_RETRIES = 3`) and the
1025/// four-slot [`WaveAggregateStats::retries_histogram`], so a chunk that lands
1026/// on the final retry is recorded in `retries_histogram[3]`.
1027///
1028/// A chunk's close group can transiently reject its `winner_pool` midpoint
1029/// while a few nodes' routing tables disagree about that midpoint; the network
1030/// converges within minutes. Per-chunk proofs are reusable, so retrying the
1031/// same proof after a short backoff recovers these shortfalls for free — no
1032/// re-payment and no new pool.
1033pub(crate) const MERKLE_STORE_MAX_ATTEMPTS: usize = 4;
1034
1035/// Base backoff between merkle store attempts. The routing-table divergence
1036/// that causes `InsufficientPeers` resolves on the order of minutes, so a short
1037/// sleep between rounds is enough to land on a converged close group. The
1038/// actual wait is jittered by [`MERKLE_RETRY_JITTER`] so a large failed set
1039/// does not re-fire against the same divergent nodes in lockstep.
1040pub(crate) const MERKLE_RETRY_BACKOFF: Duration = Duration::from_secs(30);
1041
1042/// Fractional jitter applied to [`MERKLE_RETRY_BACKOFF`] (±10%), spreading the
1043/// retry wave so convergent nodes are not all probed at the same instant.
1044const MERKLE_RETRY_JITTER: f64 = 0.1;
1045
1046/// Outcome of storing a merkle batch: how many chunks landed, how many
1047/// remained short of quorum after all retries, and the aggregate store stats.
1048#[derive(Debug, Default)]
1049pub(crate) struct MerkleStoreOutcome {
1050    /// Chunks that reached quorum, including any `stored_offset` carried in
1051    /// from a preflight (counted once, even if they needed retries).
1052    pub stored: usize,
1053    /// Addresses confirmed stored by this call (excludes the `stored_offset`
1054    /// preflight carry-in — those have no address here). The caller appends
1055    /// these to the file's stored set; using the explicit set (rather than
1056    /// inferring "input minus failed") keeps accounting correct even when a
1057    /// `fatal` error aborts the pass mid-flight, leaving some input chunks
1058    /// neither stored nor in `failed_addresses`.
1059    pub stored_addresses: Vec<[u8; 32]>,
1060    /// Chunks still short of quorum after [`MERKLE_STORE_MAX_ATTEMPTS`].
1061    pub failed: usize,
1062    /// Addresses (and the last error message) of chunks still short of quorum
1063    /// after all retries. Empty when `failed == 0`. Used by the CLI path to
1064    /// build [`crate::data::Error::PartialUpload`]; the external-signer path
1065    /// only reads the counts.
1066    pub failed_addresses: Vec<([u8; 32], String)>,
1067    /// Set when a non-quorum (fatal) store error aborted the pass. Successes
1068    /// completed before the abort are still recorded in `stored`/
1069    /// `stored_addresses`; the chunks that had already failed quorum are in
1070    /// `failed_addresses`; chunks still in flight when the abort hit are in
1071    /// neither (the caller treats input-minus-stored as failed). Callers that
1072    /// want the old "fatal aborts everything" contract re-raise this as `Err`.
1073    pub fatal: Option<Error>,
1074    /// Aggregate store stats (durations, attempts, per-round retry histogram).
1075    pub stats: crate::data::client::batch::WaveAggregateStats,
1076}
1077
1078/// Drive a set of merkle chunk stores with bounded retry of quorum shortfalls.
1079///
1080/// Runs `store_one(addr)` over all `addrs` concurrently, keeping up to `cap()`
1081/// stores in flight and RE-READING `cap()` as each slot frees — so mid-flight
1082/// adaptive growth is applied to the rest of the round instead of being frozen
1083/// at a per-round snapshot (V2-554). `store_one` acquires the chunk body itself
1084/// (e.g. reads it from the on-disk spill), so only the ≤`cap()` in-flight stores
1085/// hold a body in memory — the caller passes addresses, never a resident set of
1086/// bodies, which is what lets the whole-file store run as one cap-bounded
1087/// fan-out instead of memory-bounded waves. Collects quorum shortfalls
1088/// (`InsufficientPeers`, `CloseGroupShortfall`, `RemotePut`) rather than
1089/// aborting. Failed chunks are retried — `store_one` re-collects their close
1090/// group on each call, so a converged routing table can yield a fresh group —
1091/// for up to `max_attempts` rounds, sleeping a jittered `backoff` between
1092/// rounds. A chunk's success is counted once and recorded in the retry round it
1093/// landed on (`retries_histogram[round]`). `stored_offset` seeds the returned
1094/// `stored` count and the progress numbering; `total` is the whole-file total
1095/// reported in progress events.
1096///
1097/// A non-quorum error stops the pass but does **not** discard progress: the
1098/// successes already completed this pass stay in `stored`/`stored_addresses`,
1099/// the quorum shortfalls so far stay in `failed_addresses`, and the error is
1100/// returned in [`MerkleStoreOutcome::fatal`] (as `Ok(outcome)`, not `Err`).
1101/// Callers that want the old abort-everything behaviour re-raise `fatal` as
1102/// `Err`; CLI callers fold it into `PartialUpload` while keeping the stores.
1103#[allow(clippy::too_many_arguments)]
1104pub(crate) async fn merkle_store_with_retry<F, Fut, C>(
1105    addrs: Vec<[u8; 32]>,
1106    cap: C,
1107    max_attempts: usize,
1108    backoff: Duration,
1109    progress: Option<&mpsc::Sender<UploadEvent>>,
1110    stored_offset: usize,
1111    total: usize,
1112    store_one: F,
1113) -> Result<MerkleStoreOutcome>
1114where
1115    F: Fn([u8; 32]) -> Fut,
1116    Fut: std::future::Future<Output = Result<std::time::Instant>>,
1117    C: Fn() -> usize,
1118{
1119    let attempts = max_attempts.max(1);
1120    let mut outcome = MerkleStoreOutcome {
1121        stored: stored_offset,
1122        ..MerkleStoreOutcome::default()
1123    };
1124    let mut pending = addrs;
1125
1126    for attempt in 0..attempts {
1127        // Carries the failing address forward for the next round plus the last
1128        // quorum-shortfall message, so an exhausted set can report per-chunk
1129        // errors via `failed_addresses`. The chunk BODY is not carried — each
1130        // `store_one` re-reads it on demand, so at most `cap` bodies are ever
1131        // resident regardless of the total chunk count.
1132        let mut next_failed: Vec<([u8; 32], String)> = Vec::new();
1133
1134        // Rolling scheduler: keep up to `cap()` stores in flight, re-reading the
1135        // cap as each slot frees so limiter growth mid-round is applied to the
1136        // remaining chunks (V2-554). Iterator exhaustion bounds the launch count
1137        // to the pending set, so no explicit clamp to `pending.len()` is needed.
1138        let mut pending_iter = pending.into_iter();
1139        let mut in_flight = FuturesUnordered::new();
1140        loop {
1141            let slots = cap().max(1);
1142            while in_flight.len() < slots {
1143                match pending_iter.next() {
1144                    Some(addr) => {
1145                        let fut = store_one(addr);
1146                        in_flight.push(async move { (addr, fut.await) });
1147                    }
1148                    None => break,
1149                }
1150            }
1151            let Some((addr, result)) = in_flight.next().await else {
1152                break;
1153            };
1154            outcome.stats.chunk_attempts_total =
1155                outcome.stats.chunk_attempts_total.saturating_add(1);
1156            match result {
1157                Ok(started) => {
1158                    let duration_ms =
1159                        u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
1160                    outcome.stats.store_durations_ms.push(duration_ms);
1161                    let idx = attempt.min(outcome.stats.retries_histogram.len().saturating_sub(1));
1162                    outcome.stats.retries_histogram[idx] =
1163                        outcome.stats.retries_histogram[idx].saturating_add(1);
1164                    outcome.stored += 1;
1165                    outcome.stored_addresses.push(addr);
1166                    if let Some(tx) = progress {
1167                        let _ = tx.try_send(UploadEvent::ChunkStored {
1168                            stored: outcome.stored,
1169                            total,
1170                        });
1171                    }
1172                }
1173                // A quorum shortfall — whether a timeout-bearing capacity
1174                // shortfall (`InsufficientPeers`), a pure dial-churn shortfall
1175                // (`CloseGroupShortfall`, V2-554), or an app-only rejection
1176                // (`RemotePut`, e.g. pool-rejected / quote-stale / disk-full,
1177                // which are transient) — is recoverable: defer and retry the
1178                // chunk rather than aborting the whole upload (V2-468 / V2-554).
1179                Err(
1180                    e @ (Error::InsufficientPeers(_)
1181                    | Error::CloseGroupShortfall(_)
1182                    | Error::RemotePut { .. }),
1183                ) => {
1184                    next_failed.push((addr, e.to_string()));
1185                }
1186                Err(e) => {
1187                    // Non-quorum error: fatal. Stop consuming the stream but do
1188                    // NOT discard the outcome — successes already completed this
1189                    // pass stay recorded in `stored`/`stored_addresses`. Record
1190                    // the fatal chunk itself (and any quorum shortfalls seen so
1191                    // far) as failed; anything still in flight is left for the
1192                    // caller to treat as not-stored (input minus
1193                    // `stored_addresses`).
1194                    next_failed.push((addr, e.to_string()));
1195                    outcome.fatal = Some(e);
1196                    break;
1197                }
1198            }
1199        }
1200
1201        if outcome.fatal.is_some() {
1202            outcome.failed = next_failed.len();
1203            outcome.failed_addresses = next_failed;
1204            return Ok(outcome);
1205        }
1206
1207        if next_failed.is_empty() {
1208            break;
1209        }
1210
1211        if attempt + 1 < attempts {
1212            warn!(
1213                failed = next_failed.len(),
1214                attempt = attempt + 1,
1215                "merkle chunks short of quorum, retrying after backoff"
1216            );
1217            pending = next_failed.into_iter().map(|(addr, _msg)| addr).collect();
1218            if backoff > Duration::ZERO {
1219                // Jitter the wait (±MERKLE_RETRY_JITTER) so a large failed set
1220                // does not re-probe the same divergent nodes in lockstep.
1221                // `thread_rng` is !Send, so the value is computed and the rng
1222                // dropped before the await to keep this future Send.
1223                let wait = {
1224                    let mut rng = rand::thread_rng();
1225                    let factor = 1.0 + rng.gen_range(-MERKLE_RETRY_JITTER..=MERKLE_RETRY_JITTER);
1226                    backoff.mul_f64(factor)
1227                };
1228                tokio::time::sleep(wait).await;
1229            }
1230        } else {
1231            outcome.failed = next_failed.len();
1232            outcome.failed_addresses = next_failed;
1233            break;
1234        }
1235    }
1236
1237    Ok(outcome)
1238}
1239
1240/// Round delays (seconds) for the merkle upload deferred-retry pass. Round 0
1241/// fires immediately — most quorum shortfalls on a healthy network are
1242/// momentary close-group divergence that clears in well under a second, and
1243/// serializing them behind mandatory sleeps was the single biggest throughput
1244/// sink in the wave path (one bad chunk parked the other 63 slots for minutes).
1245/// Only chunks that survive a round get a longer back-off before the next, so a
1246/// genuinely saturated/diverged group still gets time to settle. Mirrors the
1247/// download path's `DEFERRED_ROUND_DELAYS_SECS`.
1248pub(crate) const DEFERRED_ROUND_DELAYS_SECS: [u64; 3] = [0, 15, 45];
1249
1250/// Histogram slot for a deferred-retry round's successes.
1251///
1252/// The wave first pass lands in slot 0; deferred round `r` (0-indexed) lands in
1253/// slot `r + 1`, clamped to the last slot so the four-slot
1254/// [`WaveAggregateStats::retries_histogram`] keeps recording "which round a
1255/// chunk landed on" under the post-wave deferred structure.
1256pub(crate) fn deferred_round_histogram_slot(round: usize, hist_len: usize) -> usize {
1257    (round + 1).min(hist_len.saturating_sub(1))
1258}
1259
1260/// Outcome of the post-wave deferred-retry pass.
1261#[derive(Debug, Default)]
1262pub(crate) struct DeferredRetryOutcome {
1263    /// Running total of stored chunks, seeded with the `stored_offset` passed in
1264    /// (i.e. everything the wave passes already stored) and advanced by each
1265    /// deferred round's successes.
1266    pub stored: usize,
1267    /// Addresses that reached quorum during the deferred rounds (to be appended
1268    /// to the file's `stored` set).
1269    pub stored_addresses: Vec<[u8; 32]>,
1270    /// Count of chunks still short of quorum after the final deferred round.
1271    pub failed: usize,
1272    /// Addresses (and last quorum-shortfall message) still short after the final
1273    /// round, or — when `fatal` is set — the chunks that were still pending when
1274    /// a non-quorum error aborted the pass.
1275    pub failed_addresses: Vec<([u8; 32], String)>,
1276    /// Set when a deferred round hit a non-quorum (fatal) store error. The
1277    /// caller surfaces this as `PartialUpload` preserving everything stored so
1278    /// far, mirroring the wave path's fatal handling.
1279    pub fatal: Option<String>,
1280    /// Aggregate store stats merged across rounds, with each round's successes
1281    /// already mapped into its [`deferred_round_histogram_slot`].
1282    pub stats: crate::data::client::batch::WaveAggregateStats,
1283}
1284
1285/// Retry a file-level set of quorum-short merkle chunks in concurrent rounds.
1286///
1287/// This is the upload analogue of the download path's deferred-retry loop. The
1288/// whole-file store pass hands its quorum-short chunks here. Each round stores
1289/// all still-pending chunks in a single cap-bounded pass via
1290/// [`merkle_store_with_retry`] at `concurrency_for(len)` — `store_one(addr)`
1291/// re-reads each body on demand (from the spill), so only the ≤`concurrency_for`
1292/// in-flight stores hold a body and peak resident memory stays bounded
1293/// regardless of the deferred-chunk count. Survivors carry to the next round
1294/// after a `round_delays_secs` sleep. Chunks still short after the final round
1295/// become `failed_addresses`; a non-quorum store error stops the pass and is
1296/// reported via `fatal` (with the quorum shortfalls seen so far recorded as
1297/// `failed_addresses`) so the caller can surface `PartialUpload` — reconciled
1298/// against the full address list — without discarding earlier progress.
1299///
1300/// `store_one`, `progress`, `stored_offset` and `total` mirror
1301/// [`merkle_store_with_retry`].
1302#[allow(clippy::too_many_arguments)]
1303pub(crate) async fn merkle_deferred_retry<CF, SF, Fut>(
1304    deferred: Vec<([u8; 32], String)>,
1305    round_delays_secs: &[u64],
1306    concurrency_for: CF,
1307    progress: Option<&mpsc::Sender<UploadEvent>>,
1308    stored_offset: usize,
1309    total: usize,
1310    store_one: SF,
1311) -> Result<DeferredRetryOutcome>
1312where
1313    CF: Fn(usize) -> usize,
1314    SF: Fn([u8; 32]) -> Fut,
1315    Fut: std::future::Future<Output = Result<std::time::Instant>>,
1316{
1317    let mut outcome = DeferredRetryOutcome {
1318        stored: stored_offset,
1319        ..DeferredRetryOutcome::default()
1320    };
1321    let mut remaining = deferred;
1322    let rounds = round_delays_secs.len();
1323
1324    for (round, &delay_secs) in round_delays_secs.iter().enumerate() {
1325        if remaining.is_empty() {
1326            break;
1327        }
1328        if delay_secs > 0 {
1329            tokio::time::sleep(Duration::from_secs(delay_secs)).await;
1330        }
1331        info!(
1332            "Deferred merkle retry round {}/{}: {} chunk(s) short of quorum",
1333            round + 1,
1334            rounds,
1335            remaining.len(),
1336        );
1337
1338        // Store this round's whole pending set in one cap-bounded pass. A
1339        // single-pass round records its successes in histogram slot 0, so
1340        // redirect them into the round's own slot.
1341        let slot = deferred_round_histogram_slot(round, outcome.stats.retries_histogram.len());
1342        let round_addrs: Vec<[u8; 32]> = std::mem::take(&mut remaining)
1343            .into_iter()
1344            .map(|(addr, _msg)| addr)
1345            .collect();
1346        let round_len = round_addrs.len();
1347        // Re-read the cap per scheduler refill (V2-554) via `concurrency_for`,
1348        // which re-samples the store limiter clamped to this round's size.
1349        let cap = || concurrency_for(round_len);
1350
1351        let round_outcome = merkle_store_with_retry(
1352            round_addrs,
1353            cap,
1354            1,
1355            Duration::ZERO,
1356            progress,
1357            outcome.stored,
1358            total,
1359            &store_one,
1360        )
1361        .await?;
1362
1363        outcome.stored = round_outcome.stored;
1364        outcome
1365            .stored_addresses
1366            .extend(round_outcome.stored_addresses);
1367
1368        // Merge stats, redirecting this round's successes to its slot.
1369        outcome.stats.chunk_attempts_total = outcome
1370            .stats
1371            .chunk_attempts_total
1372            .saturating_add(round_outcome.stats.chunk_attempts_total);
1373        outcome
1374            .stats
1375            .store_durations_ms
1376            .extend(round_outcome.stats.store_durations_ms);
1377        let landed: usize = round_outcome.stats.retries_histogram.iter().sum();
1378        outcome.stats.retries_histogram[slot] =
1379            outcome.stats.retries_histogram[slot].saturating_add(landed);
1380
1381        if let Some(fatal) = round_outcome.fatal {
1382            // Fatal mid-pass: confirmed stores are preserved above. The store
1383            // helper left this round's quorum shortfalls in `failed_addresses`;
1384            // chunks still in flight / not yet launched are reconciled against
1385            // the full address list by the caller's `partial_upload_after_fatal`.
1386            outcome.fatal = Some(fatal.to_string());
1387            outcome.failed = round_outcome.failed_addresses.len();
1388            outcome.failed_addresses = round_outcome.failed_addresses;
1389            return Ok(outcome);
1390        }
1391
1392        // Quorum-short chunks from this round survive to the next.
1393        remaining = round_outcome.failed_addresses;
1394    }
1395
1396    outcome.failed = remaining.len();
1397    outcome.failed_addresses = remaining;
1398    Ok(outcome)
1399}
1400
1401/// Phase 2 of external-signer merkle payment: generate proofs from winner.
1402///
1403/// Takes the prepared batch and the winner pool hash returned by the
1404/// on-chain payment transaction. Generates per-chunk merkle proofs.
1405pub fn finalize_merkle_batch(
1406    prepared: PreparedMerkleBatch,
1407    winner_pool_hash: [u8; 32],
1408) -> Result<MerkleBatchPaymentResult> {
1409    let chunk_count = prepared.addresses.len();
1410    let xornames: Vec<XorName> = prepared.addresses.iter().map(|a| XorName(*a)).collect();
1411
1412    // Find the winner pool
1413    let winner_pool = prepared
1414        .candidate_pools
1415        .iter()
1416        .find(|pool| pool.hash() == winner_pool_hash)
1417        .ok_or_else(|| {
1418            Error::Payment(format!(
1419                "Winner pool {} not found in candidate pools",
1420                hex::encode(winner_pool_hash)
1421            ))
1422        })?;
1423
1424    // ADR-0004: commitment sidecars are deliberately NOT forwarded in the
1425    // per-chunk proofs. Sixteen sidecars are ~214 KB serialized, and copying
1426    // them into every chunk's bundle pushed the proof past the storer's
1427    // payment-proof size cap, rejecting every merkle PUT once nodes carried
1428    // live commitments. The client still fully resolves every candidate's
1429    // commitment before paying (during pool collection); the storer's
1430    // cross-check is best-effort and resolves pins from its gossip cache or a
1431    // `GetCommitmentByPin` fetch when no sidecar is shipped.
1432
1433    // Generate proofs for each chunk
1434    info!("Generating merkle proofs for {chunk_count} chunks");
1435    let mut proofs = HashMap::with_capacity(chunk_count);
1436
1437    for (i, xorname) in xornames.iter().enumerate() {
1438        let address_proof = prepared
1439            .tree
1440            .generate_address_proof(i, *xorname)
1441            .map_err(|e| {
1442                Error::Payment(format!(
1443                    "Failed to generate address proof for chunk {i}: {e}"
1444                ))
1445            })?;
1446
1447        let merkle_proof = MerklePaymentProof::new(*xorname, address_proof, winner_pool.clone());
1448
1449        let tagged_bytes = serialize_merkle_proof(&merkle_proof)
1450            .map_err(|e| Error::Serialization(format!("Failed to serialize merkle proof: {e}")))?;
1451
1452        proofs.insert(prepared.addresses[i], tagged_bytes);
1453    }
1454
1455    info!("Merkle batch payment complete: {chunk_count} proofs generated");
1456
1457    Ok(MerkleBatchPaymentResult {
1458        proofs,
1459        chunk_count,
1460        storage_cost_atto: "0".to_string(),
1461        gas_cost_wei: 0,
1462        merkle_payment_timestamp: prepared.merkle_payment_timestamp,
1463    })
1464}
1465
1466/// Compile-time assertions that merkle method futures are Send.
1467#[cfg(test)]
1468mod send_assertions {
1469    use super::*;
1470    use crate::data::client::Client;
1471
1472    fn _assert_send<T: Send>(_: &T) {}
1473
1474    #[allow(
1475        dead_code,
1476        unreachable_code,
1477        unused_variables,
1478        clippy::diverging_sub_expression
1479    )]
1480    async fn _merkle_upload_chunks_is_send(client: &Client) {
1481        let batch_result: MerkleBatchPaymentResult = todo!();
1482        let fut = client.merkle_upload_chunks(Vec::new(), Vec::new(), &batch_result, None, 0, 0);
1483        _assert_send(&fut);
1484    }
1485}
1486
1487#[cfg(test)]
1488#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1489mod tests {
1490    use super::*;
1491    use ant_protocol::evm::{Amount, MerkleTree, RewardsAddress, CANDIDATES_PER_POOL};
1492
1493    // =========================================================================
1494    // should_use_merkle (free function, no Client needed)
1495    // =========================================================================
1496
1497    #[test]
1498    fn test_auto_below_threshold() {
1499        assert!(!should_use_merkle(1, PaymentMode::Auto));
1500        assert!(!should_use_merkle(10, PaymentMode::Auto));
1501        assert!(!should_use_merkle(63, PaymentMode::Auto));
1502    }
1503
1504    #[test]
1505    fn test_auto_at_and_above_threshold() {
1506        assert!(should_use_merkle(64, PaymentMode::Auto));
1507        assert!(should_use_merkle(65, PaymentMode::Auto));
1508        assert!(should_use_merkle(1000, PaymentMode::Auto));
1509    }
1510
1511    #[test]
1512    fn test_merkle_mode_forces_at_2() {
1513        assert!(!should_use_merkle(1, PaymentMode::Merkle));
1514        assert!(should_use_merkle(2, PaymentMode::Merkle));
1515        assert!(should_use_merkle(3, PaymentMode::Merkle));
1516    }
1517
1518    #[test]
1519    fn test_single_mode_always_false() {
1520        assert!(!should_use_merkle(0, PaymentMode::Single));
1521        assert!(!should_use_merkle(64, PaymentMode::Single));
1522        assert!(!should_use_merkle(1000, PaymentMode::Single));
1523    }
1524
1525    #[test]
1526    fn test_default_mode_is_auto() {
1527        assert_eq!(PaymentMode::default(), PaymentMode::Auto);
1528    }
1529
1530    #[test]
1531    fn test_threshold_value() {
1532        assert_eq!(DEFAULT_MERKLE_THRESHOLD, 64);
1533    }
1534
1535    // =========================================================================
1536    // preflight_stored_status — must degrade gracefully, never abort the batch
1537    // =========================================================================
1538
1539    #[test]
1540    fn test_preflight_quotes_gathered_means_not_stored() {
1541        assert!(matches!(preflight_stored_status(Ok(())), Ok(false)));
1542    }
1543
1544    #[test]
1545    fn test_preflight_already_stored_is_stored() {
1546        let r: Result<()> = Err(Error::AlreadyStored);
1547        assert!(matches!(preflight_stored_status(r), Ok(true)));
1548    }
1549
1550    /// The regression: a transient quote-quorum failure during the preflight
1551    /// must NOT propagate (which would abort the whole forced-merkle upload).
1552    /// It is treated as "not known to be stored" → queue the chunk for upload.
1553    #[test]
1554    fn test_preflight_transient_quote_failure_does_not_abort() {
1555        // The exact error STG-01 hit: couldn't gather a 7-quote quorum.
1556        let insufficient: Result<()> =
1557            Err(Error::InsufficientPeers("Got 5 quotes, need 7".to_string()));
1558        assert!(
1559            matches!(preflight_stored_status(insufficient), Ok(false)),
1560            "insufficient-peers during preflight must degrade to not-stored, not error"
1561        );
1562
1563        let timeout: Result<()> = Err(Error::Timeout("Timeout waiting for quote".to_string()));
1564        assert!(matches!(preflight_stored_status(timeout), Ok(false)));
1565
1566        let network: Result<()> = Err(Error::Network("connection reset".to_string()));
1567        assert!(matches!(preflight_stored_status(network), Ok(false)));
1568    }
1569
1570    /// Genuine application errors still propagate — the preflight should not
1571    /// silently swallow problems that would recur on a healthy link.
1572    #[test]
1573    fn test_preflight_application_error_propagates() {
1574        let payment: Result<()> = Err(Error::Payment("bad payment".to_string()));
1575        assert!(matches!(
1576            preflight_stored_status(payment),
1577            Err(Error::Payment(_))
1578        ));
1579    }
1580
1581    #[test]
1582    fn chunk_contents_for_upload_addresses_preserves_requested_order() {
1583        let first = Bytes::from_static(b"first");
1584        let second = Bytes::from_static(b"second");
1585        let first_addr = compute_address(&first);
1586        let second_addr = compute_address(&second);
1587
1588        let selected = chunk_contents_for_upload_addresses(
1589            vec![first.clone(), second.clone()],
1590            &[second_addr, first_addr],
1591        )
1592        .unwrap();
1593
1594        assert_eq!(selected, vec![second, first]);
1595    }
1596
1597    #[test]
1598    fn chunk_contents_for_upload_addresses_preserves_duplicate_requests() {
1599        let repeated = Bytes::from_static(b"same-content");
1600        let other = Bytes::from_static(b"other-content");
1601        let repeated_addr = compute_address(&repeated);
1602
1603        let selected = chunk_contents_for_upload_addresses(
1604            vec![repeated.clone(), other, repeated.clone()],
1605            &[repeated_addr, repeated_addr],
1606        )
1607        .unwrap();
1608
1609        assert_eq!(selected, vec![repeated.clone(), repeated]);
1610    }
1611
1612    #[test]
1613    fn chunk_contents_for_upload_addresses_ignores_unrequested_duplicates() {
1614        let requested = Bytes::from_static(b"requested-content");
1615        let unrequested = Bytes::from_static(b"unrequested-content");
1616        let requested_addr = compute_address(&requested);
1617
1618        let selected = chunk_contents_for_upload_addresses(
1619            vec![
1620                unrequested.clone(),
1621                requested.clone(),
1622                unrequested.clone(),
1623                unrequested,
1624            ],
1625            &[requested_addr],
1626        )
1627        .unwrap();
1628
1629        assert_eq!(selected, vec![requested]);
1630    }
1631
1632    #[test]
1633    fn chunk_contents_for_upload_addresses_errors_for_missing_content() {
1634        let present = Bytes::from_static(b"present-content");
1635        let missing = Bytes::from_static(b"missing-content");
1636        let missing_addr = compute_address(&missing);
1637
1638        let result = chunk_contents_for_upload_addresses(vec![present], &[missing_addr]);
1639
1640        assert!(matches!(result, Err(Error::InvalidData(_))));
1641    }
1642
1643    // =========================================================================
1644    // MerkleTree construction and proof generation (pure, no network)
1645    // =========================================================================
1646
1647    fn make_test_addresses(count: usize) -> Vec<[u8; 32]> {
1648        (0..count)
1649            .map(|i| {
1650                let xn = XorName::from_content(&i.to_le_bytes());
1651                xn.0
1652            })
1653            .collect()
1654    }
1655
1656    #[test]
1657    fn test_tree_depth_for_known_sizes() {
1658        let cases = [(2, 1), (4, 2), (16, 4), (100, 7), (256, 8)];
1659        for (count, expected_depth) in cases {
1660            let addrs = make_test_addresses(count);
1661            let xornames: Vec<XorName> = addrs.iter().map(|a| XorName(*a)).collect();
1662            let tree = MerkleTree::from_xornames(xornames).unwrap();
1663            assert_eq!(
1664                tree.depth(),
1665                expected_depth,
1666                "depth mismatch for {count} leaves"
1667            );
1668        }
1669    }
1670
1671    #[test]
1672    fn test_proof_generation_and_verification_for_all_leaves() {
1673        let addrs = make_test_addresses(16);
1674        let xornames: Vec<XorName> = addrs.iter().map(|a| XorName(*a)).collect();
1675        let tree = MerkleTree::from_xornames(xornames.clone()).unwrap();
1676
1677        for (i, xn) in xornames.iter().enumerate() {
1678            let proof = tree.generate_address_proof(i, *xn).unwrap();
1679            assert!(proof.verify(), "proof for leaf {i} should verify");
1680            assert_eq!(proof.depth(), tree.depth() as usize);
1681        }
1682    }
1683
1684    #[test]
1685    fn test_proof_fails_for_wrong_address() {
1686        let addrs = make_test_addresses(8);
1687        let xornames: Vec<XorName> = addrs.iter().map(|a| XorName(*a)).collect();
1688        let tree = MerkleTree::from_xornames(xornames).unwrap();
1689
1690        let wrong = XorName::from_content(b"wrong");
1691        let proof = tree.generate_address_proof(0, wrong).unwrap();
1692        assert!(!proof.verify(), "proof with wrong address should fail");
1693    }
1694
1695    #[test]
1696    fn test_tree_too_few_leaves() {
1697        let xornames = vec![XorName::from_content(b"only_one")];
1698        let result = MerkleTree::from_xornames(xornames);
1699        assert!(result.is_err());
1700    }
1701
1702    #[test]
1703    fn test_tree_at_max_leaves() {
1704        let addrs = make_test_addresses(MAX_LEAVES);
1705        let xornames: Vec<XorName> = addrs.iter().map(|a| XorName(*a)).collect();
1706        let tree = MerkleTree::from_xornames(xornames).unwrap();
1707        assert_eq!(tree.leaf_count(), MAX_LEAVES);
1708    }
1709
1710    // =========================================================================
1711    // Proof serialization round-trip
1712    // =========================================================================
1713
1714    #[test]
1715    fn test_merkle_proof_serialize_deserialize_roundtrip() {
1716        use ant_protocol::evm::{Amount, MerklePaymentCandidateNode, RewardsAddress};
1717        use ant_protocol::payment::{deserialize_merkle_proof, serialize_merkle_proof};
1718
1719        let addrs = make_test_addresses(4);
1720        let xornames: Vec<XorName> = addrs.iter().map(|a| XorName(*a)).collect();
1721        let tree = MerkleTree::from_xornames(xornames.clone()).unwrap();
1722
1723        let timestamp = std::time::SystemTime::now()
1724            .duration_since(std::time::UNIX_EPOCH)
1725            .unwrap()
1726            .as_secs();
1727
1728        let candidates = tree.reward_candidates(timestamp).unwrap();
1729        let midpoint = candidates.first().unwrap().clone();
1730
1731        // Build candidate nodes (with dummy signatures — not ML-DSA, just for serialization test)
1732        #[allow(clippy::cast_possible_truncation)]
1733        let candidate_nodes: [MerklePaymentCandidateNode; CANDIDATES_PER_POOL] =
1734            std::array::from_fn(|i| MerklePaymentCandidateNode {
1735                pub_key: vec![i as u8; 32],
1736                price: Amount::from(1024u64),
1737                reward_address: RewardsAddress::new([i as u8; 20]),
1738                merkle_payment_timestamp: timestamp,
1739                signature: vec![i as u8; 64],
1740                committed_key_count: 0,
1741                commitment_pin: None,
1742            });
1743
1744        let pool = MerklePaymentCandidatePool {
1745            midpoint_proof: midpoint,
1746            candidate_nodes,
1747        };
1748
1749        let address_proof = tree.generate_address_proof(0, xornames[0]).unwrap();
1750        let merkle_proof = MerklePaymentProof::new(xornames[0], address_proof, pool);
1751
1752        let tagged = serialize_merkle_proof(&merkle_proof).unwrap();
1753        assert_eq!(
1754            tagged.first().copied(),
1755            Some(0x02),
1756            "tag should be PROOF_TAG_MERKLE"
1757        );
1758
1759        let deserialized = deserialize_merkle_proof(&tagged).unwrap();
1760        assert_eq!(deserialized.address, merkle_proof.address);
1761        assert_eq!(
1762            deserialized.winner_pool.candidate_nodes.len(),
1763            CANDIDATES_PER_POOL
1764        );
1765    }
1766
1767    // =========================================================================
1768    // Candidate validation logic
1769    // =========================================================================
1770
1771    #[test]
1772    fn test_candidate_wrong_timestamp_rejected() {
1773        // Simulates what collect_validated_candidates checks
1774        let candidate = MerklePaymentCandidateNode {
1775            pub_key: vec![0u8; 32],
1776            price: ant_protocol::evm::Amount::ZERO,
1777            reward_address: ant_protocol::evm::RewardsAddress::new([0u8; 20]),
1778            merkle_payment_timestamp: 1000,
1779            signature: vec![0u8; 64],
1780            committed_key_count: 0,
1781            commitment_pin: None,
1782        };
1783
1784        // Timestamp check: 1000 != 2000
1785        assert_ne!(candidate.merkle_payment_timestamp, 2000);
1786    }
1787
1788    // =========================================================================
1789    // finalize_merkle_batch (external signer)
1790    // =========================================================================
1791
1792    fn make_dummy_candidate_nodes(
1793        timestamp: u64,
1794    ) -> [MerklePaymentCandidateNode; CANDIDATES_PER_POOL] {
1795        std::array::from_fn(|i| MerklePaymentCandidateNode {
1796            pub_key: vec![i as u8; 32],
1797            price: Amount::from(1024u64),
1798            reward_address: RewardsAddress::new([i as u8; 20]),
1799            merkle_payment_timestamp: timestamp,
1800            signature: vec![i as u8; 64],
1801            committed_key_count: 0,
1802            commitment_pin: None,
1803        })
1804    }
1805
1806    fn make_prepared_merkle_batch(count: usize) -> PreparedMerkleBatch {
1807        let addrs = make_test_addresses(count);
1808        let xornames: Vec<XorName> = addrs.iter().map(|a| XorName(*a)).collect();
1809        let tree = MerkleTree::from_xornames(xornames).unwrap();
1810
1811        let timestamp = std::time::SystemTime::now()
1812            .duration_since(std::time::UNIX_EPOCH)
1813            .unwrap()
1814            .as_secs();
1815
1816        let midpoints = tree.reward_candidates(timestamp).unwrap();
1817
1818        let candidate_pools: Vec<MerklePaymentCandidatePool> = midpoints
1819            .into_iter()
1820            .map(|mp| MerklePaymentCandidatePool {
1821                midpoint_proof: mp,
1822                candidate_nodes: make_dummy_candidate_nodes(timestamp),
1823            })
1824            .collect();
1825
1826        let pool_commitments = candidate_pools
1827            .iter()
1828            .map(MerklePaymentCandidatePool::to_commitment)
1829            .collect();
1830
1831        PreparedMerkleBatch {
1832            depth: tree.depth(),
1833            pool_commitments,
1834            merkle_payment_timestamp: timestamp,
1835            candidate_pools,
1836            tree,
1837            addresses: addrs,
1838        }
1839    }
1840
1841    #[test]
1842    fn test_finalize_merkle_batch_with_valid_winner() {
1843        let prepared = make_prepared_merkle_batch(4);
1844        let winner_hash = prepared.candidate_pools[0].hash();
1845
1846        let result = finalize_merkle_batch(prepared, winner_hash);
1847        assert!(
1848            result.is_ok(),
1849            "should succeed with valid winner: {result:?}"
1850        );
1851
1852        let batch = result.unwrap();
1853        assert_eq!(batch.chunk_count, 4);
1854        assert_eq!(batch.proofs.len(), 4);
1855
1856        // Every proof should be non-empty
1857        for proof_bytes in batch.proofs.values() {
1858            assert!(!proof_bytes.is_empty());
1859        }
1860    }
1861
1862    /// DEV-01 regression: per-chunk merkle proofs must never ship commitment
1863    /// sidecars — all 16 winner-pool sidecars (~214 KB serialized) copied into
1864    /// every chunk's proof pushed it past the storer's payment-proof size cap,
1865    /// rejecting every merkle PUT once nodes carried live commitments. The
1866    /// e2e (`adr0004_merkle_upload_against_bound_candidates`) proves the flow
1867    /// end-to-end; this pins the wire invariant directly so it cannot slip
1868    /// back in behind a raised node-side cap.
1869    #[test]
1870    fn test_finalize_merkle_batch_ships_no_commitment_sidecars() {
1871        use ant_protocol::payment::deserialize_merkle_proof;
1872
1873        let mut prepared = make_prepared_merkle_batch(4);
1874        // Bind every candidate to a pin, mirroring a network where all nodes
1875        // carry live commitments (the DEV-01 trigger state).
1876        for pool in &mut prepared.candidate_pools {
1877            for candidate in &mut pool.candidate_nodes {
1878                candidate.committed_key_count = 9_000;
1879                candidate.commitment_pin = Some([7u8; 32]);
1880            }
1881        }
1882        let winner_hash = prepared.candidate_pools[0].hash();
1883
1884        let batch = finalize_merkle_batch(prepared, winner_hash).unwrap();
1885        assert_eq!(batch.proofs.len(), 4);
1886        for proof_bytes in batch.proofs.values() {
1887            let proof = deserialize_merkle_proof(proof_bytes).unwrap();
1888            assert!(
1889                proof.commitment_sidecars.is_empty(),
1890                "per-chunk merkle proofs must not ship commitment sidecars"
1891            );
1892        }
1893    }
1894
1895    #[test]
1896    fn test_finalize_merkle_batch_with_invalid_winner() {
1897        let prepared = make_prepared_merkle_batch(4);
1898        let bad_hash = [0xFF; 32];
1899
1900        let result = finalize_merkle_batch(prepared, bad_hash);
1901        assert!(result.is_err());
1902        let err = result.unwrap_err().to_string();
1903        assert!(err.contains("not found in candidate pools"), "got: {err}");
1904    }
1905
1906    #[test]
1907    fn test_finalize_merkle_batch_proofs_are_deserializable() {
1908        use ant_protocol::payment::deserialize_merkle_proof;
1909
1910        let prepared = make_prepared_merkle_batch(8);
1911        let winner_hash = prepared.candidate_pools[0].hash();
1912
1913        let batch = finalize_merkle_batch(prepared, winner_hash).unwrap();
1914
1915        for (addr, proof_bytes) in &batch.proofs {
1916            let proof = deserialize_merkle_proof(proof_bytes);
1917            assert!(
1918                proof.is_ok(),
1919                "proof for {} should deserialize: {:?}",
1920                hex::encode(addr),
1921                proof.err()
1922            );
1923        }
1924    }
1925
1926    // =========================================================================
1927    // Batch splitting edge cases
1928    // =========================================================================
1929
1930    #[test]
1931    fn test_batch_split_calculation() {
1932        // MAX_LEAVES chunks should fit in 1 batch
1933        let addrs = make_test_addresses(MAX_LEAVES);
1934        assert_eq!(addrs.chunks(MAX_LEAVES).count(), 1);
1935
1936        // MAX_LEAVES + 1 should split into 2
1937        let addrs = make_test_addresses(MAX_LEAVES + 1);
1938        assert_eq!(addrs.chunks(MAX_LEAVES).count(), 2);
1939
1940        // 3 * MAX_LEAVES should split into 3
1941        let addrs = make_test_addresses(3 * MAX_LEAVES);
1942        assert_eq!(addrs.chunks(MAX_LEAVES).count(), 3);
1943    }
1944
1945    // =========================================================================
1946    // merkle_store_with_retry: collect-not-abort + bounded retry (C2.1 / C2.2)
1947    // =========================================================================
1948
1949    use std::sync::{Arc, Mutex};
1950
1951    /// Build `count` chunk addresses for the store helper. Bodies are read on
1952    /// demand by `store_one`, so the tests pass addresses only.
1953    fn make_addrs(count: usize) -> Vec<[u8; 32]> {
1954        make_test_addresses(count)
1955    }
1956
1957    /// C2.1: a per-chunk `InsufficientPeers` is collected, not propagated —
1958    /// the whole batch must NOT abort. With a single attempt, the failing
1959    /// subset is reported via `failed` and the rest are `stored`.
1960    #[tokio::test]
1961    async fn store_with_retry_collects_failures_instead_of_aborting() {
1962        let chunks = make_addrs(6);
1963        let failing: std::collections::HashSet<[u8; 32]> = chunks.iter().take(2).copied().collect();
1964        let failing_for_closure = failing.clone();
1965
1966        let store_one = move |addr: [u8; 32]| {
1967            let fail = failing_for_closure.contains(&addr);
1968            async move {
1969                if fail {
1970                    Err(Error::InsufficientPeers("test shortfall".into()))
1971                } else {
1972                    Ok(std::time::Instant::now())
1973                }
1974            }
1975        };
1976
1977        let outcome =
1978            merkle_store_with_retry(chunks, || 8, 1, Duration::ZERO, None, 0, 6, store_one)
1979                .await
1980                .expect("quorum shortfalls must not abort the batch");
1981
1982        assert_eq!(outcome.stored, 4);
1983        assert_eq!(outcome.failed, 2);
1984        // Single attempt → all successes recorded in round 0.
1985        assert_eq!(outcome.stats.retries_histogram[0], 4);
1986        assert_eq!(outcome.stats.chunk_attempts_total, 6);
1987    }
1988
1989    /// V2-554: the store scheduler must RE-READ the cap as each slot frees
1990    /// (rolling), not snapshot it once like `buffer_unordered`. A snapshot would
1991    /// invoke the cap closure once per attempt; the rolling scheduler invokes it
1992    /// once per drained slot, so mid-flight limiter growth reaches the rest of
1993    /// the round. Proven here by counting cap-closure invocations.
1994    #[tokio::test]
1995    async fn store_with_retry_rereads_cap_per_slot() {
1996        let count = 6;
1997        let chunks = make_addrs(count);
1998        let cap_calls = Arc::new(Mutex::new(0usize));
1999        let cap_calls_for_closure = cap_calls.clone();
2000        let cap = move || {
2001            *cap_calls_for_closure.lock().expect("cap counter poisoned") += 1;
2002            2
2003        };
2004        let store_one = move |_addr: [u8; 32]| async move { Ok(std::time::Instant::now()) };
2005
2006        let outcome =
2007            merkle_store_with_retry(chunks, cap, 1, Duration::ZERO, None, 0, count, store_one)
2008                .await
2009                .expect("all stores succeed");
2010
2011        assert_eq!(outcome.stored, count);
2012        let calls = *cap_calls.lock().expect("cap counter poisoned");
2013        assert!(
2014            calls >= count,
2015            "cap must be re-read per drained slot (rolling), not snapshotted once — \
2016             expected >= {count} invocations, got {calls}",
2017        );
2018    }
2019
2020    /// The whole-file store pass is a single cap-bounded fan-out with NO wave
2021    /// barrier: one slow straggler (a chunk whose peers take a long time) must
2022    /// not block the rest of the file, and must be driven to completion
2023    /// concurrently by the fast chunks. If the scheduler serialized (a barrier),
2024    /// the fast chunks could not run until the straggler returned — a deadlock
2025    /// the surrounding timeout would catch.
2026    #[tokio::test]
2027    async fn store_pass_has_no_barrier() {
2028        use std::sync::atomic::{AtomicUsize, Ordering};
2029        let count = 8;
2030        let addrs = make_addrs(count);
2031        let slow = addrs[0];
2032        let fast_completed = Arc::new(AtomicUsize::new(0));
2033        let release_slow = Arc::new(tokio::sync::Notify::new());
2034
2035        let store_one = move |addr: [u8; 32]| {
2036            let fast_completed = fast_completed.clone();
2037            let release_slow = release_slow.clone();
2038            async move {
2039                if addr == slow {
2040                    // Block until every fast chunk has finished. This can only
2041                    // resolve if the fast chunks run WHILE this one is parked —
2042                    // i.e. there is no barrier serializing the store pass.
2043                    release_slow.notified().await;
2044                } else if fast_completed.fetch_add(1, Ordering::SeqCst) + 1 == count - 1 {
2045                    release_slow.notify_one();
2046                }
2047                Ok(std::time::Instant::now())
2048            }
2049        };
2050
2051        let outcome = tokio::time::timeout(
2052            Duration::from_secs(5),
2053            merkle_store_with_retry(addrs, || 8, 1, Duration::ZERO, None, 0, count, store_one),
2054        )
2055        .await
2056        .expect("store pass must not deadlock — a slow chunk must not block the others")
2057        .expect("all stores succeed");
2058
2059        assert_eq!(outcome.stored, count);
2060    }
2061
2062    /// Peak memory is bounded by the store cap, not the file size: `store_one`
2063    /// reads each body on demand, so the scheduler holds at most `cap` stores
2064    /// (hence at most `cap` bodies) in flight at once — the property that lets
2065    /// the whole file store as one fan-out without the old 64-chunk waves.
2066    #[tokio::test]
2067    async fn store_pass_keeps_at_most_cap_in_flight() {
2068        use std::sync::atomic::{AtomicUsize, Ordering};
2069        let count = 40;
2070        let cap = 4;
2071        let addrs = make_addrs(count);
2072        let in_flight = Arc::new(AtomicUsize::new(0));
2073        let max_in_flight = Arc::new(AtomicUsize::new(0));
2074        let max_in_flight_for_closure = max_in_flight.clone();
2075
2076        let store_one = move |_addr: [u8; 32]| {
2077            let in_flight = in_flight.clone();
2078            let max_in_flight = max_in_flight_for_closure.clone();
2079            async move {
2080                let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
2081                max_in_flight.fetch_max(now, Ordering::SeqCst);
2082                // Yield so sibling stores get a chance to start concurrently,
2083                // maximising the observed in-flight count.
2084                tokio::task::yield_now().await;
2085                in_flight.fetch_sub(1, Ordering::SeqCst);
2086                Ok(std::time::Instant::now())
2087            }
2088        };
2089
2090        let outcome = merkle_store_with_retry(
2091            addrs,
2092            move || cap,
2093            1,
2094            Duration::ZERO,
2095            None,
2096            0,
2097            count,
2098            store_one,
2099        )
2100        .await
2101        .expect("all stores succeed");
2102
2103        assert_eq!(outcome.stored, count);
2104        let peak = max_in_flight.load(Ordering::SeqCst);
2105        assert!(
2106            peak <= cap,
2107            "at most `cap` bodies may be in flight (memory bound), got peak {peak} > cap {cap}",
2108        );
2109        assert!(
2110            peak > 1,
2111            "the pass must actually run concurrently, not serialize (peak {peak})",
2112        );
2113    }
2114
2115    /// V2-468: an app-only quorum shortfall surfaces as `Error::RemotePut`
2116    /// (pool-rejected / quote-stale / disk-full — transient), which must be
2117    /// treated as recoverable just like `InsufficientPeers`: collected and
2118    /// retried, never aborting the whole batch.
2119    #[tokio::test]
2120    async fn store_with_retry_treats_remote_put_as_recoverable() {
2121        let chunks = make_addrs(6);
2122        let failing: std::collections::HashSet<[u8; 32]> = chunks.iter().take(2).copied().collect();
2123        let failing_for_closure = failing.clone();
2124
2125        let store_one = move |addr: [u8; 32]| {
2126            let fail = failing_for_closure.contains(&addr);
2127            async move {
2128                if fail {
2129                    Err(Error::RemotePut {
2130                        address: hex::encode(addr),
2131                        source: ant_protocol::ProtocolError::StorageFailed(
2132                            "insufficient disk space".into(),
2133                        ),
2134                    })
2135                } else {
2136                    Ok(std::time::Instant::now())
2137                }
2138            }
2139        };
2140
2141        let outcome =
2142            merkle_store_with_retry(chunks, || 8, 1, Duration::ZERO, None, 0, 6, store_one)
2143                .await
2144                .expect("remote app-rejections must not abort the batch");
2145
2146        assert_eq!(outcome.stored, 4);
2147        assert_eq!(outcome.failed, 2);
2148    }
2149
2150    /// A non-quorum error (e.g. a missing proof) is captured in `fatal` rather
2151    /// than discarded — the call returns `Ok(outcome)` so the caller can decide
2152    /// whether to re-raise it or fold it into `PartialUpload`.
2153    #[tokio::test]
2154    async fn store_with_retry_reports_non_quorum_errors_as_fatal() {
2155        let chunks = make_addrs(3);
2156        let store_one = |_addr: [u8; 32]| async move {
2157            Err::<std::time::Instant, _>(Error::Payment("missing proof".into()))
2158        };
2159
2160        let outcome =
2161            merkle_store_with_retry(chunks, || 8, 3, Duration::ZERO, None, 0, 3, store_one)
2162                .await
2163                .expect("fatal is carried in the outcome, not returned as Err");
2164        assert!(matches!(outcome.fatal, Some(Error::Payment(_))));
2165    }
2166
2167    /// A fatal error mid-pass preserves the successes that already completed in
2168    /// the same pass — they are not discarded with the abort. Concurrency 1
2169    /// makes ordering deterministic: the first five chunks store, then the sixth
2170    /// aborts fatally.
2171    #[tokio::test]
2172    async fn store_with_retry_fatal_preserves_same_pass_successes() {
2173        let chunks = make_addrs(6);
2174        let bad = chunks[5];
2175        let store_one = move |addr: [u8; 32]| async move {
2176            if addr == bad {
2177                Err(Error::Payment("fatal".into()))
2178            } else {
2179                Ok(std::time::Instant::now())
2180            }
2181        };
2182
2183        let outcome =
2184            merkle_store_with_retry(chunks, || 1, 1, Duration::ZERO, None, 0, 6, store_one)
2185                .await
2186                .expect("fatal carried in outcome, not returned as Err");
2187        assert!(matches!(outcome.fatal, Some(Error::Payment(_))));
2188        // The five chunks stored before the abort are preserved, not lost.
2189        assert_eq!(outcome.stored, 5);
2190        assert_eq!(outcome.stored_addresses.len(), 5);
2191        assert!(!outcome.stored_addresses.contains(&bad));
2192        // The fatal chunk is reported as failed (not silently dropped).
2193        assert!(outcome.failed_addresses.iter().any(|(a, _)| *a == bad));
2194    }
2195
2196    /// C2.2: only the chunks that failed the previous round are retried.
2197    #[tokio::test]
2198    async fn store_with_retry_retries_only_the_failed_set() {
2199        let chunks = make_addrs(5);
2200        let total = chunks.len();
2201        let failing: std::collections::HashSet<[u8; 32]> = chunks.iter().take(2).copied().collect();
2202        let failing_for_closure = failing.clone();
2203
2204        // Record every (addr) the store op was invoked with, in call order.
2205        let calls = Arc::new(Mutex::new(Vec::<[u8; 32]>::new()));
2206        let calls_for_closure = calls.clone();
2207
2208        let store_one = move |addr: [u8; 32]| {
2209            let calls = calls_for_closure.clone();
2210            // Fails the first round only; succeeds thereafter.
2211            let already_seen = calls.lock().unwrap().iter().filter(|&&a| a == addr).count();
2212            let fail = failing_for_closure.contains(&addr) && already_seen == 0;
2213            calls.lock().unwrap().push(addr);
2214            async move {
2215                if fail {
2216                    Err(Error::InsufficientPeers("round-1 shortfall".into()))
2217                } else {
2218                    Ok(std::time::Instant::now())
2219                }
2220            }
2221        };
2222
2223        let outcome =
2224            merkle_store_with_retry(chunks, || 8, 3, Duration::ZERO, None, 0, total, store_one)
2225                .await
2226                .expect("should converge after retry");
2227
2228        assert_eq!(outcome.stored, total);
2229        assert_eq!(outcome.failed, 0);
2230
2231        // Round 1 drains fully before round 2 starts, so the call log is
2232        // segmented: first `total` calls = round 1 (all chunks), the rest =
2233        // the retry round, which must contain ONLY the failing set.
2234        let calls = calls.lock().unwrap();
2235        assert_eq!(calls.len(), total + failing.len());
2236        let round_two: std::collections::HashSet<[u8; 32]> =
2237            calls[total..].iter().copied().collect();
2238        assert_eq!(round_two, failing);
2239    }
2240
2241    /// C2.2: a chunk that fails attempt 1 and succeeds attempt 2 is counted
2242    /// once as stored and recorded as one retry in `retries_histogram[1]`.
2243    #[tokio::test]
2244    async fn store_with_retry_counts_retry_success_once_in_histogram() {
2245        let chunks = make_addrs(4);
2246        let total = chunks.len();
2247        let flaky_addr = chunks[0];
2248
2249        let attempts = Arc::new(Mutex::new(HashMap::<[u8; 32], usize>::new()));
2250        let attempts_for_closure = attempts.clone();
2251
2252        let store_one = move |addr: [u8; 32]| {
2253            let attempts = attempts_for_closure.clone();
2254            let n = {
2255                let mut m = attempts.lock().unwrap();
2256                let entry = m.entry(addr).or_insert(0);
2257                *entry += 1;
2258                *entry
2259            };
2260            let fail = addr == flaky_addr && n == 1;
2261            async move {
2262                if fail {
2263                    Err(Error::InsufficientPeers("transient".into()))
2264                } else {
2265                    Ok(std::time::Instant::now())
2266                }
2267            }
2268        };
2269
2270        let outcome =
2271            merkle_store_with_retry(chunks, || 8, 3, Duration::ZERO, None, 0, total, store_one)
2272                .await
2273                .expect("flaky chunk should recover on retry");
2274
2275        assert_eq!(outcome.stored, total);
2276        assert_eq!(outcome.failed, 0);
2277        // 3 chunks landed on the first attempt, 1 on the first retry.
2278        assert_eq!(outcome.stats.retries_histogram[0], total - 1);
2279        assert_eq!(outcome.stats.retries_histogram[1], 1);
2280        // One extra store attempt for the flaky chunk.
2281        assert_eq!(outcome.stats.chunk_attempts_total, total + 1);
2282    }
2283
2284    /// C2.2: when every chunk stays short of quorum through the whole attempt
2285    /// budget, the helper still returns `Ok` (collect-not-abort) with the full
2286    /// batch reported as `failed`, having tried each chunk exactly
2287    /// `MERKLE_STORE_MAX_ATTEMPTS` times.
2288    #[tokio::test]
2289    async fn store_with_retry_reports_all_failed_when_retries_exhausted() {
2290        let chunks = make_addrs(3);
2291        let total = chunks.len();
2292
2293        let store_one = |_addr: [u8; 32]| async move {
2294            Err::<std::time::Instant, _>(Error::InsufficientPeers("never converges".into()))
2295        };
2296
2297        let outcome = merkle_store_with_retry(
2298            chunks,
2299            || 8,
2300            MERKLE_STORE_MAX_ATTEMPTS,
2301            Duration::ZERO,
2302            None,
2303            0,
2304            total,
2305            store_one,
2306        )
2307        .await
2308        .expect("an exhausted retry budget is reported, not propagated as Err");
2309
2310        assert_eq!(outcome.stored, 0);
2311        assert_eq!(outcome.failed, total);
2312        // Every chunk was attempted once per round across the full budget.
2313        assert_eq!(
2314            outcome.stats.chunk_attempts_total,
2315            total * MERKLE_STORE_MAX_ATTEMPTS
2316        );
2317        // No successes, so the histogram stays empty.
2318        assert_eq!(outcome.stats.retries_histogram, [0; 4]);
2319    }
2320
2321    /// D (CLI path): when retries are exhausted, `failed_addresses` names
2322    /// exactly the still-short-of-quorum chunks (with their last error message)
2323    /// and excludes the ones that stored. This is what `upload_merkle_from_spill`
2324    /// uses to build `PartialUpload`.
2325    #[tokio::test]
2326    async fn store_with_retry_records_failed_addresses_when_exhausted() {
2327        let chunks = make_addrs(6);
2328        let failing: std::collections::HashSet<[u8; 32]> = chunks.iter().take(2).copied().collect();
2329        let failing_for_closure = failing.clone();
2330
2331        let store_one = move |addr: [u8; 32]| {
2332            let fail = failing_for_closure.contains(&addr);
2333            async move {
2334                if fail {
2335                    Err(Error::InsufficientPeers("permanent shortfall".into()))
2336                } else {
2337                    Ok(std::time::Instant::now())
2338                }
2339            }
2340        };
2341
2342        let outcome = merkle_store_with_retry(
2343            chunks,
2344            || 8,
2345            MERKLE_STORE_MAX_ATTEMPTS,
2346            Duration::ZERO,
2347            None,
2348            0,
2349            6,
2350            store_one,
2351        )
2352        .await
2353        .expect("quorum shortfalls must not abort the batch");
2354
2355        assert_eq!(outcome.stored, 4);
2356        assert_eq!(outcome.failed, 2);
2357        // `failed_addresses` names exactly the failing set, no stored chunks.
2358        assert_eq!(outcome.failed_addresses.len(), 2);
2359        let reported: std::collections::HashSet<[u8; 32]> =
2360            outcome.failed_addresses.iter().map(|(a, _)| *a).collect();
2361        assert_eq!(reported, failing);
2362        // Each carries a non-empty error message for the PartialUpload report.
2363        for (_, msg) in &outcome.failed_addresses {
2364            assert!(msg.contains("permanent shortfall"));
2365        }
2366    }
2367
2368    /// `failed_addresses` is empty when every chunk reaches quorum (no
2369    /// `PartialUpload` is raised by the CLI path in that case).
2370    #[tokio::test]
2371    async fn store_with_retry_failed_addresses_empty_on_full_success() {
2372        let chunks = make_addrs(4);
2373        let total = chunks.len();
2374        let store_one = |_addr: [u8; 32]| async move { Ok(std::time::Instant::now()) };
2375
2376        let outcome = merkle_store_with_retry(
2377            chunks,
2378            || 8,
2379            MERKLE_STORE_MAX_ATTEMPTS,
2380            Duration::ZERO,
2381            None,
2382            0,
2383            total,
2384            store_one,
2385        )
2386        .await
2387        .expect("all chunks store");
2388
2389        assert_eq!(outcome.stored, total);
2390        assert_eq!(outcome.failed, 0);
2391        assert!(outcome.failed_addresses.is_empty());
2392    }
2393
2394    // =========================================================================
2395    // merkle_deferred_retry: download-style concurrent post-wave retry (V2-466)
2396    // =========================================================================
2397
2398    /// The histogram slot mapping: the wave first pass is slot 0; deferred
2399    /// round `r` is slot `r + 1`, clamped to the last slot.
2400    #[test]
2401    fn deferred_round_histogram_slot_maps_and_clamps() {
2402        assert_eq!(deferred_round_histogram_slot(0, 4), 1);
2403        assert_eq!(deferred_round_histogram_slot(1, 4), 2);
2404        assert_eq!(deferred_round_histogram_slot(2, 4), 3);
2405        // Beyond the histogram width, clamp to the final slot.
2406        assert_eq!(deferred_round_histogram_slot(3, 4), 3);
2407        assert_eq!(deferred_round_histogram_slot(9, 4), 3);
2408    }
2409
2410    fn deferred_set(count: usize) -> Vec<([u8; 32], String)> {
2411        make_test_addresses(count)
2412            .into_iter()
2413            .map(|addr| (addr, "short of quorum".to_string()))
2414            .collect()
2415    }
2416
2417    /// A chunk that is quorum-short on early rounds but succeeds on a later
2418    /// round is stored exactly once, recorded in that round's histogram slot,
2419    /// and reported with no failures.
2420    #[tokio::test]
2421    async fn deferred_retry_succeeds_on_a_later_round() {
2422        let deferred = deferred_set(3);
2423        // Each chunk fails its first attempt (round 0) and succeeds the second
2424        // (round 1 → histogram slot 2).
2425        let attempts = Arc::new(Mutex::new(HashMap::<[u8; 32], usize>::new()));
2426        let attempts_for_closure = attempts.clone();
2427        let store_one = move |addr: [u8; 32]| {
2428            let attempts = attempts_for_closure.clone();
2429            async move {
2430                let n = {
2431                    let mut map = attempts.lock().unwrap();
2432                    let e = map.entry(addr).or_insert(0);
2433                    *e += 1;
2434                    *e
2435                };
2436                if n < 2 {
2437                    Err(Error::InsufficientPeers("still short".into()))
2438                } else {
2439                    Ok(std::time::Instant::now())
2440                }
2441            }
2442        };
2443
2444        let outcome = merkle_deferred_retry(
2445            deferred,
2446            &[0, 0, 0],
2447            |n: usize| n.max(1),
2448            None,
2449            0,
2450            3,
2451            store_one,
2452        )
2453        .await
2454        .expect("deferred retry must not abort on quorum shortfalls");
2455
2456        assert_eq!(outcome.stored, 3, "all three land by round 1");
2457        assert_eq!(outcome.stored_addresses.len(), 3);
2458        assert_eq!(outcome.failed, 0);
2459        assert!(outcome.failed_addresses.is_empty());
2460        assert!(outcome.fatal.is_none());
2461        // Round 1 → slot 2; round 0 (slot 1) saw zero successes.
2462        assert_eq!(outcome.stats.retries_histogram[1], 0);
2463        assert_eq!(outcome.stats.retries_histogram[2], 3);
2464        // Each chunk attempted twice: one failed round + one success round.
2465        assert_eq!(outcome.stats.chunk_attempts_total, 6);
2466    }
2467
2468    /// Chunks still short of quorum after the final deferred round become
2469    /// `failed`, not silently dropped, and no fatal error is set.
2470    #[tokio::test]
2471    async fn deferred_retry_leftovers_become_failed() {
2472        let deferred = deferred_set(2);
2473        let store_one = |_addr: [u8; 32]| async move {
2474            Err::<std::time::Instant, _>(Error::InsufficientPeers("always short".into()))
2475        };
2476
2477        let outcome = merkle_deferred_retry(
2478            deferred,
2479            &[0, 0, 0],
2480            |n: usize| n.max(1),
2481            None,
2482            0,
2483            2,
2484            store_one,
2485        )
2486        .await
2487        .expect("exhausted retries report failures, not an error");
2488
2489        assert_eq!(outcome.stored, 0);
2490        assert!(outcome.stored_addresses.is_empty());
2491        assert_eq!(outcome.failed, 2);
2492        assert_eq!(outcome.failed_addresses.len(), 2);
2493        assert!(outcome.fatal.is_none());
2494        // Three rounds × two chunks, all failing.
2495        assert_eq!(outcome.stats.chunk_attempts_total, 6);
2496    }
2497
2498    /// A non-quorum (fatal) error during a deferred round stops the pass, is
2499    /// surfaced via `fatal`, and preserves an earlier round's success in
2500    /// `stored`/`stored_addresses` while the still-pending chunk is reported as
2501    /// failed.
2502    #[tokio::test]
2503    async fn deferred_retry_fatal_error_preserves_prior_progress() {
2504        let addrs = make_test_addresses(2);
2505        let good = addrs[0];
2506        let bad = addrs[1];
2507        let deferred = vec![(good, "short".to_string()), (bad, "short".to_string())];
2508
2509        // `good` succeeds on round 0; `bad` is quorum-short on round 0, then
2510        // hits a fatal Payment error on round 1.
2511        let attempts = Arc::new(Mutex::new(HashMap::<[u8; 32], usize>::new()));
2512        let attempts_for_closure = attempts.clone();
2513        let store_one = move |addr: [u8; 32]| {
2514            let attempts = attempts_for_closure.clone();
2515            async move {
2516                let n = {
2517                    let mut map = attempts.lock().unwrap();
2518                    let e = map.entry(addr).or_insert(0);
2519                    *e += 1;
2520                    *e
2521                };
2522                if addr == good {
2523                    Ok(std::time::Instant::now())
2524                } else if n == 1 {
2525                    Err(Error::InsufficientPeers("short".into()))
2526                } else {
2527                    Err(Error::Payment("fatal on retry".into()))
2528                }
2529            }
2530        };
2531
2532        let outcome = merkle_deferred_retry(
2533            deferred,
2534            &[0, 0, 0],
2535            |n: usize| n.max(1),
2536            None,
2537            0,
2538            2,
2539            store_one,
2540        )
2541        .await
2542        .expect("a fatal round error is reported via `fatal`, not as Err");
2543
2544        assert!(outcome.fatal.is_some(), "fatal error must be captured");
2545        assert_eq!(outcome.stored, 1, "round-0 success preserved");
2546        assert_eq!(outcome.stored_addresses, vec![good]);
2547        assert_eq!(outcome.failed, 1);
2548        assert_eq!(outcome.failed_addresses.len(), 1);
2549        assert_eq!(outcome.failed_addresses[0].0, bad);
2550    }
2551
2552    /// An empty deferred set is a no-op: no rounds run, nothing stored or failed.
2553    #[tokio::test]
2554    async fn deferred_retry_empty_set_is_a_noop() {
2555        let store_one = |_addr: [u8; 32]| async move {
2556            Err::<std::time::Instant, _>(Error::InsufficientPeers("unused".into()))
2557        };
2558
2559        let outcome = merkle_deferred_retry(
2560            Vec::new(),
2561            &DEFERRED_ROUND_DELAYS_SECS,
2562            |n: usize| n.max(1),
2563            None,
2564            7,
2565            7,
2566            store_one,
2567        )
2568        .await
2569        .expect("empty deferred set is a no-op");
2570
2571        assert_eq!(outcome.stored, 7, "stored_offset carried through unchanged");
2572        assert_eq!(outcome.failed, 0);
2573        assert!(outcome.stored_addresses.is_empty());
2574        assert!(outcome.failed_addresses.is_empty());
2575        assert!(outcome.fatal.is_none());
2576    }
2577}