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 "e_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 "e_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 "e_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}