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 "e_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 "e_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 "e_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(
1121 "peer-a",
1122 quote_response(Some("https://mint-b.example"), 3),
1123 )
1124 .await;
1125 assert!(handled);
1126
1127 let quote = rx
1128 .try_recv()
1129 .expect("expected negotiated quote")
1130 .expect("expected quote payload");
1131 assert_eq!(quote.peer_id, "peer-a");
1132 assert_eq!(quote.quote_id, 7);
1133 assert_eq!(quote.payment_sat, 3);
1134 assert_eq!(quote.mint_url.as_deref(), Some("https://mint-b.example"));
1135 }
1136
1137 #[tokio::test]
1138 async fn test_build_quote_response_registers_quote_for_validation() {
1139 let state = make_state(
1140 CashuRoutingConfig {
1141 accepted_mints: vec!["https://mint-a.example".to_string()],
1142 default_mint: Some("https://mint-a.example".to_string()),
1143 ..Default::default()
1144 },
1145 true,
1146 );
1147
1148 let res = state
1149 .build_quote_response(
1150 "peer-a",
1151 &DataQuoteRequest {
1152 h: vec![0x22; 32],
1153 p: 3,
1154 t: 500,
1155 m: Some("https://mint-a.example".to_string()),
1156 },
1157 true,
1158 )
1159 .await;
1160 assert!(res.a);
1161
1162 let expected = state
1163 .take_valid_quote("peer-a", &[0x22; 32], res.q.unwrap())
1164 .await
1165 .expect("quote should validate");
1166 assert_eq!(expected.payment_sat, 3);
1167 assert_eq!(expected.mint_url.as_deref(), Some("https://mint-a.example"));
1168 }
1169
1170 #[tokio::test]
1171 async fn test_payment_timeout_records_default() {
1172 let state = make_state(
1173 CashuRoutingConfig {
1174 default_mint: Some("https://mint-a.example".to_string()),
1175 settlement_timeout_ms: 10,
1176 ..Default::default()
1177 },
1178 true,
1179 );
1180 state
1181 .register_expected_payment(
1182 "peer-a".to_string(),
1183 hex::encode([0x33; 32]),
1184 7,
1185 ExpectedSettlement {
1186 chunk_index: 0,
1187 payment_sat: 3,
1188 mint_url: Some("https://mint-a.example".to_string()),
1189 final_chunk: true,
1190 },
1191 )
1192 .await;
1193
1194 tokio::time::sleep(Duration::from_millis(25)).await;
1195 let selector = state.peer_selector.read().await;
1196 let stats = selector.get_stats("peer-a").expect("peer stats");
1197 assert_eq!(stats.cashu_payment_defaults, 1);
1198 }
1199
1200 #[tokio::test]
1201 async fn test_mint_metadata_store_persists_and_blocks_failed_mint() {
1202 let temp_dir = tempfile::tempdir().unwrap();
1203 let path = cashu_mint_metadata_path(temp_dir.path());
1204 let store = CashuMintMetadataStore::load(&path).unwrap();
1205 store
1206 .record_receipt_failure("https://mint-bad.example")
1207 .await
1208 .unwrap();
1209 store
1210 .record_receipt_failure("https://mint-bad.example")
1211 .await
1212 .unwrap();
1213
1214 let restored = CashuMintMetadataStore::load(&path).unwrap();
1215 let record = restored.get("https://mint-bad.example").await;
1216 assert_eq!(record.failed_receipts, 2);
1217 assert!(restored.is_blocked("https://mint-bad.example", 2).await);
1218 }
1219
1220 #[test]
1221 fn test_build_chunk_plan_splits_payment_into_small_chunks() {
1222 let (chunks, payments) = build_chunk_plan(vec![1_u8; 10], 4, 3);
1223 assert_eq!(chunks.len(), 3);
1224 assert_eq!(payments, vec![1, 1, 1]);
1225 assert_eq!(chunks.iter().map(Vec::len).sum::<usize>(), 10);
1226 }
1227}