Skip to main content

ant_core/data/client/
upload_state.rs

1// Copyright 2026 MaidSafe.net limited.
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Portable prepared/paid upload state. Storage and wallet adapters own persistence and I/O.
5
6use super::batch::{
7    build_plan_proof, proof_is_safely_fresh, ChunkPaymentPlan, PaidChunk, PreparedChunk,
8};
9use crate::data::error::{Error, Result};
10use ant_protocol::{
11    evm::{QuoteHash, TxHash},
12    payment::deserialize_proof,
13    XorName,
14};
15use serde::{Deserialize, Serialize};
16use std::{
17    collections::HashMap,
18    time::{Duration, SystemTime},
19};
20
21/// A payment may have reached the wallet or chain. Never submit it again merely
22/// because receipt parsing or observation failed.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct PaymentAttempt {
25    /// True for a prepared Merkle transaction.
26    pub merkle: bool,
27    /// Records bound to the prepared single-payment intent.
28    pub addresses: Vec<XorName>,
29    /// Wallet submission evidence retained before awaiting confirmation.
30    #[serde(default)]
31    pub submissions: Vec<serde_json::Value>,
32    /// Unparsed wallet result, retained even if validation later fails.
33    pub receipt: Option<serde_json::Value>,
34}
35
36/// Evidence retained when an explicit wallet verifier establishes terminal failure.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38struct FailedPaymentAttempt {
39    attempt: PaymentAttempt,
40    resolution: serde_json::Value,
41}
42
43/// Prepared plans and confirmed proofs keyed by content address.
44/// Checkpoints contain no file bytes or wallet secrets.
45#[derive(Debug, Default, Clone, Serialize, Deserialize)]
46pub struct UploadState {
47    plans: HashMap<XorName, ChunkPaymentPlan>,
48    proofs: HashMap<XorName, Vec<u8>>,
49    #[serde(default)]
50    pub(crate) pending_merkle: Option<super::merkle::PreparedMerkleBatch>,
51    /// Submission journal; an unresolved attempt must be reconciled before another payment.
52    #[serde(default)]
53    pub pending_payment: Option<PaymentAttempt>,
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    failed_payments: Vec<FailedPaymentAttempt>,
56}
57
58/// Native proof expiry, shared by every client adapter.
59pub fn reusable_proof(address: &XorName, bytes: &[u8], now: SystemTime) -> bool {
60    if let Ok(proof) = ant_protocol::payment::deserialize_merkle_proof(bytes) {
61        return proof.address.0 == *address
62            && proof.data_proof.verify()
63            && proof.data_proof.root() == proof.winner_pool.midpoint_proof.root()
64            && merkle_fresh(
65                proof.winner_pool.midpoint_proof.merkle_payment_timestamp,
66                now,
67            );
68    }
69    let Ok((proof, _)) = deserialize_proof(bytes) else {
70        return false;
71    };
72    !proof.peer_quotes.is_empty()
73        && proof
74            .peer_quotes
75            .iter()
76            .all(|(_, quote)| quote.content.0 == *address)
77        && proof_is_safely_fresh(
78            &proof,
79            now,
80            Duration::from_secs(
81                super::batch::CACHED_PROOF_MAX_AGE_SECS
82                    - super::batch::CACHED_PROOF_SAFETY_MARGIN_SECS,
83            ),
84        )
85}
86
87pub(crate) fn merkle_fresh(timestamp: u64, now: SystemTime) -> bool {
88    now.duration_since(std::time::UNIX_EPOCH)
89        .ok()
90        .is_some_and(|now| {
91            timestamp <= now.as_secs()
92                && now.as_secs() - timestamp < ant_protocol::evm::MERKLE_PAYMENT_EXPIRATION
93        })
94}
95
96impl UploadState {
97    #[cfg(all(target_arch = "wasm32", feature = "browser-wasm"))]
98    pub(crate) fn record_failed_payment(&mut self, resolution: serde_json::Value) -> Result<()> {
99        let attempt = self
100            .pending_payment
101            .take()
102            .ok_or_else(|| Error::Payment("no pending payment".into()))?;
103        self.failed_payments.push(FailedPaymentAttempt {
104            attempt,
105            resolution,
106        });
107        Ok(())
108    }
109
110    pub(crate) fn start_payment(&mut self, merkle: bool, addresses: Vec<XorName>) -> Result<()> {
111        if self.pending_payment.is_some() {
112            return Err(Error::Payment(
113                "payment outcome unknown; reconcile before submitting again".into(),
114            ));
115        }
116        self.pending_payment = Some(PaymentAttempt {
117            merkle,
118            addresses,
119            submissions: Vec::new(),
120            receipt: None,
121        });
122        Ok(())
123    }
124
125    pub(crate) fn pending_plans(&self) -> Result<Vec<ChunkPaymentPlan>> {
126        let attempt = self
127            .pending_payment
128            .as_ref()
129            .ok_or_else(|| Error::Payment("no pending payment".into()))?;
130        attempt
131            .addresses
132            .iter()
133            .map(|address| {
134                self.plans.get(address).cloned().ok_or_else(|| {
135                    Error::InvalidData("pending payment is missing its prepared plan".into())
136                })
137            })
138            .collect()
139    }
140
141    pub(crate) fn insert_merkle(&mut self, result: super::merkle::MerkleBatchPaymentResult) {
142        self.proofs.extend(result.proofs);
143        self.pending_merkle = None;
144        self.pending_payment = None;
145    }
146    pub(crate) fn proof(&self, address: &XorName) -> Option<&Vec<u8>> {
147        self.proofs.get(address)
148    }
149
150    #[cfg(feature = "native")]
151    pub(crate) fn proofs(&self) -> &HashMap<XorName, Vec<u8>> {
152        &self.proofs
153    }
154
155    /// Confirm a native prepared chunk through the same state transition as browser checkpoints.
156    pub(super) fn pay_prepared(
157        chunk: PreparedChunk,
158        transactions: &HashMap<QuoteHash, TxHash>,
159    ) -> Result<PaidChunk> {
160        let mut state = Self::default();
161        state.prepare(ChunkPaymentPlan {
162            address: chunk.address,
163            data_size: chunk.content.len() as u64,
164            quoted_peers: chunk.quoted_peers.clone(),
165            payment: chunk.payment,
166            peer_quotes: chunk.peer_quotes,
167            commitment_sidecars: chunk.commitment_sidecars,
168        });
169        state.confirm(
170            &[chunk.address],
171            transactions,
172            crate::runtime::system_time(),
173        )?;
174        let proof_bytes = state
175            .proofs
176            .remove(&chunk.address)
177            .ok_or_else(|| Error::Payment("confirmed proof missing".into()))?;
178        Ok(PaidChunk {
179            content: chunk.content,
180            address: chunk.address,
181            quoted_peers: chunk.quoted_peers,
182            proof_bytes,
183        })
184    }
185
186    /// Import the same per-content proofs used by native disk receipts.
187    pub fn from_proofs(proofs: HashMap<XorName, Vec<u8>>) -> Self {
188        Self {
189            plans: HashMap::new(),
190            proofs,
191            pending_merkle: None,
192            pending_payment: None,
193            failed_payments: Vec::new(),
194        }
195    }
196
197    /// Reuse a previously prepared plan after a wallet callback was interrupted.
198    /// Paid records are re-discovered so current storage targets replace stale ones.
199    pub fn retained_plan(
200        &self,
201        address: &XorName,
202        size: u64,
203        now: SystemTime,
204    ) -> Option<ChunkPaymentPlan> {
205        if self.is_paid(address, now) {
206            return None;
207        }
208        let plan = self.plans.get(address)?;
209        if plan.data_size != size || plan.peer_quotes.is_empty() {
210            return None;
211        }
212        let proof = ant_protocol::evm::ProofOfPayment {
213            peer_quotes: plan.peer_quotes.clone(),
214        };
215        proof_is_safely_fresh(
216            &proof,
217            now,
218            Duration::from_secs(
219                super::batch::CACHED_PROOF_MAX_AGE_SECS
220                    - super::batch::CACHED_PROOF_SAFETY_MARGIN_SECS,
221            ),
222        )
223        .then(|| plan.clone())
224    }
225
226    /// Retain a verified plan before invoking an external wallet.
227    pub fn prepare(&mut self, plan: ChunkPaymentPlan) {
228        self.plans.insert(plan.address, plan);
229    }
230
231    /// Whether a confirmed, safely fresh proof already covers this record.
232    pub fn is_paid(&self, address: &XorName, now: SystemTime) -> bool {
233        self.proofs
234            .get(address)
235            .is_some_and(|bytes| reusable_proof(address, bytes, now))
236    }
237
238    /// Bind a confirmed payment to all pending plans, before loading or storing bytes.
239    /// Missing transactions fail atomically, preserving the prepared plans for recovery.
240    pub fn confirm(
241        &mut self,
242        addresses: &[XorName],
243        transactions: &HashMap<QuoteHash, TxHash>,
244        now: SystemTime,
245    ) -> Result<()> {
246        let proofs = addresses
247            .iter()
248            .filter(|address| !self.is_paid(address, now))
249            .map(|address| {
250                let plan = self
251                    .plans
252                    .get(address)
253                    .ok_or_else(|| Error::Payment("missing prepared payment plan".into()))?;
254                build_plan_proof(plan, transactions).map(|proof| (*address, proof))
255            })
256            .collect::<Result<Vec<_>>>()?;
257        self.proofs.extend(proofs);
258        if self.pending_payment.as_ref().is_some_and(|attempt| {
259            !attempt.merkle
260                && attempt
261                    .addresses
262                    .iter()
263                    .all(|address| self.proofs.contains_key(address))
264        }) {
265            self.pending_payment = None;
266        }
267        for address in addresses {
268            self.plans.remove(address);
269        }
270        Ok(())
271    }
272
273    /// Attach an existing proof to refreshed native or browser PUT targets.
274    pub fn reuse_prepared(&self, prepared: &PreparedChunk, now: SystemTime) -> Option<PaidChunk> {
275        let proof_bytes = self.proofs.get(&prepared.address)?;
276        if !reusable_proof(&prepared.address, proof_bytes, now) {
277            return None;
278        }
279        Some(PaidChunk {
280            content: prepared.content.clone(),
281            address: prepared.address,
282            quoted_peers: prepared.quoted_peers.clone(),
283            proof_bytes: proof_bytes.clone(),
284        })
285    }
286
287    /// Serialize a local recovery checkpoint. Treat it like a native payment receipt.
288    pub fn checkpoint(&self) -> Result<Vec<u8>> {
289        rmp_serde::to_vec_named(self).map_err(|e| Error::Serialization(e.to_string()))
290    }
291
292    /// Restore a local checkpoint, validating payment amounts and signed quote identities.
293    pub fn restore(bytes: &[u8]) -> Result<Self> {
294        let state: Self =
295            rmp_serde::from_slice(bytes).map_err(|e| Error::Serialization(e.to_string()))?;
296        for (address, plan) in &state.plans {
297            if address != &plan.address
298                || plan.peer_quotes.is_empty()
299                || plan.peer_quotes.iter().any(|(peer, quote)| {
300                    quote.content.0 != *address
301                        || *peer
302                            != ant_protocol::evm::EncodedPeerId::new(
303                                *blake3::hash(&quote.pub_key).as_bytes(),
304                            )
305                        || !ant_protocol::payment::verify_quote_signature(quote)
306                })
307            {
308                return Err(Error::InvalidData("invalid checkpoint quote".into()));
309            }
310            let canonical = super::batch::SingleNodeQuotePayment::from_quotes(
311                plan.peer_quotes
312                    .iter()
313                    .map(|(_, quote)| quote.clone())
314                    .collect(),
315            )?;
316            let signature = |quotes: &[ant_protocol::payment::QuotePaymentInfo]| {
317                quotes
318                    .iter()
319                    .map(|q| (q.quote_hash, q.rewards_address, q.amount, q.price))
320                    .collect::<Vec<_>>()
321            };
322            if signature(&canonical.quotes) != signature(&plan.payment.quotes) {
323                return Err(Error::InvalidData(
324                    "checkpoint payment differs from signed quotes".into(),
325                ));
326            }
327        }
328        if let Some(prepared) = &state.pending_merkle {
329            prepared.validate_checkpoint()?;
330        }
331        if let Some(attempt) = &state.pending_payment {
332            if attempt.submissions.len() > 256 || (attempt.merkle && state.pending_merkle.is_none())
333            {
334                return Err(Error::InvalidData("invalid payment journal".into()));
335            }
336            if !attempt.merkle {
337                state.pending_plans()?;
338            }
339        }
340        for (address, bytes) in &state.proofs {
341            if let Ok(proof) = ant_protocol::payment::deserialize_merkle_proof(bytes) {
342                if proof.address.0 != *address
343                    || !proof.data_proof.verify()
344                    || proof.data_proof.root() != proof.winner_pool.midpoint_proof.root()
345                    || proof.winner_pool.candidate_nodes.iter().any(|candidate| {
346                        !ant_protocol::payment::verify_merkle_candidate_signature(candidate)
347                    })
348                {
349                    return Err(Error::InvalidData("invalid checkpoint Merkle proof".into()));
350                }
351                continue;
352            }
353            let proof = ant_protocol::payment::proof::deserialize_single_node_proof(bytes)
354                .map_err(Error::InvalidData)?;
355            if proof.tx_hashes.is_empty()
356                || proof.proof_of_payment.peer_quotes.is_empty()
357                || proof
358                    .proof_of_payment
359                    .peer_quotes
360                    .iter()
361                    .any(|(peer, quote)| {
362                        quote.content.0 != *address
363                            || *peer
364                                != ant_protocol::evm::EncodedPeerId::new(
365                                    *blake3::hash(&quote.pub_key).as_bytes(),
366                                )
367                            || !ant_protocol::payment::verify_quote_signature(quote)
368                    })
369            {
370                return Err(Error::InvalidData(
371                    "invalid checkpoint payment proof".into(),
372                ));
373            }
374        }
375        Ok(state)
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::super::batch::{finalize_batch_payment, SingleNodeQuotePayment};
382    use super::*;
383    use ant_protocol::{
384        evm::{Amount, EncodedPeerId, PaymentQuote, RewardsAddress},
385        transport::NodeIdentity,
386    };
387    use bytes::Bytes;
388
389    fn plan(content: &[u8], timestamp: SystemTime) -> ChunkPaymentPlan {
390        let identity = NodeIdentity::generate().unwrap();
391        let mut quote = PaymentQuote {
392            content: xor_name::XorName(ant_protocol::compute_address(content)),
393            timestamp,
394            price: Amount::from(10),
395            rewards_address: RewardsAddress::new([1; 20]),
396            pub_key: identity.public_key().as_bytes().to_vec(),
397            signature: Vec::new(),
398            committed_key_count: 0,
399            commitment_pin: None,
400        };
401        quote.signature = identity
402            .sign(&quote.bytes_for_sig())
403            .unwrap()
404            .as_bytes()
405            .to_vec();
406        ChunkPaymentPlan {
407            address: quote.content.0,
408            data_size: content.len() as u64,
409            quoted_peers: Vec::new(),
410            payment: SingleNodeQuotePayment::from_quotes(vec![quote.clone()]).unwrap(),
411            peer_quotes: vec![(
412                EncodedPeerId::new(*blake3::hash(&quote.pub_key).as_bytes()),
413                quote,
414            )],
415            commitment_sidecars: Vec::new(),
416        }
417    }
418
419    #[test]
420    fn checkpoint_reuses_original_proof_with_new_quotes_and_matches_native_finalization() {
421        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
422        let content = Bytes::from_static(b"retained paid record");
423        let original = plan(&content, now);
424        let txs = HashMap::from([(original.payment.quotes[0].quote_hash, TxHash::from([7; 32]))]);
425        let mut state = UploadState::default();
426        state.prepare(original.clone());
427        state = UploadState::restore(&state.checkpoint().unwrap()).unwrap();
428        assert_eq!(
429            state
430                .retained_plan(&original.address, content.len() as u64, now)
431                .unwrap()
432                .payment
433                .quotes[0]
434                .quote_hash,
435            original.payment.quotes[0].quote_hash
436        );
437        state.confirm(&[original.address], &txs, now).unwrap();
438        state = UploadState::restore(&state.checkpoint().unwrap()).unwrap();
439        let refreshed = plan(&content, now + Duration::from_secs(1));
440        assert_ne!(
441            refreshed.payment.quotes[0].quote_hash,
442            original.payment.quotes[0].quote_hash
443        );
444        let recovered = state
445            .reuse_prepared(&refreshed.with_content(content.clone()).unwrap(), now)
446            .unwrap();
447        let native =
448            finalize_batch_payment(vec![original.with_content(content).unwrap()], &txs).unwrap();
449        assert_eq!(recovered.proof_bytes, native[0].proof_bytes);
450    }
451
452    #[test]
453    fn confirmation_is_atomic_and_supports_distinct_transactions() {
454        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
455        let a = plan(b"first", now);
456        let b = plan(b"second", now);
457        let mut state = UploadState::default();
458        state.prepare(a.clone());
459        state.prepare(b.clone());
460        let mut txs = HashMap::from([(a.payment.quotes[0].quote_hash, TxHash::from([1; 32]))]);
461        assert!(state.confirm(&[a.address, b.address], &txs, now).is_err());
462        assert!(!state.is_paid(&a.address, now));
463        txs.insert(b.payment.quotes[0].quote_hash, TxHash::from([2; 32]));
464        state.confirm(&[a.address, b.address], &txs, now).unwrap();
465        assert!(state.is_paid(&a.address, now));
466        assert!(state.is_paid(&b.address, now));
467        assert!(!state.is_paid(&a.address, now + Duration::from_secs(24 * 60 * 60 - 299)));
468        assert!(!reusable_proof(&b.address, &state.proofs[&a.address], now));
469    }
470
471    #[test]
472    fn checkpoint_rejects_modified_payment_amount() {
473        let mut state = UploadState::default();
474        let mut a = plan(b"first", SystemTime::UNIX_EPOCH);
475        a.payment.quotes[0].amount += Amount::from(1);
476        state.prepare(a);
477        assert!(UploadState::restore(&state.checkpoint().unwrap()).is_err());
478    }
479    #[test]
480    fn expired_proof_allows_a_fresh_plan_without_discarding_payment_evidence() {
481        let issued = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
482        let old = plan(b"expired upload", issued);
483        let mut state = UploadState::default();
484        state.prepare(old.clone());
485        state
486            .confirm(
487                &[old.address],
488                &HashMap::from([(old.payment.quotes[0].quote_hash, TxHash::from([7; 32]))]),
489                issued,
490            )
491            .unwrap();
492        let evidence = state.proof(&old.address).cloned().unwrap();
493        let now = issued + Duration::from_secs(25 * 60 * 60);
494        let fresh = plan(b"expired upload", now);
495        state.prepare(fresh.clone());
496        let state = UploadState::restore(&state.checkpoint().unwrap()).unwrap();
497        assert!(state
498            .retained_plan(&old.address, old.data_size, issued)
499            .is_none());
500        assert!(state
501            .retained_plan(&fresh.address, fresh.data_size, now)
502            .is_some());
503        assert_eq!(state.proof(&old.address), Some(&evidence));
504    }
505}