Skip to main content

agent_first_pay/provider/
cashu.rs

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
17/// Normalize mint URL per NUT-00: strip trailing slashes.
18fn 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        // Best-effort balance collection: one broken wallet should not block routing.
71        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 mints filter provided, try each mint in order (caller's priority)
106        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            // Try mints in order
111            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            // No match — build a helpful error
133            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        // No mints filter — original behavior
162        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        // Check cache first
183        {
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        // Load wallet metadata
191        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        // Cache
253        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            // Generate BIP39 12-word mnemonic (128-bit entropy)
283            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        // Check balance first — only allow closing zero-balance wallets
331        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        // Remove from cache
339        {
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        // Check unspent proofs against the mint
373        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        // Sum only truly unspent
386        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        // Also check pending proofs for recovery
394        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        // Persist claim as a receive history item so history/status can track mint deposits.
473        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        // Release reserved proofs — this is quote-only
527        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            // Explicit wallet — validate it's on an accepted mint
548            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        // P2P cashu token send
574        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            // Parse token to extract mint_url
641            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            // Find existing wallet with matching mint_url
651            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                // Auto-create wallet for this mint
659                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            // Validate mint URL matches the wallet's mint
678            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            // Select wallet with largest balance for withdraw
773            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(&quote.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        // Verify wallet exists and is cashu
846        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    /// CashuProvider with redb store: create wallet, list, load metadata.
899    #[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    /// CashuProvider with postgres_url=Some but redb store: CDK wallet should
938    /// attempt postgres. Without a real PG server we just verify the error path.
939    #[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        // Create a wallet (metadata stored in redb)
952        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        // get_or_create_cdk_wallet should try cdk-postgres and fail
971        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    /// CashuProvider with postgres_url=None: CDK wallet uses redb localstore.
980    /// Verify the redb CDK database file is created.
981    #[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        // Trigger CDK wallet creation (will fail to connect to mint, but
1008        // the redb database file should be created before the network call)
1009        let _ = provider.balance(&w.id).await;
1010
1011        // Check that the cdk-wallet.redb file exists
1012        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        // Same mnemonic should produce same seed
1143        let seed2 = mnemonic.to_seed_normalized("");
1144        assert_eq!(seed, seed2);
1145    }
1146}