Skip to main content

hashtree_cli/webrtc/
cashu.rs

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