Skip to main content

hashtree_network/
cashu.rs

1use anyhow::{anyhow, Context, Result};
2use cashu_service::{CashuMintBalance, CashuPaymentClient, CashuReceivedPayment, CashuSentPayment};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10use tokio::sync::{oneshot, Mutex, RwLock};
11
12use crate::protocol::{DataChunk, DataQuoteRequest, DataQuoteResponse};
13use crate::PeerSelector;
14
15pub const CASHU_MINT_METADATA_VERSION: u32 = 1;
16
17#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
18pub struct CashuMintMetadataRecord {
19    pub successful_receipts: u64,
20    pub failed_receipts: u64,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24struct PersistedCashuMintMetadata {
25    version: u32,
26    mints: HashMap<String, CashuMintMetadataRecord>,
27}
28
29pub fn cashu_mint_metadata_path(data_dir: &Path) -> PathBuf {
30    data_dir.join("cashu").join("mint-metadata.json")
31}
32
33pub struct CashuMintMetadataStore {
34    path: Option<PathBuf>,
35    state: RwLock<HashMap<String, CashuMintMetadataRecord>>,
36}
37
38impl CashuMintMetadataStore {
39    pub fn in_memory() -> Arc<Self> {
40        Arc::new(Self {
41            path: None,
42            state: RwLock::new(HashMap::new()),
43        })
44    }
45
46    pub fn load(path: impl Into<PathBuf>) -> Result<Arc<Self>> {
47        let path = path.into();
48        let state = if path.exists() {
49            let content = fs::read_to_string(&path).with_context(|| {
50                format!("Failed to read Cashu mint metadata from {}", path.display())
51            })?;
52            let snapshot: PersistedCashuMintMetadata =
53                serde_json::from_str(&content).context("Failed to parse Cashu mint metadata")?;
54            snapshot.mints
55        } else {
56            HashMap::new()
57        };
58
59        Ok(Arc::new(Self {
60            path: Some(path),
61            state: RwLock::new(state),
62        }))
63    }
64
65    pub async fn get(&self, mint_url: &str) -> CashuMintMetadataRecord {
66        self.state
67            .read()
68            .await
69            .get(mint_url)
70            .cloned()
71            .unwrap_or_default()
72    }
73
74    pub async fn record_receipt_success(&self, mint_url: &str) -> Result<()> {
75        let mut state = self.state.write().await;
76        state
77            .entry(mint_url.to_string())
78            .or_default()
79            .successful_receipts += 1;
80        self.persist_locked(&state)
81    }
82
83    pub async fn record_receipt_failure(&self, mint_url: &str) -> Result<()> {
84        let mut state = self.state.write().await;
85        state
86            .entry(mint_url.to_string())
87            .or_default()
88            .failed_receipts += 1;
89        self.persist_locked(&state)
90    }
91
92    pub async fn is_blocked(&self, mint_url: &str, threshold: u64) -> bool {
93        if threshold == 0 {
94            return false;
95        }
96        let record = self.get(mint_url).await;
97        record.failed_receipts >= threshold && record.failed_receipts > record.successful_receipts
98    }
99
100    fn persist_locked(&self, state: &HashMap<String, CashuMintMetadataRecord>) -> Result<()> {
101        let Some(path) = self.path.as_ref() else {
102            return Ok(());
103        };
104        if let Some(parent) = path.parent() {
105            fs::create_dir_all(parent).with_context(|| {
106                format!(
107                    "Failed to create Cashu mint metadata directory {}",
108                    parent.display()
109                )
110            })?;
111        }
112
113        let snapshot = PersistedCashuMintMetadata {
114            version: CASHU_MINT_METADATA_VERSION,
115            mints: state.clone(),
116        };
117        let content = serde_json::to_string_pretty(&snapshot)
118            .context("Failed to encode Cashu mint metadata")?;
119        let tmp_path = path.with_extension("json.tmp");
120        fs::write(&tmp_path, content).with_context(|| {
121            format!(
122                "Failed to write temporary Cashu mint metadata {}",
123                tmp_path.display()
124            )
125        })?;
126        fs::rename(&tmp_path, path).with_context(|| {
127            format!(
128                "Failed to move Cashu mint metadata into place {}",
129                path.display()
130            )
131        })?;
132        Ok(())
133    }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct CashuRoutingConfig {
138    pub accepted_mints: Vec<String>,
139    pub default_mint: Option<String>,
140    pub quote_payment_offer_sat: u64,
141    pub quote_ttl_ms: u32,
142    pub settlement_timeout_ms: u64,
143    pub mint_failure_block_threshold: u64,
144    pub peer_suggested_mint_base_cap_sat: u64,
145    pub peer_suggested_mint_success_step_sat: u64,
146    pub peer_suggested_mint_receipt_step_sat: u64,
147    pub peer_suggested_mint_max_cap_sat: u64,
148    pub payment_default_block_threshold: u64,
149    pub chunk_target_bytes: usize,
150}
151
152impl Default for CashuRoutingConfig {
153    fn default() -> Self {
154        Self {
155            accepted_mints: Vec::new(),
156            default_mint: None,
157            quote_payment_offer_sat: 3,
158            quote_ttl_ms: 1_500,
159            settlement_timeout_ms: 5_000,
160            mint_failure_block_threshold: 2,
161            peer_suggested_mint_base_cap_sat: 3,
162            peer_suggested_mint_success_step_sat: 1,
163            peer_suggested_mint_receipt_step_sat: 2,
164            peer_suggested_mint_max_cap_sat: 21,
165            payment_default_block_threshold: 0,
166            chunk_target_bytes: 32 * 1024,
167        }
168    }
169}
170
171struct PendingQuoteRequest {
172    response_tx: oneshot::Sender<Option<NegotiatedQuote>>,
173    preferred_mint_url: Option<String>,
174    offered_payment_sat: u64,
175}
176
177struct IssuedQuote {
178    payment_sat: u64,
179    mint_url: Option<String>,
180    expires_at: Instant,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct ExpectedSettlement {
185    pub chunk_index: u32,
186    pub payment_sat: u64,
187    pub mint_url: Option<String>,
188    pub final_chunk: bool,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct NegotiatedQuote {
193    pub peer_id: String,
194    pub quote_id: u64,
195    pub payment_sat: u64,
196    pub mint_url: Option<String>,
197}
198
199struct OutgoingTransfer {
200    chunks: Vec<Vec<u8>>,
201    chunk_payments: Vec<u64>,
202    mint_url: Option<String>,
203    next_chunk_index: usize,
204}
205
206pub struct CashuQuoteState {
207    routing: CashuRoutingConfig,
208    peer_selector: Arc<RwLock<PeerSelector>>,
209    payment_client: Option<Arc<dyn CashuPaymentClient>>,
210    mint_metadata: Arc<CashuMintMetadataStore>,
211    pending_quotes: Mutex<HashMap<String, PendingQuoteRequest>>,
212    issued_quotes: Mutex<HashMap<(String, String, u64), IssuedQuote>>,
213    pending_settlements: Mutex<HashMap<(String, String, u64), ExpectedSettlement>>,
214    outgoing_transfers: Mutex<HashMap<(String, String, u64), OutgoingTransfer>>,
215    next_quote_id: AtomicU64,
216}
217
218impl CashuQuoteState {
219    pub fn new(
220        routing: CashuRoutingConfig,
221        peer_selector: Arc<RwLock<PeerSelector>>,
222        payment_client: Option<Arc<dyn CashuPaymentClient>>,
223    ) -> Self {
224        Self::new_with_mint_metadata(
225            routing,
226            peer_selector,
227            payment_client,
228            CashuMintMetadataStore::in_memory(),
229        )
230    }
231
232    pub fn new_with_mint_metadata(
233        routing: CashuRoutingConfig,
234        peer_selector: Arc<RwLock<PeerSelector>>,
235        payment_client: Option<Arc<dyn CashuPaymentClient>>,
236        mint_metadata: Arc<CashuMintMetadataStore>,
237    ) -> Self {
238        Self {
239            routing,
240            peer_selector,
241            payment_client,
242            mint_metadata,
243            pending_quotes: Mutex::new(HashMap::new()),
244            issued_quotes: Mutex::new(HashMap::new()),
245            pending_settlements: Mutex::new(HashMap::new()),
246            outgoing_transfers: Mutex::new(HashMap::new()),
247            next_quote_id: AtomicU64::new(1),
248        }
249    }
250
251    pub fn payment_client_available(&self) -> bool {
252        self.payment_client.is_some()
253    }
254
255    pub async fn requester_quote_terms(&self) -> Option<(String, u64, u32)> {
256        if !self.payment_client_available()
257            || self.routing.quote_payment_offer_sat == 0
258            || self.routing.quote_ttl_ms == 0
259        {
260            return None;
261        }
262
263        let mint_url = self
264            .requested_quote_mint(self.routing.quote_payment_offer_sat)
265            .await?;
266        Some((
267            mint_url,
268            self.routing.quote_payment_offer_sat,
269            self.routing.quote_ttl_ms,
270        ))
271    }
272
273    pub fn settlement_timeout(&self) -> Duration {
274        Duration::from_millis(self.routing.settlement_timeout_ms.max(1))
275    }
276
277    pub async fn requested_quote_mint(&self, amount_sat: u64) -> Option<String> {
278        for mint_url in self.trusted_mint_candidates() {
279            if self
280                .is_mint_allowed_for_requester(&mint_url, amount_sat)
281                .await
282            {
283                return Some(mint_url);
284            }
285        }
286        None
287    }
288
289    pub async fn choose_quote_mint(&self, requested_mint: Option<&str>) -> Option<String> {
290        if let Some(requested_mint) = requested_mint {
291            if self.accepts_quote_mint(Some(requested_mint))
292                && !self.is_mint_blocked(requested_mint).await
293            {
294                return Some(requested_mint.to_string());
295            }
296        }
297        if let Some(default_mint) = self.routing.default_mint.as_ref() {
298            if !self.is_mint_blocked(default_mint).await {
299                return Some(default_mint.clone());
300            }
301        }
302        for mint_url in &self.routing.accepted_mints {
303            if !self.is_mint_blocked(mint_url).await {
304                return Some(mint_url.clone());
305            }
306        }
307        if self.routing.accepted_mints.is_empty() {
308            if let Some(requested_mint) = requested_mint {
309                if !self.is_mint_blocked(requested_mint).await {
310                    return Some(requested_mint.to_string());
311                }
312            }
313        }
314        None
315    }
316
317    pub async fn register_pending_quote(
318        &self,
319        hash_hex: String,
320        preferred_mint_url: Option<String>,
321        offered_payment_sat: u64,
322    ) -> oneshot::Receiver<Option<NegotiatedQuote>> {
323        let (tx, rx) = oneshot::channel();
324        self.pending_quotes.lock().await.insert(
325            hash_hex,
326            PendingQuoteRequest {
327                response_tx: tx,
328                preferred_mint_url,
329                offered_payment_sat,
330            },
331        );
332        rx
333    }
334
335    pub async fn clear_pending_quote(&self, hash_hex: &str) {
336        let _ = self.pending_quotes.lock().await.remove(hash_hex);
337    }
338
339    pub async fn should_accept_quote_response(
340        &self,
341        from_peer: &str,
342        preferred_mint_url: Option<&str>,
343        offered_payment_sat: u64,
344        res: &DataQuoteResponse,
345    ) -> bool {
346        let Some(payment_sat) = res.p else {
347            return false;
348        };
349        if payment_sat > offered_payment_sat {
350            return false;
351        }
352
353        let response_mint = res.m.as_deref();
354        let Some(response_mint) = response_mint else {
355            return false;
356        };
357        if self.is_mint_blocked(response_mint).await
358            || !self
359                .has_sufficient_balance(response_mint, payment_sat)
360                .await
361        {
362            return false;
363        }
364        if preferred_mint_url == Some(response_mint) {
365            return true;
366        }
367        if self.trusts_quote_mint(Some(response_mint)) {
368            return true;
369        }
370
371        payment_sat <= self.peer_suggested_mint_cap_sat(from_peer).await
372    }
373
374    pub async fn handle_quote_response(&self, from_peer: &str, res: DataQuoteResponse) -> bool {
375        if !res.a || !self.payment_client_available() {
376            return false;
377        }
378
379        let (Some(quote_id), Some(payment_sat)) = (res.q, res.p) else {
380            return false;
381        };
382        let hash_hex = hex::encode(&res.h);
383        let (preferred_mint_url, offered_payment_sat) = {
384            let pending_quotes = self.pending_quotes.lock().await;
385            let Some(pending) = pending_quotes.get(&hash_hex) else {
386                return false;
387            };
388            (
389                pending.preferred_mint_url.clone(),
390                pending.offered_payment_sat,
391            )
392        };
393
394        if !self
395            .should_accept_quote_response(
396                from_peer,
397                preferred_mint_url.as_deref(),
398                offered_payment_sat,
399                &res,
400            )
401            .await
402        {
403            return false;
404        }
405
406        let Some(pending) = self.pending_quotes.lock().await.remove(&hash_hex) else {
407            return false;
408        };
409        let _ = pending.response_tx.send(Some(NegotiatedQuote {
410            peer_id: from_peer.to_string(),
411            quote_id,
412            payment_sat,
413            mint_url: res.m,
414        }));
415        true
416    }
417
418    pub async fn build_quote_response(
419        &self,
420        from_peer: &str,
421        req: &DataQuoteRequest,
422        can_serve: bool,
423    ) -> DataQuoteResponse {
424        if !can_serve || !self.payment_client_available() {
425            return DataQuoteResponse {
426                h: req.h.clone(),
427                a: false,
428                q: None,
429                p: None,
430                t: None,
431                m: None,
432            };
433        }
434
435        let Some(chosen_mint) = self.choose_quote_mint(req.m.as_deref()).await else {
436            return DataQuoteResponse {
437                h: req.h.clone(),
438                a: false,
439                q: None,
440                p: None,
441                t: None,
442                m: None,
443            };
444        };
445        let quote_id = self.next_quote_id.fetch_add(1, Ordering::Relaxed);
446        let hash_hex = hex::encode(&req.h);
447        self.issued_quotes.lock().await.insert(
448            (from_peer.to_string(), hash_hex, quote_id),
449            IssuedQuote {
450                payment_sat: req.p,
451                mint_url: Some(chosen_mint.clone()),
452                expires_at: Instant::now() + Duration::from_millis(req.t as u64),
453            },
454        );
455
456        DataQuoteResponse {
457            h: req.h.clone(),
458            a: true,
459            q: Some(quote_id),
460            p: Some(req.p),
461            t: Some(req.t),
462            m: Some(chosen_mint),
463        }
464    }
465
466    pub async fn take_valid_quote(
467        &self,
468        from_peer: &str,
469        hash: &[u8],
470        quote_id: u64,
471    ) -> Option<ExpectedSettlement> {
472        let hash_hex = hex::encode(hash);
473        let key = (from_peer.to_string(), hash_hex, quote_id);
474        let issued = self.issued_quotes.lock().await.remove(&key)?;
475        (issued.expires_at >= Instant::now()).then_some(ExpectedSettlement {
476            chunk_index: 0,
477            payment_sat: issued.payment_sat,
478            mint_url: issued.mint_url,
479            final_chunk: true,
480        })
481    }
482
483    pub async fn register_expected_payment(
484        self: &Arc<Self>,
485        from_peer: String,
486        hash_hex: String,
487        quote_id: u64,
488        settlement: ExpectedSettlement,
489    ) {
490        let key = (from_peer.clone(), hash_hex.clone(), quote_id);
491        self.pending_settlements
492            .lock()
493            .await
494            .insert(key.clone(), settlement);
495
496        let state = Arc::clone(self);
497        tokio::spawn(async move {
498            tokio::time::sleep(state.settlement_timeout()).await;
499            let expired = state
500                .pending_settlements
501                .lock()
502                .await
503                .remove(&key)
504                .is_some();
505            if expired {
506                let _ = state.outgoing_transfers.lock().await.remove(&key);
507                state
508                    .peer_selector
509                    .write()
510                    .await
511                    .record_cashu_payment_default(&from_peer);
512            }
513        });
514    }
515
516    pub async fn claim_expected_payment(
517        &self,
518        from_peer: &str,
519        hash: &[u8],
520        quote_id: u64,
521        chunk_index: u32,
522        announced_payment_sat: u64,
523        announced_mint: Option<&str>,
524    ) -> Result<ExpectedSettlement> {
525        let hash_hex = hex::encode(hash);
526        let key = (from_peer.to_string(), hash_hex, quote_id);
527        let settlement = self
528            .pending_settlements
529            .lock()
530            .await
531            .remove(&key)
532            .ok_or_else(|| anyhow!("No pending settlement"))?;
533
534        if settlement.chunk_index != chunk_index {
535            return Err(anyhow!("Payment chunk did not match the expected chunk"));
536        }
537        if announced_payment_sat < settlement.payment_sat {
538            return Err(anyhow!("Quoted payment amount was not met"));
539        }
540        if settlement.mint_url.as_deref() != announced_mint {
541            return Err(anyhow!("Payment mint does not match quoted mint"));
542        }
543
544        Ok(settlement)
545    }
546
547    pub async fn prepare_quoted_transfer(
548        &self,
549        from_peer: &str,
550        hash: &[u8],
551        quote_id: u64,
552        settlement: &ExpectedSettlement,
553        data: Vec<u8>,
554    ) -> Option<(DataChunk, ExpectedSettlement)> {
555        let hash_hex = hex::encode(hash);
556        let transfer_key = (from_peer.to_string(), hash_hex, quote_id);
557        let outgoing = OutgoingTransfer::new(
558            data,
559            self.routing.chunk_target_bytes.max(1),
560            settlement.payment_sat,
561            settlement.mint_url.clone(),
562        );
563        let (first_chunk, first_expected, maybe_store) =
564            outgoing.take_first_chunk(hash, quote_id)?;
565        if let Some(transfer) = maybe_store {
566            self.outgoing_transfers
567                .lock()
568                .await
569                .insert(transfer_key, transfer);
570        }
571        Some((first_chunk, first_expected))
572    }
573
574    pub async fn next_outgoing_chunk(
575        &self,
576        from_peer: &str,
577        hash: &[u8],
578        quote_id: u64,
579    ) -> Option<(DataChunk, ExpectedSettlement)> {
580        let hash_hex = hex::encode(hash);
581        let key = (from_peer.to_string(), hash_hex, quote_id);
582        let mut transfers = self.outgoing_transfers.lock().await;
583        let transfer = transfers.get_mut(&key)?;
584        let next = transfer.next_chunk(hash, quote_id)?;
585        if transfer.is_complete() {
586            let _ = transfers.remove(&key);
587        }
588        Some(next)
589    }
590
591    pub async fn create_payment_token(
592        &self,
593        mint_url: &str,
594        amount_sat: u64,
595    ) -> Result<CashuSentPayment> {
596        let client = self
597            .payment_client
598            .as_ref()
599            .ok_or_else(|| anyhow!("Cashu settlement helper unavailable"))?;
600        client.send_payment(mint_url, amount_sat).await
601    }
602
603    pub async fn receive_payment_token(&self, encoded_token: &str) -> Result<CashuReceivedPayment> {
604        let client = self
605            .payment_client
606            .as_ref()
607            .ok_or_else(|| anyhow!("Cashu settlement helper unavailable"))?;
608        client.receive_payment(encoded_token).await
609    }
610
611    pub async fn revoke_payment_token(&self, mint_url: &str, operation_id: &str) -> Result<()> {
612        let client = self
613            .payment_client
614            .as_ref()
615            .ok_or_else(|| anyhow!("Cashu settlement helper unavailable"))?;
616        client.revoke_payment(mint_url, operation_id).await
617    }
618
619    pub async fn record_paid_peer(&self, peer_id: &str, amount_sat: u64) {
620        self.peer_selector
621            .write()
622            .await
623            .record_cashu_payment(peer_id, amount_sat);
624    }
625
626    pub async fn record_receipt_from_peer(
627        &self,
628        peer_id: &str,
629        mint_url: &str,
630        amount_sat: u64,
631    ) -> Result<()> {
632        self.peer_selector
633            .write()
634            .await
635            .record_cashu_receipt(peer_id, amount_sat);
636        self.mint_metadata.record_receipt_success(mint_url).await
637    }
638
639    pub async fn record_payment_default_from_peer(&self, peer_id: &str) {
640        self.peer_selector
641            .write()
642            .await
643            .record_cashu_payment_default(peer_id);
644    }
645
646    pub async fn record_mint_receive_failure(&self, mint_url: &str) -> Result<()> {
647        self.mint_metadata.record_receipt_failure(mint_url).await
648    }
649
650    pub async fn should_refuse_requests_from_peer(&self, peer_id: &str) -> bool {
651        let threshold = self.routing.payment_default_block_threshold;
652        if threshold == 0 {
653            return false;
654        }
655        self.peer_selector
656            .read()
657            .await
658            .is_peer_blocked_for_payment_defaults(peer_id, threshold)
659    }
660
661    fn accepts_quote_mint(&self, mint_url: Option<&str>) -> bool {
662        if self.routing.accepted_mints.is_empty() {
663            return true;
664        }
665
666        let Some(mint_url) = mint_url else {
667            return false;
668        };
669        self.routing
670            .accepted_mints
671            .iter()
672            .any(|mint| mint == mint_url)
673    }
674
675    fn trusts_quote_mint(&self, mint_url: Option<&str>) -> bool {
676        let Some(mint_url) = mint_url else {
677            return self.routing.default_mint.is_none() && self.routing.accepted_mints.is_empty();
678        };
679        self.routing.default_mint.as_deref() == Some(mint_url)
680            || self
681                .routing
682                .accepted_mints
683                .iter()
684                .any(|mint| mint == mint_url)
685    }
686
687    async fn peer_suggested_mint_cap_sat(&self, peer_id: &str) -> u64 {
688        let base = self.routing.peer_suggested_mint_base_cap_sat;
689        if base == 0 {
690            return 0;
691        }
692
693        let selector = self.peer_selector.read().await;
694        let Some(stats) = selector.get_stats(peer_id) else {
695            let max_cap = self.routing.peer_suggested_mint_max_cap_sat;
696            return if max_cap > 0 { base.min(max_cap) } else { base };
697        };
698
699        if stats.cashu_payment_defaults > 0
700            && stats.cashu_payment_defaults >= stats.cashu_payment_receipts
701        {
702            return 0;
703        }
704
705        let success_bonus = stats
706            .successes
707            .saturating_mul(self.routing.peer_suggested_mint_success_step_sat);
708        let receipt_bonus = stats
709            .cashu_payment_receipts
710            .saturating_mul(self.routing.peer_suggested_mint_receipt_step_sat);
711        let mut cap = base
712            .saturating_add(success_bonus)
713            .saturating_add(receipt_bonus);
714        let max_cap = self.routing.peer_suggested_mint_max_cap_sat;
715        if max_cap > 0 {
716            cap = cap.min(max_cap);
717        }
718        cap
719    }
720
721    async fn is_mint_allowed_for_requester(&self, mint_url: &str, amount_sat: u64) -> bool {
722        !self.is_mint_blocked(mint_url).await
723            && self.has_sufficient_balance(mint_url, amount_sat).await
724    }
725
726    async fn has_sufficient_balance(&self, mint_url: &str, amount_sat: u64) -> bool {
727        if amount_sat == 0 {
728            return true;
729        }
730        self.mint_balance_sat(mint_url).await >= amount_sat
731    }
732
733    async fn mint_balance_sat(&self, mint_url: &str) -> u64 {
734        let Some(client) = self.payment_client.as_ref() else {
735            return 0;
736        };
737        match client.mint_balance(mint_url).await {
738            Ok(CashuMintBalance {
739                unit, balance_sat, ..
740            }) if unit == "sat" => balance_sat,
741            _ => 0,
742        }
743    }
744
745    async fn is_mint_blocked(&self, mint_url: &str) -> bool {
746        self.mint_metadata
747            .is_blocked(mint_url, self.routing.mint_failure_block_threshold)
748            .await
749    }
750
751    fn trusted_mint_candidates(&self) -> Vec<String> {
752        let mut candidates = Vec::new();
753        if let Some(default_mint) = self.routing.default_mint.as_ref() {
754            candidates.push(default_mint.clone());
755        }
756        for mint_url in &self.routing.accepted_mints {
757            if !candidates.iter().any(|existing| existing == mint_url) {
758                candidates.push(mint_url.clone());
759            }
760        }
761        candidates
762    }
763}
764
765impl OutgoingTransfer {
766    fn new(
767        data: Vec<u8>,
768        chunk_target_bytes: usize,
769        total_payment_sat: u64,
770        mint_url: Option<String>,
771    ) -> Self {
772        let (chunks, chunk_payments) =
773            build_chunk_plan(data, chunk_target_bytes, total_payment_sat);
774        Self {
775            chunks,
776            chunk_payments,
777            mint_url,
778            next_chunk_index: 0,
779        }
780    }
781
782    fn take_first_chunk(
783        mut self,
784        hash: &[u8],
785        quote_id: u64,
786    ) -> Option<(DataChunk, ExpectedSettlement, Option<Self>)> {
787        let next = self.next_chunk(hash, quote_id)?;
788        let store = (!self.is_complete()).then_some(self);
789        Some((next.0, next.1, store))
790    }
791
792    fn next_chunk(
793        &mut self,
794        hash: &[u8],
795        quote_id: u64,
796    ) -> Option<(DataChunk, ExpectedSettlement)> {
797        let chunk_index = self.next_chunk_index;
798        let data = self.chunks.get(chunk_index)?.clone();
799        let payment_sat = *self.chunk_payments.get(chunk_index)?;
800        let total_chunks = self.chunks.len() as u32;
801        self.next_chunk_index += 1;
802        Some((
803            DataChunk {
804                h: hash.to_vec(),
805                q: quote_id,
806                c: chunk_index as u32,
807                n: total_chunks,
808                p: payment_sat,
809                d: data,
810            },
811            ExpectedSettlement {
812                chunk_index: chunk_index as u32,
813                payment_sat,
814                mint_url: self.mint_url.clone(),
815                final_chunk: chunk_index + 1 == self.chunks.len(),
816            },
817        ))
818    }
819
820    fn is_complete(&self) -> bool {
821        self.next_chunk_index >= self.chunks.len()
822    }
823}
824
825fn build_chunk_plan(
826    data: Vec<u8>,
827    chunk_target_bytes: usize,
828    total_payment_sat: u64,
829) -> (Vec<Vec<u8>>, Vec<u64>) {
830    let total_len = data.len();
831    let max_chunks_by_size = if total_len == 0 {
832        1
833    } else {
834        total_len.div_ceil(chunk_target_bytes.max(1))
835    };
836    let chunk_count = if total_payment_sat == 0 {
837        1
838    } else {
839        max_chunks_by_size
840            .min(total_payment_sat.min(usize::MAX as u64) as usize)
841            .max(1)
842    };
843
844    let mut chunks = Vec::with_capacity(chunk_count);
845    if total_len == 0 {
846        chunks.push(Vec::new());
847    } else {
848        let base = total_len / chunk_count;
849        let extra = total_len % chunk_count;
850        let mut offset = 0usize;
851        for chunk_idx in 0..chunk_count {
852            let size = base + usize::from(chunk_idx < extra);
853            let end = offset + size;
854            chunks.push(data[offset..end].to_vec());
855            offset = end;
856        }
857    }
858
859    let mut chunk_payments = Vec::with_capacity(chunk_count);
860    let base_payment = total_payment_sat / chunk_count as u64;
861    let extra_payment = total_payment_sat % chunk_count as u64;
862    for chunk_idx in 0..chunk_count {
863        chunk_payments.push(base_payment + u64::from(chunk_idx < extra_payment as usize));
864    }
865
866    (chunks, chunk_payments)
867}
868
869#[cfg(test)]
870mod tests {
871    use super::*;
872    use crate::SelectionStrategy;
873    use async_trait::async_trait;
874    use cashu_service::{
875        CashuMintBalance, CashuPaymentClient, CashuReceivedPayment, CashuSentPayment,
876    };
877    use std::collections::HashMap;
878    use std::sync::Mutex as StdMutex;
879
880    #[derive(Debug)]
881    struct NoopPaymentClient {
882        balances: StdMutex<HashMap<String, u64>>,
883    }
884
885    impl Default for NoopPaymentClient {
886        fn default() -> Self {
887            let mut balances = HashMap::new();
888            balances.insert("https://mint.example".to_string(), 21);
889            balances.insert("https://mint-a.example".to_string(), 21);
890            balances.insert("https://mint-b.example".to_string(), 21);
891            Self {
892                balances: StdMutex::new(balances),
893            }
894        }
895    }
896
897    #[async_trait]
898    impl CashuPaymentClient for NoopPaymentClient {
899        async fn send_payment(&self, mint_url: &str, amount_sat: u64) -> Result<CashuSentPayment> {
900            Ok(CashuSentPayment {
901                mint_url: mint_url.to_string(),
902                unit: "sat".to_string(),
903                amount_sat,
904                send_fee_sat: 0,
905                operation_id: "op-1".to_string(),
906                token: "cashuBtoken".to_string(),
907            })
908        }
909
910        async fn receive_payment(&self, _encoded_token: &str) -> Result<CashuReceivedPayment> {
911            Ok(CashuReceivedPayment {
912                mint_url: "https://mint.example".to_string(),
913                unit: "sat".to_string(),
914                amount_sat: 3,
915            })
916        }
917
918        async fn revoke_payment(&self, _mint_url: &str, _operation_id: &str) -> Result<()> {
919            Ok(())
920        }
921
922        async fn mint_balance(&self, mint_url: &str) -> Result<CashuMintBalance> {
923            let balance_sat = self
924                .balances
925                .lock()
926                .unwrap()
927                .get(mint_url)
928                .copied()
929                .unwrap_or_default();
930            Ok(CashuMintBalance {
931                mint_url: mint_url.to_string(),
932                unit: "sat".to_string(),
933                balance_sat,
934            })
935        }
936    }
937
938    fn make_state(routing: CashuRoutingConfig, with_client: bool) -> Arc<CashuQuoteState> {
939        Arc::new(CashuQuoteState::new(
940            routing,
941            Arc::new(RwLock::new(PeerSelector::with_strategy(
942                SelectionStrategy::TitForTat,
943            ))),
944            with_client
945                .then_some(Arc::new(NoopPaymentClient::default()) as Arc<dyn CashuPaymentClient>),
946        ))
947    }
948
949    fn quote_response(mint_url: Option<&str>, payment_sat: u64) -> DataQuoteResponse {
950        DataQuoteResponse {
951            h: vec![0x11; 32],
952            a: true,
953            q: Some(7),
954            p: Some(payment_sat),
955            t: Some(500),
956            m: mint_url.map(str::to_string),
957        }
958    }
959
960    #[tokio::test]
961    async fn test_requester_quote_terms_require_payment_client_and_mint_policy() {
962        let disabled = make_state(CashuRoutingConfig::default(), false);
963        assert_eq!(disabled.requester_quote_terms().await, None);
964
965        let no_client = make_state(
966            CashuRoutingConfig {
967                default_mint: Some("https://mint-a.example".to_string()),
968                ..Default::default()
969            },
970            false,
971        );
972        assert_eq!(no_client.requester_quote_terms().await, None);
973
974        let enabled = make_state(
975            CashuRoutingConfig {
976                default_mint: Some("https://mint-a.example".to_string()),
977                ..Default::default()
978            },
979            true,
980        );
981        assert_eq!(
982            enabled.requester_quote_terms().await,
983            Some(("https://mint-a.example".to_string(), 3, 1_500))
984        );
985    }
986
987    #[tokio::test]
988    async fn test_requester_quote_terms_require_funded_trusted_mint() {
989        let state = make_state(
990            CashuRoutingConfig {
991                default_mint: Some("https://mint-empty.example".to_string()),
992                quote_payment_offer_sat: 3,
993                ..Default::default()
994            },
995            true,
996        );
997        assert_eq!(
998            state.requester_quote_terms().await,
999            None,
1000            "unfunded trusted mint should disable paid quotes"
1001        );
1002    }
1003
1004    #[tokio::test]
1005    async fn test_should_accept_quote_response_allows_bounded_peer_suggested_mint() {
1006        let state = make_state(
1007            CashuRoutingConfig {
1008                accepted_mints: vec!["https://mint-a.example".to_string()],
1009                default_mint: Some("https://mint-a.example".to_string()),
1010                peer_suggested_mint_base_cap_sat: 3,
1011                peer_suggested_mint_max_cap_sat: 3,
1012                ..Default::default()
1013            },
1014            true,
1015        );
1016
1017        let accepted = state
1018            .should_accept_quote_response(
1019                "peer-a",
1020                Some("https://mint-a.example"),
1021                3,
1022                &quote_response(Some("https://mint-b.example"), 3),
1023            )
1024            .await;
1025        assert!(accepted);
1026    }
1027
1028    #[tokio::test]
1029    async fn test_should_accept_quote_response_rejects_peer_suggested_mint_after_defaults() {
1030        let state = make_state(
1031            CashuRoutingConfig {
1032                accepted_mints: vec!["https://mint-a.example".to_string()],
1033                default_mint: Some("https://mint-a.example".to_string()),
1034                peer_suggested_mint_base_cap_sat: 3,
1035                peer_suggested_mint_max_cap_sat: 3,
1036                ..Default::default()
1037            },
1038            true,
1039        );
1040        state
1041            .peer_selector
1042            .write()
1043            .await
1044            .record_cashu_payment_default("peer-a");
1045
1046        let accepted = state
1047            .should_accept_quote_response(
1048                "peer-a",
1049                Some("https://mint-a.example"),
1050                3,
1051                &quote_response(Some("https://mint-b.example"), 3),
1052            )
1053            .await;
1054        assert!(!accepted);
1055    }
1056
1057    #[tokio::test]
1058    async fn test_should_accept_quote_response_rejects_when_mint_balance_is_insufficient() {
1059        let state = make_state(
1060            CashuRoutingConfig {
1061                accepted_mints: vec!["https://mint-a.example".to_string()],
1062                default_mint: Some("https://mint-a.example".to_string()),
1063                ..Default::default()
1064            },
1065            true,
1066        );
1067
1068        let accepted = state
1069            .should_accept_quote_response(
1070                "peer-a",
1071                Some("https://mint-empty.example"),
1072                5,
1073                &quote_response(Some("https://mint-empty.example"), 5),
1074            )
1075            .await;
1076        assert!(!accepted);
1077    }
1078
1079    #[tokio::test]
1080    async fn test_handle_quote_response_resolves_pending_quote() {
1081        let state = make_state(
1082            CashuRoutingConfig {
1083                accepted_mints: vec!["https://mint-a.example".to_string()],
1084                default_mint: Some("https://mint-a.example".to_string()),
1085                peer_suggested_mint_base_cap_sat: 3,
1086                peer_suggested_mint_max_cap_sat: 3,
1087                ..Default::default()
1088            },
1089            true,
1090        );
1091
1092        let hash_hex = hex::encode([0x11; 32]);
1093        let mut rx = state
1094            .register_pending_quote(hash_hex, Some("https://mint-a.example".to_string()), 3)
1095            .await;
1096
1097        let handled = state
1098            .handle_quote_response("peer-a", quote_response(Some("https://mint-b.example"), 3))
1099            .await;
1100        assert!(handled);
1101
1102        let quote = rx
1103            .try_recv()
1104            .expect("expected negotiated quote")
1105            .expect("expected quote payload");
1106        assert_eq!(quote.peer_id, "peer-a");
1107        assert_eq!(quote.quote_id, 7);
1108        assert_eq!(quote.payment_sat, 3);
1109        assert_eq!(quote.mint_url.as_deref(), Some("https://mint-b.example"));
1110    }
1111
1112    #[tokio::test]
1113    async fn test_build_quote_response_registers_quote_for_validation() {
1114        let state = make_state(
1115            CashuRoutingConfig {
1116                accepted_mints: vec!["https://mint-a.example".to_string()],
1117                default_mint: Some("https://mint-a.example".to_string()),
1118                ..Default::default()
1119            },
1120            true,
1121        );
1122
1123        let res = state
1124            .build_quote_response(
1125                "peer-a",
1126                &DataQuoteRequest {
1127                    h: vec![0x22; 32],
1128                    p: 3,
1129                    t: 500,
1130                    m: Some("https://mint-a.example".to_string()),
1131                },
1132                true,
1133            )
1134            .await;
1135        assert!(res.a);
1136
1137        let expected = state
1138            .take_valid_quote("peer-a", &[0x22; 32], res.q.unwrap())
1139            .await
1140            .expect("quote should validate");
1141        assert_eq!(expected.payment_sat, 3);
1142        assert_eq!(expected.mint_url.as_deref(), Some("https://mint-a.example"));
1143    }
1144
1145    #[tokio::test]
1146    async fn test_payment_timeout_records_default() {
1147        let state = make_state(
1148            CashuRoutingConfig {
1149                default_mint: Some("https://mint-a.example".to_string()),
1150                settlement_timeout_ms: 10,
1151                ..Default::default()
1152            },
1153            true,
1154        );
1155        state
1156            .register_expected_payment(
1157                "peer-a".to_string(),
1158                hex::encode([0x33; 32]),
1159                7,
1160                ExpectedSettlement {
1161                    chunk_index: 0,
1162                    payment_sat: 3,
1163                    mint_url: Some("https://mint-a.example".to_string()),
1164                    final_chunk: true,
1165                },
1166            )
1167            .await;
1168
1169        tokio::time::sleep(Duration::from_millis(25)).await;
1170        let selector = state.peer_selector.read().await;
1171        let stats = selector.get_stats("peer-a").expect("peer stats");
1172        assert_eq!(stats.cashu_payment_defaults, 1);
1173    }
1174
1175    #[tokio::test]
1176    async fn test_mint_metadata_store_persists_and_blocks_failed_mint() {
1177        let temp_dir = tempfile::tempdir().unwrap();
1178        let path = cashu_mint_metadata_path(temp_dir.path());
1179        let store = CashuMintMetadataStore::load(&path).unwrap();
1180        store
1181            .record_receipt_failure("https://mint-bad.example")
1182            .await
1183            .unwrap();
1184        store
1185            .record_receipt_failure("https://mint-bad.example")
1186            .await
1187            .unwrap();
1188
1189        let restored = CashuMintMetadataStore::load(&path).unwrap();
1190        let record = restored.get("https://mint-bad.example").await;
1191        assert_eq!(record.failed_receipts, 2);
1192        assert!(restored.is_blocked("https://mint-bad.example", 2).await);
1193    }
1194
1195    #[test]
1196    fn test_build_chunk_plan_splits_payment_into_small_chunks() {
1197        let (chunks, payments) = build_chunk_plan(vec![1_u8; 10], 4, 3);
1198        assert_eq!(chunks.len(), 3);
1199        assert_eq!(payments, vec![1, 1, 1]);
1200        assert_eq!(chunks.iter().map(Vec::len).sum::<usize>(), 10);
1201    }
1202}