1use crate::provider::{PayError, PayProvider};
2use crate::store::wallet::{self, WalletMetadata};
3use crate::store::{PayStore, StorageBackend};
4use crate::types::*;
5use async_trait::async_trait;
6use bip39::Mnemonic;
7use cdk::nuts::{CurrencyUnit, PaymentMethod, ProofsMethods, State, Token};
8use cdk::wallet::{ReceiveOptions, SendOptions, Wallet, WalletBuilder};
9use cdk::Amount as CdkAmount;
10#[cfg(feature = "redb")]
11use cdk_redb::wallet::WalletRedbDatabase;
12use std::collections::HashMap;
13use std::str::FromStr;
14use std::sync::Arc;
15use tokio::sync::RwLock;
16
17fn normalize_mint_url(url: &str) -> String {
19 url.trim().trim_end_matches('/').to_string()
20}
21
22fn cashu_wallet_summary(m: WalletMetadata) -> WalletSummary {
23 let mint_url = m.mint_url.clone();
24 WalletSummary {
25 id: m.id,
26 network: Network::Cashu,
27 label: m.label,
28 address: mint_url.clone().unwrap_or_default(),
29 backend: None,
30 mint_url,
31 rpc_endpoints: None,
32 chain_id: None,
33 created_at_epoch_s: m.created_at_epoch_s,
34 }
35}
36
37pub struct CashuProvider {
38 _data_dir: String,
39 postgres_url: Option<String>,
40 store: Arc<StorageBackend>,
41 wallet_cache: RwLock<HashMap<String, Arc<Wallet>>>,
42}
43
44impl CashuProvider {
45 pub fn new(data_dir: &str, postgres_url: Option<String>, store: Arc<StorageBackend>) -> Self {
46 Self {
47 _data_dir: data_dir.to_string(),
48 postgres_url,
49 store,
50 wallet_cache: RwLock::new(HashMap::new()),
51 }
52 }
53
54 fn get_mint_url(&self, meta: &WalletMetadata) -> Result<String, PayError> {
55 meta.mint_url
56 .clone()
57 .ok_or_else(|| PayError::InternalError("wallet has no mint_url".to_string()))
58 }
59
60 async fn select_wallet_by_balance(
61 &self,
62 min_sats: u64,
63 prefer_smallest: bool,
64 mints: Option<&[String]>,
65 ) -> Result<String, PayError> {
66 let wallets = self.store.list_wallet_metadata(Some(Network::Cashu))?;
67 let mut wallet_infos = Vec::new();
68 let mut balance_failures = Vec::new();
69
70 for meta in &wallets {
72 let sats = match self.get_or_create_cdk_wallet(&meta.id).await {
73 Ok(w) => match w.total_balance().await {
74 Ok(bal) => bal.to_u64(),
75 Err(e) => {
76 balance_failures.push(format!("{}: balance: {e}", meta.id));
77 continue;
78 }
79 },
80 Err(e) => {
81 balance_failures.push(format!("{}: {e}", meta.id));
82 continue;
83 }
84 };
85 wallet_infos.push((meta, sats));
86 }
87
88 let unavailable_error = || {
89 let detail = balance_failures
90 .iter()
91 .take(3)
92 .cloned()
93 .collect::<Vec<_>>()
94 .join("; ");
95 let suffix = if balance_failures.len() > 3 {
96 format!(" (+{} more)", balance_failures.len() - 3)
97 } else {
98 String::new()
99 };
100 PayError::NetworkError(format!(
101 "failed to query cashu wallet balances: {detail}{suffix}"
102 ))
103 };
104
105 if let Some(mint_list) = mints {
107 let normalized_mints: Vec<String> =
108 mint_list.iter().map(|m| normalize_mint_url(m)).collect();
109
110 for mint_url in &normalized_mints {
112 let mut candidates: Vec<_> = wallet_infos
113 .iter()
114 .filter(|(meta, sats)| {
115 meta.mint_url
116 .as_deref()
117 .map(normalize_mint_url)
118 .is_some_and(|u| u == *mint_url)
119 && *sats >= min_sats
120 })
121 .collect();
122 if prefer_smallest {
123 candidates.sort_by_key(|(_, bal)| *bal);
124 } else {
125 candidates.sort_by_key(|(_, bal)| std::cmp::Reverse(*bal));
126 }
127 if let Some((meta, _)) = candidates.first() {
128 return Ok(meta.id.clone());
129 }
130 }
131
132 let has_wallet_on_mint = wallets.iter().any(|meta| {
134 meta.mint_url
135 .as_deref()
136 .map(normalize_mint_url)
137 .is_some_and(|u| normalized_mints.iter().any(|m| m == &u))
138 });
139 let has_healthy_wallet_on_mint = wallet_infos.iter().any(|(meta, _)| {
140 meta.mint_url
141 .as_deref()
142 .map(normalize_mint_url)
143 .is_some_and(|u| normalized_mints.iter().any(|m| m == &u))
144 });
145 return if has_wallet_on_mint {
146 if !has_healthy_wallet_on_mint && !balance_failures.is_empty() {
147 Err(unavailable_error())
148 } else {
149 Err(PayError::InvalidAmount(format!(
150 "insufficient balance on accepted mints; need {min_sats} sats"
151 )))
152 }
153 } else {
154 Err(PayError::WalletNotFound(format!(
155 "no wallet on accepted mints: {}; create one with: afpay cashu wallet create --mint-url <mint>",
156 mint_list.join(", ")
157 )))
158 };
159 }
160
161 let mut candidates = Vec::new();
163 for (meta, sats) in wallet_infos {
164 if sats >= min_sats {
165 candidates.push((meta.id.clone(), sats));
166 }
167 }
168 if prefer_smallest {
169 candidates.sort_by_key(|(_, bal)| *bal);
170 } else {
171 candidates.sort_by_key(|(_, bal)| std::cmp::Reverse(*bal));
172 }
173 if candidates.is_empty() && !wallets.is_empty() && !balance_failures.is_empty() {
174 return Err(unavailable_error());
175 }
176 candidates.first().map(|(id, _)| id.clone()).ok_or_else(|| {
177 PayError::WalletNotFound("no wallet with sufficient balance".to_string())
178 })
179 }
180
181 async fn get_or_create_cdk_wallet(&self, wallet_id: &str) -> Result<Arc<Wallet>, PayError> {
182 {
184 let cache = self.wallet_cache.read().await;
185 if let Some(w) = cache.get(wallet_id) {
186 return Ok(w.clone());
187 }
188 }
189
190 let meta = self.store.load_wallet_metadata(wallet_id)?;
192 if meta.network != Network::Cashu {
193 return Err(PayError::WalletNotFound(format!(
194 "{wallet_id} is not a cashu wallet"
195 )));
196 }
197
198 let seed_secret = meta
199 .seed_secret
200 .as_deref()
201 .ok_or_else(|| PayError::InternalError("wallet missing seed".to_string()))?;
202 let mnemonic: Mnemonic = seed_secret
203 .parse()
204 .map_err(|e| PayError::InternalError(format!("parse mnemonic: {e}")))?;
205 let seed = mnemonic.to_seed_normalized("");
206
207 let mint_url = self.get_mint_url(&meta)?;
208 let mint_url_parsed: cdk::mint_url::MintUrl = mint_url
209 .parse()
210 .map_err(|e| PayError::InternalError(format!("parse mint url: {e}")))?;
211
212 let localstore: Arc<
213 dyn cdk::cdk_database::WalletDatabase<cdk::cdk_database::Error> + Send + Sync,
214 > = if let Some(url) = &self.postgres_url {
215 #[cfg(feature = "postgres")]
216 {
217 let db = cdk_postgres::new_wallet_pg_database(url)
218 .await
219 .map_err(|e| PayError::InternalError(format!("cdk postgres: {e}")))?;
220 Arc::new(db)
221 }
222 #[cfg(not(feature = "postgres"))]
223 return Err(PayError::NotImplemented(format!(
224 "postgres feature not compiled (url: {url})"
225 )));
226 } else {
227 #[cfg(feature = "redb")]
228 {
229 let db_dir = self.store.wallet_data_directory_path_for_meta(&meta);
230 std::fs::create_dir_all(&db_dir)
231 .map_err(|e| PayError::InternalError(format!("create cashu db dir: {e}")))?;
232 let db = WalletRedbDatabase::new(&db_dir.join("cdk-wallet.redb"))
233 .map_err(|e| PayError::InternalError(format!("open redb: {e}")))?;
234 Arc::new(db)
235 }
236 #[cfg(not(feature = "redb"))]
237 return Err(PayError::NotImplemented(
238 "redb feature not compiled".to_string(),
239 ));
240 };
241
242 let wallet = WalletBuilder::new()
243 .mint_url(mint_url_parsed)
244 .unit(CurrencyUnit::Sat)
245 .localstore(localstore)
246 .seed(seed)
247 .build()
248 .map_err(|e| PayError::InternalError(format!("build cdk wallet: {e}")))?;
249
250 let wallet = Arc::new(wallet);
251
252 let mut cache = self.wallet_cache.write().await;
254 cache.insert(wallet_id.to_string(), wallet.clone());
255
256 Ok(wallet)
257 }
258}
259
260#[async_trait]
261impl PayProvider for CashuProvider {
262 fn network(&self) -> Network {
263 Network::Cashu
264 }
265
266 fn writes_locally(&self) -> bool {
267 true
268 }
269
270 async fn create_wallet(&self, request: &WalletCreateRequest) -> Result<WalletInfo, PayError> {
271 let id = wallet::generate_wallet_identifier()?;
272 let resolved_mint = request.mint_url.as_deref().ok_or_else(|| {
273 PayError::InvalidAmount("mint_url is required for cashu wallets".to_string())
274 })?;
275
276 let mnemonic_str = if let Some(raw) = request.mnemonic_secret.as_deref() {
277 let mnemonic: Mnemonic = raw.parse().map_err(|e| {
278 PayError::InvalidAmount(format!("invalid mnemonic-secret for cashu wallet: {e}"))
279 })?;
280 mnemonic.words().collect::<Vec<_>>().join(" ")
281 } else {
282 let mut entropy = [0u8; 16];
284 getrandom::fill(&mut entropy)
285 .map_err(|e| PayError::InternalError(format!("rng failed: {e}")))?;
286 let mnemonic = Mnemonic::from_entropy(&entropy)
287 .map_err(|e| PayError::InternalError(format!("mnemonic gen: {e}")))?;
288 mnemonic.words().collect::<Vec<_>>().join(" ")
289 };
290
291 let meta = WalletMetadata {
292 id: id.clone(),
293 network: Network::Cashu,
294 label: {
295 let trimmed = request.label.trim();
296 if trimmed.is_empty() || trimmed == "default" {
297 None
298 } else {
299 Some(trimmed.to_string())
300 }
301 },
302 mint_url: Some(normalize_mint_url(resolved_mint)),
303 sol_rpc_endpoints: None,
304 evm_rpc_endpoints: None,
305 evm_chain_id: None,
306 seed_secret: Some(mnemonic_str.clone()),
307 backend: None,
308 btc_esplora_url: None,
309 btc_network: None,
310 btc_address_type: None,
311 btc_core_url: None,
312 btc_core_auth_secret: None,
313 btc_electrum_url: None,
314 custom_tokens: None,
315 created_at_epoch_s: wallet::now_epoch_seconds(),
316 error: None,
317 };
318 self.store.save_wallet_metadata(&meta)?;
319
320 Ok(WalletInfo {
321 id,
322 network: Network::Cashu,
323 address: resolved_mint.to_string(),
324 label: meta.label,
325 mnemonic: None,
326 })
327 }
328
329 async fn close_wallet(&self, wallet_id: &str) -> Result<(), PayError> {
330 let bal = self.balance(wallet_id).await?;
332 if bal.confirmed > 0 || bal.pending > 0 {
333 return Err(PayError::InvalidAmount(format!(
334 "wallet {wallet_id} has {} confirmed + {} pending {}; send or withdraw first",
335 bal.confirmed, bal.pending, bal.unit
336 )));
337 }
338 {
340 let mut cache = self.wallet_cache.write().await;
341 cache.remove(wallet_id);
342 }
343 self.store.delete_wallet_metadata(wallet_id)?;
344 Ok(())
345 }
346
347 async fn list_wallets(&self) -> Result<Vec<WalletSummary>, PayError> {
348 let wallets = self.store.list_wallet_metadata(Some(Network::Cashu))?;
349 Ok(wallets.into_iter().map(cashu_wallet_summary).collect())
350 }
351
352 async fn balance(&self, wallet_id: &str) -> Result<BalanceInfo, PayError> {
353 let w = self.get_or_create_cdk_wallet(wallet_id).await?;
354 let confirmed = w
355 .total_balance()
356 .await
357 .map_err(|e| PayError::NetworkError(format!("balance: {e}")))?;
358 let pending = w
359 .total_pending_balance()
360 .await
361 .map_err(|e| PayError::NetworkError(format!("pending balance: {e}")))?;
362 Ok(BalanceInfo::new(
363 confirmed.to_u64(),
364 pending.to_u64(),
365 "sats",
366 ))
367 }
368
369 async fn check_balance(&self, wallet_id: &str) -> Result<BalanceInfo, PayError> {
370 let w = self.get_or_create_cdk_wallet(wallet_id).await?;
371
372 let unspent_proofs = w
374 .get_unspent_proofs()
375 .await
376 .map_err(|e| PayError::NetworkError(format!("get proofs: {e}")))?;
377 let states = if unspent_proofs.is_empty() {
378 vec![]
379 } else {
380 w.check_proofs_spent(unspent_proofs.clone())
381 .await
382 .map_err(|e| PayError::NetworkError(format!("check proofs: {e}")))?
383 };
384
385 let mut confirmed: u64 = 0;
387 for (proof, state) in unspent_proofs.iter().zip(states.iter()) {
388 if state.state == State::Unspent {
389 confirmed += proof.amount.to_u64();
390 }
391 }
392
393 let pending_amount = w
395 .check_all_pending_proofs()
396 .await
397 .map_err(|e| PayError::NetworkError(format!("check pending: {e}")))?;
398
399 Ok(BalanceInfo::new(confirmed, pending_amount.to_u64(), "sats"))
400 }
401
402 async fn restore(&self, wallet_id: &str) -> Result<RestoreResult, PayError> {
403 let w = self.get_or_create_cdk_wallet(wallet_id).await?;
404 let restored = w
405 .restore()
406 .await
407 .map_err(|e| PayError::NetworkError(format!("restore: {e}")))?;
408 Ok(RestoreResult {
409 wallet: wallet_id.to_string(),
410 unspent: restored.unspent.to_u64(),
411 spent: restored.spent.to_u64(),
412 pending: restored.pending.to_u64(),
413 unit: "sats".to_string(),
414 })
415 }
416
417 async fn balance_all(&self) -> Result<Vec<WalletBalanceItem>, PayError> {
418 let wallets = self.store.list_wallet_metadata(Some(Network::Cashu))?;
419 let mut items = Vec::new();
420 for meta in &wallets {
421 let w = self.get_or_create_cdk_wallet(&meta.id).await?;
422 let confirmed = w
423 .total_balance()
424 .await
425 .map_err(|e| PayError::NetworkError(format!("balance: {e}")))?;
426 let pending = w
427 .total_pending_balance()
428 .await
429 .map_err(|e| PayError::NetworkError(format!("pending balance: {e}")))?;
430 items.push(WalletBalanceItem {
431 wallet: cashu_wallet_summary(meta.clone()),
432 balance: Some(BalanceInfo::new(
433 confirmed.to_u64(),
434 pending.to_u64(),
435 "sats",
436 )),
437 error: None,
438 });
439 }
440 Ok(items)
441 }
442
443 async fn receive_info(
444 &self,
445 wallet_id: &str,
446 amount: Option<Amount>,
447 ) -> Result<ReceiveInfo, PayError> {
448 let w = self.get_or_create_cdk_wallet(wallet_id).await?;
449 let cdk_amount = amount.map(|a| CdkAmount::from(a.value));
450 let quote = w
451 .mint_quote(PaymentMethod::BOLT11, cdk_amount, None, None)
452 .await
453 .map_err(|e| PayError::NetworkError(format!("mint quote: {e}")))?;
454 Ok(ReceiveInfo {
455 address: None,
456 invoice: Some(quote.request),
457 quote_id: Some(quote.id),
458 })
459 }
460
461 async fn receive_claim(&self, wallet_id: &str, quote_id: &str) -> Result<u64, PayError> {
462 let w = self.get_or_create_cdk_wallet(wallet_id).await?;
463 let proofs = w
464 .mint(quote_id, cdk::amount::SplitTarget::default(), None)
465 .await
466 .map_err(|e| PayError::NetworkError(format!("mint: {e}")))?;
467 let total: u64 = proofs
468 .total_amount()
469 .map_err(|e| PayError::InternalError(format!("sum proofs: {e}")))?
470 .to_u64();
471
472 if self
474 .store
475 .find_transaction_record_by_id(quote_id)?
476 .is_none()
477 {
478 let now = wallet::now_epoch_seconds();
479 let record = HistoryRecord {
480 transaction_id: quote_id.to_string(),
481 wallet: wallet_id.to_string(),
482 network: Network::Cashu,
483 direction: Direction::Receive,
484 amount: Amount {
485 value: total,
486 token: "sats".to_string(),
487 },
488 status: TxStatus::Confirmed,
489 onchain_memo: Some("cashu mint claim".to_string()),
490 local_memo: None,
491 remote_addr: None,
492 preimage: None,
493 created_at_epoch_s: now,
494 confirmed_at_epoch_s: Some(now),
495 fee: None,
496 reference_keys: None,
497 };
498 let _ = self.store.append_transaction_record(&record);
499 }
500 Ok(total)
501 }
502
503 #[cfg(feature = "interactive")]
504 async fn cashu_send_quote(
505 &self,
506 wallet_id: &str,
507 amount: &Amount,
508 ) -> Result<CashuSendQuoteInfo, PayError> {
509 let resolved = if wallet_id.is_empty() {
510 self.select_wallet_by_balance(amount.value, true, None)
511 .await?
512 } else {
513 wallet_id.to_string()
514 };
515 let w = self.get_or_create_cdk_wallet(&resolved).await?;
516 let cdk_amount = CdkAmount::from(amount.value);
517 let send_options = SendOptions {
518 include_fee: true,
519 ..SendOptions::default()
520 };
521 let prepared = w
522 .prepare_send(cdk_amount, send_options)
523 .await
524 .map_err(|e| PayError::NetworkError(format!("prepare send: {e}")))?;
525 let fee_sats = prepared.fee().to_u64();
526 let _ = prepared.cancel().await;
528 Ok(CashuSendQuoteInfo {
529 wallet: resolved,
530 amount_native: amount.value,
531 fee_native: fee_sats,
532 fee_unit: "sats".to_string(),
533 })
534 }
535
536 async fn cashu_send(
537 &self,
538 wallet_id: &str,
539 amount: Amount,
540 onchain_memo: Option<&str>,
541 mints: Option<&[String]>,
542 ) -> Result<CashuSendResult, PayError> {
543 let resolved = if wallet_id.is_empty() {
544 self.select_wallet_by_balance(amount.value, true, mints)
545 .await?
546 } else if let Some(mint_list) = mints {
547 let meta = self.store.load_wallet_metadata(wallet_id)?;
549 if let Some(url) = &meta.mint_url {
550 let normalized = normalize_mint_url(url);
551 if !mint_list
552 .iter()
553 .any(|m| normalize_mint_url(m) == normalized)
554 {
555 return Err(PayError::InvalidAmount(format!(
556 "wallet {wallet_id} is on mint {url}, not in accepted mints: {}",
557 mint_list.join(", ")
558 )));
559 }
560 }
561 wallet_id.to_string()
562 } else {
563 wallet_id.to_string()
564 };
565 let w = self.get_or_create_cdk_wallet(&resolved).await?;
566 let transaction_id = wallet::generate_transaction_identifier()?;
567 let balance_before_send = w
568 .total_balance()
569 .await
570 .map_err(|e| PayError::NetworkError(format!("balance before send: {e}")))?
571 .to_u64();
572
573 let cdk_amount = CdkAmount::from(amount.value);
575 let send_options = SendOptions {
576 include_fee: true,
577 ..SendOptions::default()
578 };
579 let prepared = w
580 .prepare_send(cdk_amount, send_options)
581 .await
582 .map_err(|e| PayError::NetworkError(format!("prepare send: {e}")))?;
583
584 let token = prepared
585 .confirm(None)
586 .await
587 .map_err(|e| PayError::NetworkError(format!("confirm send: {e}")))?;
588
589 let balance_after_send = w
590 .total_balance()
591 .await
592 .map_err(|e| PayError::NetworkError(format!("balance after send: {e}")))?
593 .to_u64();
594 let total_spent = balance_before_send.saturating_sub(balance_after_send);
595 let fee_sats = total_spent.saturating_sub(amount.value);
596
597 let token_str = token.to_string();
598
599 let fee_amount = if fee_sats > 0 {
600 Some(Amount {
601 value: fee_sats,
602 token: "sats".to_string(),
603 })
604 } else {
605 None
606 };
607 let record = HistoryRecord {
608 transaction_id: transaction_id.clone(),
609 wallet: resolved.clone(),
610 network: Network::Cashu,
611 direction: Direction::Send,
612 amount: amount.clone(),
613 status: TxStatus::Confirmed,
614 onchain_memo: onchain_memo.map(|s| s.to_string()),
615 local_memo: None,
616 remote_addr: None,
617 preimage: None,
618 created_at_epoch_s: wallet::now_epoch_seconds(),
619 confirmed_at_epoch_s: Some(wallet::now_epoch_seconds()),
620 fee: fee_amount.clone(),
621 reference_keys: None,
622 };
623 let _ = self.store.append_transaction_record(&record);
624
625 Ok(CashuSendResult {
626 wallet: resolved,
627 transaction_id,
628 status: TxStatus::Confirmed,
629 fee: fee_amount,
630 token: token_str,
631 })
632 }
633
634 async fn cashu_receive(
635 &self,
636 wallet_id: &str,
637 token: &str,
638 ) -> Result<CashuReceiveResult, PayError> {
639 let resolved_wallet = if wallet_id.is_empty() {
640 let parsed = Token::from_str(token)
642 .map_err(|e| PayError::InvalidAmount(format!("parse token: {e}")))?;
643 let mint_url_str = normalize_mint_url(
644 &parsed
645 .mint_url()
646 .map_err(|e| PayError::InvalidAmount(format!("token mint_url: {e}")))?
647 .to_string(),
648 );
649
650 let wallets = self.store.list_wallet_metadata(Some(Network::Cashu))?;
652 if let Some(w) = wallets
653 .iter()
654 .find(|w| w.mint_url.as_deref() == Some(mint_url_str.as_str()))
655 {
656 w.id.clone()
657 } else {
658 self.create_wallet(&WalletCreateRequest {
660 label: "default".to_string(),
661 mint_url: Some(mint_url_str.clone()),
662 rpc_endpoints: vec![],
663 chain_id: None,
664 mnemonic_secret: None,
665 btc_esplora_url: None,
666 btc_network: None,
667 btc_address_type: None,
668 btc_backend: None,
669 btc_core_url: None,
670 btc_core_auth_secret: None,
671 btc_electrum_url: None,
672 })
673 .await?
674 .id
675 }
676 } else {
677 let parsed = Token::from_str(token)
679 .map_err(|e| PayError::InvalidAmount(format!("parse token: {e}")))?;
680 let token_mint = normalize_mint_url(
681 &parsed
682 .mint_url()
683 .map_err(|e| PayError::InvalidAmount(format!("token mint_url: {e}")))?
684 .to_string(),
685 );
686 let meta = self.store.load_wallet_metadata(wallet_id)?;
687 if let Some(wallet_mint) = meta.mint_url.as_deref() {
688 if normalize_mint_url(wallet_mint) != token_mint {
689 return Err(PayError::InvalidAmount(format!(
690 "token mint ({token_mint}) does not match wallet {} mint ({wallet_mint})",
691 wallet_id
692 )));
693 }
694 }
695 wallet_id.to_string()
696 };
697
698 let w = self.get_or_create_cdk_wallet(&resolved_wallet).await?;
699 let transaction_id = wallet::generate_transaction_identifier()?;
700
701 let received = w
702 .receive(token, ReceiveOptions::default())
703 .await
704 .map_err(|e| PayError::NetworkError(format!("receive: {e}")))?;
705
706 let sats = received.to_u64();
707
708 let record = HistoryRecord {
709 transaction_id,
710 wallet: resolved_wallet.clone(),
711 network: Network::Cashu,
712 direction: Direction::Receive,
713 amount: Amount {
714 value: sats,
715 token: "sats".to_string(),
716 },
717 status: TxStatus::Confirmed,
718 onchain_memo: Some("receive cashu token".to_string()),
719 local_memo: None,
720 remote_addr: None,
721 preimage: None,
722 created_at_epoch_s: wallet::now_epoch_seconds(),
723 confirmed_at_epoch_s: Some(wallet::now_epoch_seconds()),
724 fee: None,
725 reference_keys: None,
726 };
727 let _ = self.store.append_transaction_record(&record);
728
729 Ok(CashuReceiveResult {
730 wallet: resolved_wallet,
731 amount: Amount {
732 value: sats,
733 token: "sats".to_string(),
734 },
735 })
736 }
737
738 async fn send_quote(
739 &self,
740 wallet_id: &str,
741 to: &str,
742 mints: Option<&[String]>,
743 ) -> Result<SendQuoteInfo, PayError> {
744 let resolved = if wallet_id.is_empty() {
745 self.select_wallet_by_balance(1, false, mints).await?
746 } else {
747 wallet_id.to_string()
748 };
749 let w = self.get_or_create_cdk_wallet(&resolved).await?;
750
751 let quote = w
752 .melt_quote(PaymentMethod::BOLT11, to, None, None)
753 .await
754 .map_err(|e| PayError::NetworkError(format!("melt quote: {e}")))?;
755
756 Ok(SendQuoteInfo {
757 wallet: resolved,
758 amount_native: quote.amount.to_u64(),
759 fee_estimate_native: quote.fee_reserve.to_u64(),
760 fee_unit: "sats".to_string(),
761 })
762 }
763
764 async fn send(
765 &self,
766 wallet_id: &str,
767 to: &str,
768 onchain_memo: Option<&str>,
769 mints: Option<&[String]>,
770 ) -> Result<SendResult, PayError> {
771 let resolved = if wallet_id.is_empty() {
772 self.select_wallet_by_balance(1, false, mints).await?
774 } else {
775 wallet_id.to_string()
776 };
777 let w = self.get_or_create_cdk_wallet(&resolved).await?;
778 let transaction_id = wallet::generate_transaction_identifier()?;
779
780 let quote = w
781 .melt_quote(PaymentMethod::BOLT11, to, None, None)
782 .await
783 .map_err(|e| PayError::NetworkError(format!("melt quote: {e}")))?;
784
785 let prepared = w
786 .prepare_melt("e.id, HashMap::new())
787 .await
788 .map_err(|e| PayError::NetworkError(format!("prepare melt: {e}")))?;
789
790 let finalized = prepared
791 .confirm()
792 .await
793 .map_err(|e| PayError::NetworkError(format!("confirm melt: {e}")))?;
794
795 let fee_sats = finalized.fee_paid().to_u64();
796 let amount_sats = quote.amount.to_u64();
797 let amount = Amount {
798 value: amount_sats,
799 token: "sats".to_string(),
800 };
801
802 let fee_amount = if fee_sats > 0 {
803 Some(Amount {
804 value: fee_sats,
805 token: "sats".to_string(),
806 })
807 } else {
808 None
809 };
810 let record = HistoryRecord {
811 transaction_id: transaction_id.clone(),
812 wallet: resolved.clone(),
813 network: Network::Cashu,
814 direction: Direction::Send,
815 amount: amount.clone(),
816 status: TxStatus::Confirmed,
817 onchain_memo: onchain_memo
818 .map(|s| s.to_string())
819 .or(Some("withdraw to Lightning".to_string())),
820 local_memo: None,
821 remote_addr: Some(to.to_string()),
822 preimage: None,
823 created_at_epoch_s: wallet::now_epoch_seconds(),
824 confirmed_at_epoch_s: Some(wallet::now_epoch_seconds()),
825 fee: fee_amount.clone(),
826 reference_keys: None,
827 };
828 let _ = self.store.append_transaction_record(&record);
829
830 Ok(SendResult {
831 wallet: resolved,
832 transaction_id,
833 amount,
834 fee: fee_amount,
835 preimage: None,
836 })
837 }
838
839 async fn history_list(
840 &self,
841 wallet_id: &str,
842 limit: usize,
843 offset: usize,
844 ) -> Result<Vec<HistoryRecord>, PayError> {
845 let meta = self.store.load_wallet_metadata(wallet_id)?;
847 if meta.network != Network::Cashu {
848 return Err(PayError::WalletNotFound(format!(
849 "{wallet_id} is not a cashu wallet"
850 )));
851 }
852 let all = self.store.load_wallet_transaction_records(wallet_id)?;
853 let end = all.len().min(offset + limit);
854 let start = all.len().min(offset);
855 Ok(all[start..end].to_vec())
856 }
857
858 async fn history_status(&self, transaction_id: &str) -> Result<HistoryStatusInfo, PayError> {
859 match self.store.find_transaction_record_by_id(transaction_id)? {
860 Some(rec) => Ok(HistoryStatusInfo {
861 transaction_id: rec.transaction_id.clone(),
862 status: rec.status,
863 confirmations: None,
864 preimage: rec.preimage.clone(),
865 item: Some(rec),
866 }),
867 None => Err(PayError::WalletNotFound(format!(
868 "transaction {transaction_id} not found"
869 ))),
870 }
871 }
872
873 async fn history_sync(
874 &self,
875 wallet_id: &str,
876 limit: usize,
877 ) -> Result<crate::provider::HistorySyncStats, PayError> {
878 let records = self.history_list(wallet_id, limit, 0).await?;
879 Ok(crate::provider::HistorySyncStats {
880 records_scanned: records.len(),
881 records_added: 0,
882 records_updated: 0,
883 })
884 }
885}
886
887#[cfg(test)]
888mod tests {
889 use super::*;
890
891 #[cfg(feature = "redb")]
892 fn test_store(data_dir: &str) -> Arc<StorageBackend> {
893 Arc::new(crate::store::StorageBackend::Redb(
894 crate::store::redb_store::RedbStore::new(data_dir),
895 ))
896 }
897
898 #[cfg(feature = "redb")]
900 #[tokio::test]
901 async fn create_and_list_wallets_redb() {
902 let tmp = tempfile::tempdir().unwrap();
903 let dir = tmp.path().to_str().unwrap();
904 let store = test_store(dir);
905 let provider = CashuProvider::new(dir, None, store);
906
907 let w = provider
908 .create_wallet(&WalletCreateRequest {
909 label: "test".to_string(),
910 mint_url: Some("https://mint.example.com".to_string()),
911 rpc_endpoints: vec![],
912 chain_id: None,
913 mnemonic_secret: None,
914 btc_esplora_url: None,
915 btc_network: None,
916 btc_address_type: None,
917 btc_backend: None,
918 btc_core_url: None,
919 btc_core_auth_secret: None,
920 btc_electrum_url: None,
921 })
922 .await
923 .unwrap();
924
925 assert_eq!(w.network, Network::Cashu);
926 assert_eq!(w.address, "https://mint.example.com");
927
928 let wallets = provider.list_wallets().await.unwrap();
929 assert_eq!(wallets.len(), 1);
930 assert_eq!(wallets[0].id, w.id);
931 assert_eq!(
932 wallets[0].mint_url.as_deref(),
933 Some("https://mint.example.com")
934 );
935 }
936
937 #[cfg(all(feature = "redb", feature = "postgres"))]
940 #[tokio::test]
941 async fn cdk_postgres_url_errors_without_server() {
942 let tmp = tempfile::tempdir().unwrap();
943 let dir = tmp.path().to_str().unwrap();
944 let store = test_store(dir);
945 let provider = CashuProvider::new(
946 dir,
947 Some("postgres://invalid:5432/nonexistent".to_string()),
948 store,
949 );
950
951 let w = provider
953 .create_wallet(&WalletCreateRequest {
954 label: "pg-test".to_string(),
955 mint_url: Some("https://mint.example.com".to_string()),
956 rpc_endpoints: vec![],
957 chain_id: None,
958 mnemonic_secret: None,
959 btc_esplora_url: None,
960 btc_network: None,
961 btc_address_type: None,
962 btc_backend: None,
963 btc_core_url: None,
964 btc_core_auth_secret: None,
965 btc_electrum_url: None,
966 })
967 .await
968 .unwrap();
969
970 let err = provider.balance(&w.id).await.unwrap_err();
972 let msg = err.to_string();
973 assert!(
974 msg.contains("cdk postgres"),
975 "expected cdk postgres error, got: {msg}"
976 );
977 }
978
979 #[cfg(feature = "redb")]
982 #[tokio::test]
983 async fn cdk_redb_creates_database_file() {
984 let tmp = tempfile::tempdir().unwrap();
985 let dir = tmp.path().to_str().unwrap();
986 let store = test_store(dir);
987 let provider = CashuProvider::new(dir, None, store);
988
989 let w = provider
990 .create_wallet(&WalletCreateRequest {
991 label: "redb-cdk".to_string(),
992 mint_url: Some("https://mint.example.com".to_string()),
993 rpc_endpoints: vec![],
994 chain_id: None,
995 mnemonic_secret: None,
996 btc_esplora_url: None,
997 btc_network: None,
998 btc_address_type: None,
999 btc_backend: None,
1000 btc_core_url: None,
1001 btc_core_auth_secret: None,
1002 btc_electrum_url: None,
1003 })
1004 .await
1005 .unwrap();
1006
1007 let _ = provider.balance(&w.id).await;
1010
1011 let meta = provider.store.load_wallet_metadata(&w.id).unwrap();
1013 let db_dir = provider.store.wallet_data_directory_path_for_meta(&meta);
1014 let redb_path = db_dir.join("cdk-wallet.redb");
1015 assert!(
1016 redb_path.exists(),
1017 "cdk-wallet.redb should be created at {redb_path:?}"
1018 );
1019 }
1020
1021 #[cfg(feature = "redb")]
1022 #[tokio::test]
1023 async fn select_wallet_skips_invalid_wallet_metadata() {
1024 let tmp = tempfile::tempdir().unwrap();
1025 let dir = tmp.path().to_str().unwrap();
1026 let store = test_store(dir);
1027 let provider = CashuProvider::new(dir, None, store);
1028
1029 let healthy = provider
1030 .create_wallet(&WalletCreateRequest {
1031 label: "healthy".to_string(),
1032 mint_url: Some("https://mint.example.com".to_string()),
1033 rpc_endpoints: vec![],
1034 chain_id: None,
1035 mnemonic_secret: None,
1036 btc_esplora_url: None,
1037 btc_network: None,
1038 btc_address_type: None,
1039 btc_backend: None,
1040 btc_core_url: None,
1041 btc_core_auth_secret: None,
1042 btc_electrum_url: None,
1043 })
1044 .await
1045 .unwrap();
1046
1047 let bad_id = wallet::generate_wallet_identifier().unwrap();
1048 provider
1049 .store
1050 .save_wallet_metadata(&WalletMetadata {
1051 id: bad_id,
1052 network: Network::Cashu,
1053 label: Some("broken".to_string()),
1054 mint_url: Some("https://mint.example.com".to_string()),
1055 sol_rpc_endpoints: None,
1056 evm_rpc_endpoints: None,
1057 evm_chain_id: None,
1058 seed_secret: Some("not a mnemonic".to_string()),
1059 backend: None,
1060 btc_esplora_url: None,
1061 btc_network: None,
1062 btc_address_type: None,
1063 btc_core_url: None,
1064 btc_core_auth_secret: None,
1065 btc_electrum_url: None,
1066 custom_tokens: None,
1067 created_at_epoch_s: wallet::now_epoch_seconds(),
1068 error: None,
1069 })
1070 .unwrap();
1071
1072 let selected = provider
1073 .select_wallet_by_balance(0, true, None)
1074 .await
1075 .unwrap();
1076 assert_eq!(
1077 selected, healthy.id,
1078 "wallet selection should skip invalid wallet metadata"
1079 );
1080 }
1081
1082 #[cfg(feature = "redb")]
1083 #[tokio::test]
1084 async fn select_wallet_reports_unavailable_when_all_wallets_fail() {
1085 let tmp = tempfile::tempdir().unwrap();
1086 let dir = tmp.path().to_str().unwrap();
1087 let store = test_store(dir);
1088 let provider = CashuProvider::new(dir, None, store);
1089
1090 let bad_id = wallet::generate_wallet_identifier().unwrap();
1091 provider
1092 .store
1093 .save_wallet_metadata(&WalletMetadata {
1094 id: bad_id,
1095 network: Network::Cashu,
1096 label: Some("broken".to_string()),
1097 mint_url: Some("https://mint.example.com".to_string()),
1098 sol_rpc_endpoints: None,
1099 evm_rpc_endpoints: None,
1100 evm_chain_id: None,
1101 seed_secret: Some("not a mnemonic".to_string()),
1102 backend: None,
1103 btc_esplora_url: None,
1104 btc_network: None,
1105 btc_address_type: None,
1106 btc_core_url: None,
1107 btc_core_auth_secret: None,
1108 btc_electrum_url: None,
1109 custom_tokens: None,
1110 created_at_epoch_s: wallet::now_epoch_seconds(),
1111 error: None,
1112 })
1113 .unwrap();
1114
1115 let err = provider
1116 .select_wallet_by_balance(0, true, None)
1117 .await
1118 .unwrap_err();
1119 assert!(
1120 matches!(err, PayError::NetworkError(_)),
1121 "expected NetworkError, got: {err}"
1122 );
1123 }
1124
1125 #[test]
1126 fn bip39_roundtrip() {
1127 let mut entropy = [0u8; 16];
1128 getrandom::fill(&mut entropy).ok();
1129 let mnemonic = Mnemonic::from_entropy(&entropy).unwrap();
1130 let words: Vec<&str> = mnemonic.words().collect();
1131 assert_eq!(
1132 words.len(),
1133 12,
1134 "BIP39 128-bit entropy should produce 12 words"
1135 );
1136
1137 let mnemonic_str = words.join(" ");
1138 let parsed: Mnemonic = mnemonic_str.parse().unwrap();
1139 let seed = parsed.to_seed_normalized("");
1140 assert_eq!(seed.len(), 64, "BIP39 seed should be 64 bytes");
1141
1142 let seed2 = mnemonic.to_seed_normalized("");
1144 assert_eq!(seed, seed2);
1145 }
1146}