//! Cashu ecash wallet for peer-to-peer micropayments and streaming data payments. //! //! Real Cashu protocol implementation using BDHKE blind signatures. //! Connects to Cashu-compatible mints (local Fedimint or external) for //! mint/melt/swap operations. Stores proofs locally in the data directory. use super::cashu::{amount_to_denominations, CashuToken, Proof}; use super::mint_client::MintClient; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::path::Path; use tokio::fs; use tracing::{debug, info, warn}; const WALLET_FILE: &str = "wallet/ecash.json"; const MINTS_FILE: &str = "wallet/accepted_mints.json"; /// Transaction type for history tracking. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum TransactionType { Mint, Melt, Send, Receive, StreamingPayment, StreamingRevenue, } /// Transaction history entry. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EcashTransaction { pub id: String, pub tx_type: TransactionType, pub amount_sats: u64, pub timestamp: String, #[serde(default)] pub description: String, /// Mint URL involved in this transaction. #[serde(default)] pub mint_url: String, /// Peer identifier (DID, pubkey, or onion) if applicable. #[serde(default)] pub peer: String, } /// A stored proof with metadata. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StoredProof { /// The Cashu proof. #[serde(flatten)] pub proof: Proof, /// Mint URL this proof is from. pub mint_url: String, /// Whether this proof has been spent (sent to someone or melted). #[serde(default)] pub spent: bool, /// Whether this proof is reserved (allocated to an in-progress operation). #[serde(default)] pub reserved: bool, /// Timestamp when received. pub created_at: String, } /// Persistent wallet state. #[derive(Debug, Default, Serialize, Deserialize)] pub struct WalletState { /// All proofs (spent and unspent). pub proofs: Vec, /// Transaction history. pub transactions: Vec, /// Primary mint URL. #[serde(default)] pub mint_url: String, // ── Legacy compatibility ── // Old wallet format had a `tokens` field. If present during deserialization, // we migrate to proofs. This field is never written. #[serde(default, skip_serializing)] tokens: Vec, } /// Accepted mints configuration. #[derive(Debug, Default, Serialize, Deserialize)] pub struct AcceptedMints { /// List of mint URLs we accept tokens from. pub mints: Vec, } impl WalletState { /// Total balance of unspent, unreserved proofs. pub fn balance(&self) -> u64 { self.proofs .iter() .filter(|p| !p.spent && !p.reserved) .map(|p| p.proof.amount) .sum() } /// Balance from a specific mint. pub fn balance_for_mint(&self, mint_url: &str) -> u64 { self.proofs .iter() .filter(|p| !p.spent && !p.reserved && p.mint_url == mint_url) .map(|p| p.proof.amount) .sum() } /// Spendable (unspent, unreserved) balance grouped by mint URL. pub fn spendable_by_mint(&self) -> Vec<(String, u64)> { use std::collections::BTreeMap; let mut by_mint: BTreeMap = BTreeMap::new(); for p in &self.proofs { if !p.spent && !p.reserved { *by_mint.entry(p.mint_url.clone()).or_default() += p.proof.amount; } } by_mint.into_iter().collect() } /// Select unspent proofs that cover at least `amount` sats from a specific mint. /// Returns selected proofs and any overpayment amount. pub fn select_proofs(&self, mint_url: &str, amount: u64) -> Option<(Vec, u64)> { let mut candidates: Vec<(usize, u64)> = self .proofs .iter() .enumerate() .filter(|(_, p)| !p.spent && !p.reserved && p.mint_url == mint_url) .map(|(i, p)| (i, p.proof.amount)) .collect(); // Sort by amount ascending for efficient selection candidates.sort_by_key(|&(_, a)| a); let mut selected = Vec::new(); let mut total = 0u64; for (idx, amt) in &candidates { if total >= amount { break; } selected.push(*idx); total += amt; } if total >= amount { Some((selected, total - amount)) } else { None } } /// Mark proofs at given indices as spent. pub fn mark_spent(&mut self, indices: &[usize]) { for &i in indices { if i < self.proofs.len() { self.proofs[i].spent = true; } } } /// Add new proofs to the wallet. pub fn add_proofs(&mut self, mint_url: &str, proofs: Vec) { let now = chrono::Utc::now().to_rfc3339(); for proof in proofs { self.proofs.push(StoredProof { proof, mint_url: mint_url.to_string(), spent: false, reserved: false, created_at: now.clone(), }); } } /// Record a transaction. pub fn record_tx( &mut self, tx_type: TransactionType, amount_sats: u64, description: &str, mint_url: &str, peer: &str, ) { self.transactions.push(EcashTransaction { id: uuid::Uuid::new_v4().to_string(), tx_type, amount_sats, timestamp: chrono::Utc::now().to_rfc3339(), description: description.to_string(), mint_url: mint_url.to_string(), peer: peer.to_string(), }); } /// Prune spent proofs older than 30 days to keep wallet file manageable. pub fn prune_old_spent(&mut self) { let cutoff = chrono::Utc::now() - chrono::Duration::days(30); let cutoff_str = cutoff.to_rfc3339(); self.proofs .retain(|p| !p.spent || p.created_at > cutoff_str); } } /// Load wallet state from disk. pub async fn load_wallet(data_dir: &Path) -> Result { let path = data_dir.join(WALLET_FILE); if !path.exists() { return Ok(WalletState { mint_url: default_mint_url(), ..Default::default() }); } let content = fs::read_to_string(&path) .await .context("Failed to read wallet file")?; let mut wallet: WalletState = serde_json::from_str(&content).unwrap_or_default(); // Set default mint URL if empty if wallet.mint_url.is_empty() { wallet.mint_url = default_mint_url(); } Ok(wallet) } /// Save wallet state to disk. pub async fn save_wallet(data_dir: &Path, wallet: &WalletState) -> Result<()> { let dir = data_dir.join("wallet"); fs::create_dir_all(&dir) .await .context("Failed to create wallet dir")?; let path = data_dir.join(WALLET_FILE); let content = serde_json::to_string_pretty(wallet).context("Failed to serialize wallet")?; fs::write(&path, content) .await .context("Failed to write wallet file")?; Ok(()) } /// Load accepted mints list. pub async fn load_accepted_mints(data_dir: &Path) -> Result { let path = data_dir.join(MINTS_FILE); if !path.exists() { return Ok(AcceptedMints { mints: vec![default_mint_url()], }); } let content = fs::read_to_string(&path) .await .context("Failed to read accepted mints")?; let mints: AcceptedMints = serde_json::from_str(&content).unwrap_or(AcceptedMints { mints: vec![default_mint_url()], }); Ok(mints) } /// Save accepted mints list. pub async fn save_accepted_mints(data_dir: &Path, mints: &AcceptedMints) -> Result<()> { let dir = data_dir.join("wallet"); fs::create_dir_all(&dir) .await .context("Failed to create wallet dir")?; let path = data_dir.join(MINTS_FILE); let content = serde_json::to_string_pretty(mints).context("Failed to serialize accepted mints")?; fs::write(&path, content) .await .context("Failed to write accepted mints")?; Ok(()) } /// Request a mint quote — returns a Lightning invoice to pay. pub async fn mint_quote( data_dir: &Path, amount_sats: u64, ) -> Result { let wallet = load_wallet(data_dir).await?; let client = MintClient::new(&wallet.mint_url)?; client.mint_quote(amount_sats).await } /// Mint new ecash tokens after a Lightning invoice has been paid. pub async fn mint_tokens(data_dir: &Path, quote_id: &str, amount_sats: u64) -> Result { let mut wallet = load_wallet(data_dir).await?; let mint_url = wallet.mint_url.clone(); let client = MintClient::new(&mint_url)?; let result = client.mint_tokens(quote_id, amount_sats).await?; let minted: u64 = result.proofs.iter().map(|p| p.amount).sum(); wallet.add_proofs(&mint_url, result.proofs); wallet.record_tx( TransactionType::Mint, minted, &format!("Minted {} sats from Lightning", minted), &mint_url, "", ); save_wallet(data_dir, &wallet).await?; debug!("Minted {} sats ecash", minted); Ok(minted) } /// Request a melt quote — how much to pay a Lightning invoice with ecash. pub async fn melt_quote(data_dir: &Path, bolt11: &str) -> Result { let wallet = load_wallet(data_dir).await?; let client = MintClient::new(&wallet.mint_url)?; client.melt_quote(bolt11).await } /// Melt ecash tokens to pay a Lightning invoice. pub async fn melt_tokens(data_dir: &Path, quote_id: &str, bolt11: &str) -> Result { let mut wallet = load_wallet(data_dir).await?; let mint_url = wallet.mint_url.clone(); let client = MintClient::new(&mint_url)?; // Get the melt quote to know the amount needed let quote = client.melt_quote(bolt11).await?; let total_needed = quote.amount + quote.fee_reserve; // Select proofs to cover the amount let (indices, _overpayment) = wallet .select_proofs(&mint_url, total_needed) .ok_or_else(|| { anyhow::anyhow!( "Insufficient balance: need {} sats, have {} sats", total_needed, wallet.balance_for_mint(&mint_url) ) })?; let proofs: Vec = indices .iter() .map(|&i| wallet.proofs[i].proof.clone()) .collect(); let spent_amount: u64 = proofs.iter().map(|p| p.amount).sum(); // Execute melt let result = client.melt_tokens(quote_id, &proofs).await?; // Mark proofs as spent wallet.mark_spent(&indices); wallet.record_tx( TransactionType::Melt, quote.amount, &format!( "Melted {} sats to Lightning (fee: {})", quote.amount, quote.fee_reserve ), &mint_url, "", ); // If there was overpayment and the melt returned change, we could handle it here. // For now, any overpayment beyond fee_reserve is lost (mints may return change in future NUTs). let _ = result; let _ = spent_amount; save_wallet(data_dir, &wallet).await?; debug!("Melted {} sats to Lightning", quote.amount); Ok(quote.amount) } // ── Cross-mint settlement (plan §2a / phasing F2) ────────────────────────── // // The wallet data model is already multi-mint (proofs, balances and selection // are all keyed by mint URL). What was hardcoded to the home mint is the // convenience layer. These `*_at` helpers parameterize that layer by target // mint, and `swap_between_mints` moves value across mints over Lightning so a // node holding tokens on mint A can pay a seeder that only accepts mint B. /// How long to wait for a target mint's Lightning invoice to settle before /// claiming the freshly-minted tokens. const SWAP_CLAIM_TIMEOUT_SECS: u64 = 60; /// Poll interval while waiting for the invoice to settle. const SWAP_CLAIM_POLL_SECS: u64 = 2; /// Whether we trust a mint enough to swap value *into* it (or accept its tokens). /// /// The local Fedimint (home mint) is always trusted; any other mint must be on /// the configured accepted-mints allow-list. Comparison ignores a trailing /// slash so advertised URLs match stored ones. See plan §2a "Mint trust list". pub async fn is_mint_trusted(data_dir: &Path, mint_url: &str) -> Result { let norm = |s: &str| s.trim_end_matches('/').to_string(); let target = norm(mint_url); if target == norm(&default_mint_url()) { return Ok(true); } let accepted = load_accepted_mints(data_dir).await?; Ok(accepted.mints.iter().any(|m| norm(m) == target)) } /// All-in cost of a swap, relative to the amount actually delivered. /// /// `total_paid` is what the source mint charges (melt amount + LN fee reserve); /// `amount_delivered` is what lands on the target mint. The difference is the /// fee the user pays to move value across mints. fn swap_fee(total_paid: u64, amount_delivered: u64) -> u64 { total_paid.saturating_sub(amount_delivered) } /// Move `amount_sats` of value from mint `from_mint` to mint `to_mint` over /// Lightning, returning the amount claimed on the target mint. /// /// Flow (Cashu/Fedimint settle over BOLT11): /// 1. `mint_quote` on the target → a Lightning invoice to pay. /// 2. `melt_quote` on the source → the cost in source tokens (amount + fee). /// 3. Fee-cap check: refuse if the all-in fee exceeds `max_fee_sats`. /// 4. Select source proofs and `melt` them to pay the target's invoice. /// 5. Once the invoice settles, `mint` (claim) the tokens on the target. /// /// Crash-safety: the source spend is persisted *before* the claim, so a crash /// between paying and claiming never double-spends — at worst the target tokens /// are left unclaimed (reconcilable from the mint quote id). Idempotent resume /// is phasing step 3 (deferred). pub async fn swap_between_mints( data_dir: &Path, from_mint: &str, to_mint: &str, amount_sats: u64, max_fee_sats: u64, ) -> Result { if amount_sats == 0 { anyhow::bail!("swap amount must be greater than zero"); } let norm = |s: &str| s.trim_end_matches('/').to_string(); if norm(from_mint) == norm(to_mint) { anyhow::bail!("swap source and target mints are identical"); } if !is_mint_trusted(data_dir, to_mint).await? { anyhow::bail!( "target mint '{}' is not in the trusted/accepted mint list", to_mint ); } let from = MintClient::new(from_mint)?; let to = MintClient::new(to_mint)?; // 1. Mint quote on the target → invoice to pay. let mint_quote = to .mint_quote(amount_sats) .await .with_context(|| format!("requesting mint quote at target mint {}", to_mint))?; // 2. Melt quote on the source for that invoice → cost in source tokens. let melt_quote = from .melt_quote(&mint_quote.request) .await .with_context(|| format!("requesting melt quote at source mint {}", from_mint))?; let total_needed = melt_quote.amount + melt_quote.fee_reserve; let fee = swap_fee(total_needed, amount_sats); // 3. Fee-cap check — caller falls back to free origin if too expensive. if fee > max_fee_sats { anyhow::bail!( "swap fee {} sats exceeds cap {} sats (need {} on {} to deliver {} on {})", fee, max_fee_sats, total_needed, from_mint, amount_sats, to_mint ); } // 4. Select source proofs and melt them to pay the target invoice. let mut wallet = load_wallet(data_dir).await?; let (indices, _overpayment) = wallet .select_proofs(from_mint, total_needed) .ok_or_else(|| { anyhow::anyhow!( "insufficient balance on {}: need {} sats, have {} sats", from_mint, total_needed, wallet.balance_for_mint(from_mint) ) })?; let proofs: Vec = indices .iter() .map(|&i| wallet.proofs[i].proof.clone()) .collect(); if let Err(e) = from.melt_tokens(&melt_quote.quote, &proofs).await { // The pay leg never completed — record the route failure so future // payments can prefer a route with a track record. record_swap_failure(data_dir, from_mint, to_mint).await; return Err(e).with_context(|| { format!( "melting source proofs at {} to pay target invoice", from_mint ) }); } // Persist the spend BEFORE claiming so a crash can't double-spend, and // journal the in-flight swap so the claim can be resumed after a crash. wallet.mark_spent(&indices); wallet.record_tx( TransactionType::Melt, total_needed, &format!( "Cross-mint swap {}→{}: paid {} sats (fee {})", from_mint, to_mint, total_needed, fee ), from_mint, to_mint, ); save_wallet(data_dir, &wallet).await?; add_pending_swap( data_dir, PendingSwap { from_mint: from_mint.to_string(), to_mint: to_mint.to_string(), amount_sats, melt_quote_id: melt_quote.quote.clone(), mint_quote_id: mint_quote.quote.clone(), created_at: chrono::Utc::now().to_rfc3339(), }, ) .await?; // 5. Wait for the invoice to settle, then claim the minted tokens. wait_for_mint_quote_paid(&to, &mint_quote.quote).await?; let result = to .mint_tokens(&mint_quote.quote, amount_sats) .await .with_context(|| format!("claiming minted tokens at target mint {}", to_mint))?; let minted: u64 = result.proofs.iter().map(|p| p.amount).sum(); let mut wallet = load_wallet(data_dir).await?; wallet.add_proofs(to_mint, result.proofs); wallet.record_tx( TransactionType::Mint, minted, &format!( "Cross-mint swap {}→{}: claimed {} sats", from_mint, to_mint, minted ), to_mint, from_mint, ); save_wallet(data_dir, &wallet).await?; // Swap fully settled — clear the journal entry and credit the route. remove_pending_swap(data_dir, &mint_quote.quote).await?; record_swap_success(data_dir, from_mint, to_mint).await; debug!( "Cross-mint swap complete: {} → {} delivered {} sats (fee {})", from_mint, to_mint, minted, fee ); Ok(minted) } /// Poll a mint quote until its Lightning invoice is paid (state `PAID`/`ISSUED`), /// or time out. The melt above pays the invoice; the target mint sees it settle /// shortly after. async fn wait_for_mint_quote_paid(client: &MintClient, quote_id: &str) -> Result<()> { let deadline = SWAP_CLAIM_TIMEOUT_SECS / SWAP_CLAIM_POLL_SECS.max(1); for _ in 0..deadline.max(1) { let status = client.mint_quote_status(quote_id).await?; match status.state.as_str() { "PAID" | "ISSUED" => return Ok(()), _ => tokio::time::sleep(std::time::Duration::from_secs(SWAP_CLAIM_POLL_SECS)).await, } } anyhow::bail!( "target mint invoice for quote {} did not settle within {}s", quote_id, SWAP_CLAIM_TIMEOUT_SECS ) } /// Create a cashuA token string to send to a peer, drawing from the home mint. pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result { let mint_url = load_wallet(data_dir).await?.mint_url; send_token_at(data_dir, &mint_url, amount_sats).await } /// Create a cashuA token denominated in a specific mint's tokens. /// /// Used by the payer-side cross-mint flow: after `swap_between_mints` lands value /// on the seeder's accepted mint, we send a token from *that* mint so the seeder /// only ever receives its own mint's proofs (see plan §2a, payer-side swap). pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) -> Result { let mut wallet = load_wallet(data_dir).await?; let mint_url = mint_url.to_string(); // Select proofs covering the amount let (indices, overpayment) = wallet .select_proofs(&mint_url, amount_sats) .ok_or_else(|| { anyhow::anyhow!( "Insufficient balance: need {} sats, have {} sats", amount_sats, wallet.balance_for_mint(&mint_url) ) })?; let selected_proofs: Vec = indices .iter() .map(|&i| wallet.proofs[i].proof.clone()) .collect(); // If there's overpayment, swap to get exact change let send_proofs = if overpayment > 0 { let client = MintClient::new(&mint_url)?; let send_denoms = amount_to_denominations(amount_sats); let change_denoms = amount_to_denominations(overpayment); let mut all_target: Vec = send_denoms.clone(); all_target.extend(&change_denoms); let swap_result = client.swap(&selected_proofs, &all_target).await?; // Mark original proofs as spent wallet.mark_spent(&indices); // Separate send proofs from change proofs let (send, change): (Vec<_>, Vec<_>) = swap_result .new_proofs .into_iter() .partition(|p| send_denoms.contains(&p.amount)); // Add change proofs back to wallet if !change.is_empty() { wallet.add_proofs(&mint_url, change); } send } else { // Exact amount — just mark as spent and use directly wallet.mark_spent(&indices); selected_proofs }; // Serialize as cashuA token let token = CashuToken::new(&mint_url, send_proofs); let token_str = token.serialize()?; wallet.record_tx( TransactionType::Send, amount_sats, &format!("Sent {} sats ecash", amount_sats), &mint_url, "", ); save_wallet(data_dir, &wallet).await?; debug!("Created send token for {} sats", amount_sats); Ok(token_str) } // ── Payer-side payment builder (plan §2a step 2) ─────────────────────────── // // Given a seeder's advertised `accepted_mints`, pick the cheapest way to pay: // spend tokens we already hold on an accepted mint (no fee), else swap value // into a *trusted* accepted mint and pay from there. If neither is possible // within budget, decline so the caller falls back to free origin. /// How a payment of a given amount can be satisfied across our mints. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PaymentPlan { /// We already hold enough on this accepted mint — pay directly, no swap fee. Direct { mint_url: String }, /// Swap value from `from_mint` into the trusted accepted `to_mint`, then pay. Swap { from_mint: String, to_mint: String }, /// No single mint can cover the amount (caller should use free origin). Insufficient, } /// Decide how to pay `amount` sats to a seeder, given what we hold and which /// mints the seeder accepts. /// /// - `holdings`: our spendable `(mint_url, balance)` pairs (verbatim URLs). /// - `accepted`: the seeder's `(mint_url, trusted)` pairs, where `trusted` /// means the mint is on our swap-into allow-list (`is_mint_trusted`). /// - Direct beats Swap (no fee). A Direct target needs no trust (we already /// hold those tokens); a Swap target must be trusted. The home mint is /// preferred as a tie-break for both legs (lowest friction). /// /// Pure and synchronous so it can be unit-tested without a live mint. It does /// not know swap fees; `swap_between_mints` enforces the fee cap and bails (→ /// origin fallback) if the chosen source can't cover amount + fee. fn plan_payment( holdings: &[(String, u64)], accepted: &[(String, bool)], amount: u64, ) -> PaymentPlan { let norm = |s: &str| s.trim_end_matches('/').to_string(); let home = norm(&default_mint_url()); let held = |mint: &str| -> u64 { holdings .iter() .filter(|(m, _)| norm(m) == norm(mint)) .map(|(_, b)| *b) .sum() }; // 1. Direct: any accepted mint we already hold enough on. Prefer home. let mut direct: Vec<&(String, bool)> = accepted.iter().filter(|(m, _)| held(m) >= amount).collect(); direct.sort_by_key(|(m, _)| norm(m) != home); // home (false) sorts first if let Some((mint, _)) = direct.first() { return PaymentPlan::Direct { mint_url: mint.clone(), }; } // 2. Swap: a trusted accepted target + a source we hold that covers `amount`. let mut targets: Vec<&(String, bool)> = accepted.iter().filter(|(_, trusted)| *trusted).collect(); targets.sort_by_key(|(m, _)| norm(m) != home); if let Some((to_mint, _)) = targets.first() { // Largest source we hold that isn't the target itself. let from = holdings .iter() .filter(|(m, b)| norm(m) != norm(to_mint) && *b >= amount) .max_by_key(|(_, b)| *b); if let Some((from_mint, _)) = from { return PaymentPlan::Swap { from_mint: from_mint.clone(), to_mint: to_mint.clone(), }; } } PaymentPlan::Insufficient } /// Build a cashuA token to pay a seeder `amount_sats`, denominated in one of the /// seeder's `accepted_mints`. Auto-swaps across mints (up to `max_fee_sats`) when /// we don't already hold the right mint. Returns the token string ready to send. /// /// Errors (caller should fall back to free origin) when no accepted mint is /// reachable within balance, no trusted swap target exists, or the swap exceeds /// the fee cap. pub async fn build_payment_token( data_dir: &Path, accepted_mints: &[String], amount_sats: u64, max_fee_sats: u64, ) -> Result { if amount_sats == 0 { anyhow::bail!("payment amount must be greater than zero"); } if accepted_mints.is_empty() { anyhow::bail!("seeder advertised no accepted mints"); } // Annotate each accepted mint with whether we trust swapping into it. let mut accepted: Vec<(String, bool)> = Vec::with_capacity(accepted_mints.len()); for m in accepted_mints { let trusted = is_mint_trusted(data_dir, m).await?; accepted.push((m.clone(), trusted)); } // Prefer swap targets with a liquidity track record. plan_payment's stable // sort keeps the home mint first; within the rest, this orders by how // reliably we've reached each target before (best routes first). let liq = load_swap_liquidity(data_dir).await; accepted.sort_by_key(|(m, _)| std::cmp::Reverse(target_liquidity_score(&liq, m))); let holdings = load_wallet(data_dir).await?.spendable_by_mint(); match plan_payment(&holdings, &accepted, amount_sats) { PaymentPlan::Direct { mint_url } => { debug!( "Payment plan: direct from {} for {} sats", mint_url, amount_sats ); send_token_at(data_dir, &mint_url, amount_sats).await } PaymentPlan::Swap { from_mint, to_mint } => { debug!( "Payment plan: swap {}→{} then pay {} sats (fee cap {})", from_mint, to_mint, amount_sats, max_fee_sats ); swap_between_mints(data_dir, &from_mint, &to_mint, amount_sats, max_fee_sats).await?; send_token_at(data_dir, &to_mint, amount_sats).await } PaymentPlan::Insufficient => anyhow::bail!( "cannot pay {} sats: no accepted mint covers it within balance/trust", amount_sats ), } } // ── F2 step 3 — hardening: idempotent swap resume + liquidity cache ───────── const PENDING_SWAPS_FILE: &str = "wallet/pending_swaps.json"; const SWAP_LIQUIDITY_FILE: &str = "wallet/swap_liquidity.json"; /// An in-flight cross-mint swap, journaled the moment the source proofs are /// melted (paid) so a crash before the target claim can be resumed instead of /// silently losing the value. Removed once the target tokens are claimed. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PendingSwap { pub from_mint: String, pub to_mint: String, pub amount_sats: u64, /// Source-mint melt quote id (the leg already paid). pub melt_quote_id: String, /// Target-mint mint quote id (the leg to claim). pub mint_quote_id: String, pub created_at: String, } async fn load_pending_swaps(data_dir: &Path) -> Result> { let path = data_dir.join(PENDING_SWAPS_FILE); if !path.exists() { return Ok(Vec::new()); } let content = fs::read_to_string(&path).await?; Ok(serde_json::from_str(&content).unwrap_or_default()) } async fn save_pending_swaps(data_dir: &Path, swaps: &[PendingSwap]) -> Result<()> { let dir = data_dir.join("wallet"); fs::create_dir_all(&dir).await?; let path = data_dir.join(PENDING_SWAPS_FILE); fs::write(&path, serde_json::to_string_pretty(swaps)?).await?; Ok(()) } async fn add_pending_swap(data_dir: &Path, swap: PendingSwap) -> Result<()> { let mut all = load_pending_swaps(data_dir).await?; all.push(swap); save_pending_swaps(data_dir, &all).await } async fn remove_pending_swap(data_dir: &Path, mint_quote_id: &str) -> Result<()> { let mut all = load_pending_swaps(data_dir).await?; all.retain(|s| s.mint_quote_id != mint_quote_id); save_pending_swaps(data_dir, &all).await } /// Resume any swaps that were interrupted between paying the source mint and /// claiming the target tokens. For each pending swap, ask the target mint about /// the mint quote: /// - `PAID` → claim now (the value was paid but never claimed). Reclaimed. /// - `ISSUED` → already claimed on a prior run; just drop the journal entry. /// - else → leave it (the invoice hasn't settled yet; retry next time). /// /// Returns the total sats reclaimed. Safe to call repeatedly (idempotent): a /// quote is only minted once, and `ISSUED` quotes are never re-claimed. pub async fn resume_pending_swaps(data_dir: &Path) -> Result { let pending = load_pending_swaps(data_dir).await?; let mut reclaimed = 0u64; for swap in pending { let to = match MintClient::new(&swap.to_mint) { Ok(c) => c, Err(e) => { warn!( "resume_pending_swaps: bad target mint {}: {}", swap.to_mint, e ); continue; } }; let status = match to.mint_quote_status(&swap.mint_quote_id).await { Ok(s) => s, Err(e) => { debug!( "resume_pending_swaps: status check failed for {}: {} — leaving pending", swap.mint_quote_id, e ); continue; } }; match status.state.as_str() { "PAID" => match to.mint_tokens(&swap.mint_quote_id, swap.amount_sats).await { Ok(result) => { let minted: u64 = result.proofs.iter().map(|p| p.amount).sum(); let mut wallet = load_wallet(data_dir).await?; wallet.add_proofs(&swap.to_mint, result.proofs); wallet.record_tx( TransactionType::Mint, minted, &format!( "Resumed cross-mint swap {}→{}: claimed {} sats", swap.from_mint, swap.to_mint, minted ), &swap.to_mint, &swap.from_mint, ); save_wallet(data_dir, &wallet).await?; remove_pending_swap(data_dir, &swap.mint_quote_id).await?; record_swap_success(data_dir, &swap.from_mint, &swap.to_mint).await; reclaimed += minted; info!( "Resumed interrupted swap {}→{}: reclaimed {} sats", swap.from_mint, swap.to_mint, minted ); } Err(e) => warn!( "resume_pending_swaps: claim failed for {}: {}", swap.mint_quote_id, e ), }, "ISSUED" => { // Already claimed on a previous run — drop the journal entry. remove_pending_swap(data_dir, &swap.mint_quote_id).await?; } other => debug!( "resume_pending_swaps: quote {} state {} — leaving pending", swap.mint_quote_id, other ), } } Ok(reclaimed) } /// Success/failure counts for a single (from → to) swap route. #[derive(Debug, Clone, Default, Serialize, Deserialize)] struct RouteStat { successes: u64, failures: u64, } /// Per-mint-pair liquidity cache: which swap routes have actually worked, so the /// payer can prefer routes with a track record over ones that keep failing. #[derive(Debug, Default, Serialize, Deserialize)] struct SwapLiquidity { /// Keyed by `"|"` (normalized URLs). routes: std::collections::BTreeMap, } fn route_key(from_mint: &str, to_mint: &str) -> String { format!( "{}|{}", from_mint.trim_end_matches('/'), to_mint.trim_end_matches('/') ) } async fn load_swap_liquidity(data_dir: &Path) -> SwapLiquidity { let path = data_dir.join(SWAP_LIQUIDITY_FILE); match fs::read_to_string(&path).await { Ok(content) => serde_json::from_str(&content).unwrap_or_default(), Err(_) => SwapLiquidity::default(), } } async fn save_swap_liquidity(data_dir: &Path, liq: &SwapLiquidity) { let dir = data_dir.join("wallet"); let _ = fs::create_dir_all(&dir).await; if let Ok(content) = serde_json::to_string_pretty(liq) { let _ = fs::write(data_dir.join(SWAP_LIQUIDITY_FILE), content).await; } } /// Record that a swap route succeeded (best-effort; never fails the caller). async fn record_swap_success(data_dir: &Path, from_mint: &str, to_mint: &str) { let mut liq = load_swap_liquidity(data_dir).await; liq.routes .entry(route_key(from_mint, to_mint)) .or_default() .successes += 1; save_swap_liquidity(data_dir, &liq).await; } /// Record that a swap route failed (best-effort; never fails the caller). async fn record_swap_failure(data_dir: &Path, from_mint: &str, to_mint: &str) { let mut liq = load_swap_liquidity(data_dir).await; liq.routes .entry(route_key(from_mint, to_mint)) .or_default() .failures += 1; save_swap_liquidity(data_dir, &liq).await; } /// Liquidity score for reaching `to_mint` from any source: net successes across /// all routes ending at this target. Higher = a more reliable destination. fn target_liquidity_score(liq: &SwapLiquidity, to_mint: &str) -> i64 { let suffix = format!("|{}", to_mint.trim_end_matches('/')); liq.routes .iter() .filter(|(k, _)| k.ends_with(&suffix)) .map(|(_, s)| s.successes as i64 - s.failures as i64) .sum() } /// Receive a cashuA token from a peer — swaps proofs at the mint for fresh ones. pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result { // Handle legacy format for backwards compatibility if token_str.starts_with("cashuSend_") { return receive_legacy_token(data_dir, token_str).await; } let token = CashuToken::deserialize(token_str)?; let total_amount = token.total_amount(); if total_amount == 0 { anyhow::bail!("Token has zero value"); } // Verify all mints in the token are accepted let accepted = load_accepted_mints(data_dir).await?; for mint_url in token.mint_urls() { if !accepted.mints.iter().any(|m| m == mint_url) { anyhow::bail!("Mint '{}' is not in accepted mints list", mint_url); } } let mut wallet = load_wallet(data_dir).await?; let mut received_total = 0u64; // Swap proofs at each mint for entry in &token.token { let client = MintClient::new(&entry.mint)?; match client.receive_token(&token).await { Ok(new_proofs) => { let amount: u64 = new_proofs.iter().map(|p| p.amount).sum(); wallet.add_proofs(&entry.mint, new_proofs); received_total += amount; } Err(e) => { warn!("Failed to swap proofs from mint {}: {}", entry.mint, e); // Continue with other mints if any } } } if received_total == 0 { anyhow::bail!("Failed to receive any proofs from token"); } wallet.record_tx( TransactionType::Receive, received_total, &format!("Received {} sats ecash", received_total), token.token.first().map(|e| e.mint.as_str()).unwrap_or(""), "", ); save_wallet(data_dir, &wallet).await?; debug!("Received {} sats ecash token", received_total); Ok(received_total) } /// Receive a legacy format token (cashuSend_{amount}_{uuid}_{timestamp}). /// For backwards compatibility during migration period. async fn receive_legacy_token(data_dir: &Path, token_str: &str) -> Result { let amount_sats = token_str .split('_') .nth(1) .and_then(|s| s.parse::().ok()) .unwrap_or(0); if amount_sats == 0 { anyhow::bail!("Invalid legacy ecash token"); } let mut wallet = load_wallet(data_dir).await?; // Store as a synthetic proof (legacy tokens can't be verified cryptographically) let proof = Proof { amount: amount_sats, id: "legacy".to_string(), secret: token_str.to_string(), c: "0000000000000000000000000000000000000000000000000000000000000000ff".to_string(), }; wallet.add_proofs(&wallet.mint_url.clone(), vec![proof]); wallet.record_tx( TransactionType::Receive, amount_sats, &format!("Received {} sats (legacy format)", amount_sats), &wallet.mint_url.clone(), "", ); save_wallet(data_dir, &wallet).await?; debug!("Received {} sats legacy ecash token", amount_sats); Ok(amount_sats) } /// Verify a payment token and receive it into the wallet. /// Returns the verified amount, or error if verification fails. /// Used by the content server and streaming gate to verify incoming payments. pub async fn verify_and_receive_payment( data_dir: &Path, token_str: &str, required_sats: u64, ) -> Result { // Handle legacy tokens if token_str.starts_with("cashuSend_") { let amount = token_str .split('_') .nth(1) .and_then(|s| s.parse::().ok()) .unwrap_or(0); if amount < required_sats { anyhow::bail!( "Insufficient payment: {} sats, need {} sats", amount, required_sats ); } let received = receive_legacy_token(data_dir, token_str).await?; return Ok(received); } // Parse and validate cashuA token let token = CashuToken::deserialize(token_str)?; let total = token.total_amount(); if total < required_sats { anyhow::bail!( "Insufficient payment: {} sats, need {} sats", total, required_sats ); } // Verify mints are accepted let accepted = load_accepted_mints(data_dir).await?; for mint_url in token.mint_urls() { if !accepted.mints.iter().any(|m| m == mint_url) { anyhow::bail!("Mint '{}' not accepted", mint_url); } } // Swap proofs at mint (this verifies they're unspent and gives us fresh proofs) let mut wallet = load_wallet(data_dir).await?; let mut received_total = 0u64; for entry in &token.token { let client = MintClient::new(&entry.mint)?; let entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum(); let target_amounts = amount_to_denominations(entry_total); match client.swap(&entry.proofs, &target_amounts).await { Ok(result) => { let amount: u64 = result.new_proofs.iter().map(|p| p.amount).sum(); wallet.add_proofs(&entry.mint, result.new_proofs); received_total += amount; } Err(e) => { warn!("Payment verification failed at mint {}: {}", entry.mint, e); } } } if received_total < required_sats { anyhow::bail!( "Payment verification failed: only {} of {} sats verified", received_total, required_sats ); } wallet.record_tx( TransactionType::Receive, received_total, &format!("Payment received: {} sats", received_total), token.token.first().map(|e| e.mint.as_str()).unwrap_or(""), "", ); save_wallet(data_dir, &wallet).await?; Ok(received_total) } /// Check the wallet balance. pub async fn get_balance(data_dir: &Path) -> Result { let wallet = load_wallet(data_dir).await?; Ok(wallet.balance()) } /// Default mint URL (local Fedimint). /// Default Cashu mint. Minibits is a well-known public Cashu mint — note this /// is a CASHU mint, distinct from the local Fedimint guardian (:8175), which is /// a separate ecash protocol managed under the Fedimint Federations tab. The /// old default pointed at :8175, which incorrectly surfaced the Fedimint URL in /// the Cashu mints list. fn default_mint_url() -> String { "https://mint.minibits.cash/Bitcoin".to_string() } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn test_wallet_state_balance_empty() { let wallet = WalletState::default(); assert_eq!(wallet.balance(), 0); } #[test] fn test_wallet_state_balance_with_proofs() { let mut wallet = WalletState::default(); wallet.add_proofs( "http://mint", vec![ Proof { amount: 100, id: "ks1".into(), secret: "s1".into(), c: "c1".into(), }, Proof { amount: 200, id: "ks1".into(), secret: "s2".into(), c: "c2".into(), }, ], ); assert_eq!(wallet.balance(), 300); } #[test] fn test_wallet_state_balance_excludes_spent() { let mut wallet = WalletState::default(); wallet.add_proofs( "http://mint", vec![ Proof { amount: 100, id: "ks1".into(), secret: "s1".into(), c: "c1".into(), }, Proof { amount: 200, id: "ks1".into(), secret: "s2".into(), c: "c2".into(), }, ], ); wallet.proofs[0].spent = true; assert_eq!(wallet.balance(), 200); } #[test] fn test_wallet_state_balance_excludes_reserved() { let mut wallet = WalletState::default(); wallet.add_proofs( "http://mint", vec![Proof { amount: 100, id: "ks1".into(), secret: "s1".into(), c: "c1".into(), }], ); wallet.proofs[0].reserved = true; assert_eq!(wallet.balance(), 0); } #[test] fn test_select_proofs_sufficient() { let mut wallet = WalletState::default(); wallet.add_proofs( "http://mint", vec![ Proof { amount: 1, id: "ks1".into(), secret: "s1".into(), c: "c1".into(), }, Proof { amount: 4, id: "ks1".into(), secret: "s2".into(), c: "c2".into(), }, Proof { amount: 8, id: "ks1".into(), secret: "s3".into(), c: "c3".into(), }, ], ); let (indices, overpayment) = wallet.select_proofs("http://mint", 5).unwrap(); let total: u64 = indices.iter().map(|&i| wallet.proofs[i].proof.amount).sum(); assert!(total >= 5); assert_eq!(total - 5, overpayment); } #[test] fn test_select_proofs_insufficient() { let mut wallet = WalletState::default(); wallet.add_proofs( "http://mint", vec![Proof { amount: 1, id: "ks1".into(), secret: "s1".into(), c: "c1".into(), }], ); assert!(wallet.select_proofs("http://mint", 100).is_none()); } #[test] fn test_select_proofs_wrong_mint() { let mut wallet = WalletState::default(); wallet.add_proofs( "http://mint-a", vec![Proof { amount: 100, id: "ks1".into(), secret: "s1".into(), c: "c1".into(), }], ); assert!(wallet.select_proofs("http://mint-b", 100).is_none()); } #[test] fn test_balance_for_mint() { let mut wallet = WalletState::default(); wallet.add_proofs( "http://mint-a", vec![Proof { amount: 100, id: "ks1".into(), secret: "s1".into(), c: "c1".into(), }], ); wallet.add_proofs( "http://mint-b", vec![Proof { amount: 200, id: "ks2".into(), secret: "s2".into(), c: "c2".into(), }], ); assert_eq!(wallet.balance_for_mint("http://mint-a"), 100); assert_eq!(wallet.balance_for_mint("http://mint-b"), 200); assert_eq!(wallet.balance(), 300); } #[tokio::test] async fn test_load_wallet_creates_default_when_missing() { let tmp = TempDir::new().unwrap(); let wallet = load_wallet(tmp.path()).await.unwrap(); assert_eq!(wallet.balance(), 0); assert!(wallet.proofs.is_empty()); assert!(wallet.transactions.is_empty()); assert_eq!(wallet.mint_url, default_mint_url()); } #[tokio::test] async fn test_save_and_load_wallet_roundtrip() { let tmp = TempDir::new().unwrap(); let mut wallet = WalletState { mint_url: "https://mint.minibits.cash/Bitcoin".into(), ..Default::default() }; wallet.add_proofs( "https://mint.minibits.cash/Bitcoin", vec![Proof { amount: 42, id: "ks1".into(), secret: "test_secret".into(), c: "test_c".into(), }], ); wallet.record_tx( TransactionType::Mint, 42, "Test mint", "https://mint.minibits.cash/Bitcoin", "", ); save_wallet(tmp.path(), &wallet).await.unwrap(); let loaded = load_wallet(tmp.path()).await.unwrap(); assert_eq!(loaded.proofs.len(), 1); assert_eq!(loaded.proofs[0].proof.amount, 42); assert!(!loaded.proofs[0].spent); assert_eq!(loaded.transactions.len(), 1); assert_eq!(loaded.balance(), 42); } #[tokio::test] async fn test_save_wallet_creates_directory() { let tmp = TempDir::new().unwrap(); let wallet_dir = tmp.path().join("wallet"); assert!(!wallet_dir.exists()); let wallet = WalletState::default(); save_wallet(tmp.path(), &wallet).await.unwrap(); assert!(wallet_dir.exists()); } #[tokio::test] async fn test_receive_legacy_token() { let tmp = TempDir::new().unwrap(); let amount = receive_token(tmp.path(), "cashuSend_500_abc123_1700000000") .await .unwrap(); assert_eq!(amount, 500); let wallet = load_wallet(tmp.path()).await.unwrap(); assert_eq!(wallet.balance(), 500); assert_eq!(wallet.proofs.len(), 1); assert!(!wallet.proofs[0].spent); } #[tokio::test] async fn test_receive_token_invalid_format() { let tmp = TempDir::new().unwrap(); let result = receive_token(tmp.path(), "invalid_token_string").await; assert!(result.is_err()); } #[tokio::test] async fn test_receive_legacy_token_zero_amount() { let tmp = TempDir::new().unwrap(); let result = receive_token(tmp.path(), "cashuSend_0_abc_1700000000").await; assert!(result.is_err()); } #[test] fn test_prune_old_spent() { let mut wallet = WalletState::default(); // Add an old spent proof wallet.proofs.push(StoredProof { proof: Proof { amount: 100, id: "ks1".into(), secret: "old".into(), c: "c".into(), }, mint_url: "http://mint".into(), spent: true, reserved: false, created_at: "2020-01-01T00:00:00Z".into(), }); // Add a recent unspent proof wallet.proofs.push(StoredProof { proof: Proof { amount: 200, id: "ks1".into(), secret: "new".into(), c: "c".into(), }, mint_url: "http://mint".into(), spent: false, reserved: false, created_at: chrono::Utc::now().to_rfc3339(), }); wallet.prune_old_spent(); assert_eq!(wallet.proofs.len(), 1); assert_eq!(wallet.proofs[0].proof.amount, 200); } #[tokio::test] async fn test_accepted_mints_default() { let tmp = TempDir::new().unwrap(); let mints = load_accepted_mints(tmp.path()).await.unwrap(); assert_eq!(mints.mints.len(), 1); assert_eq!(mints.mints[0], default_mint_url()); } #[tokio::test] async fn test_accepted_mints_roundtrip() { let tmp = TempDir::new().unwrap(); let mints = AcceptedMints { mints: vec!["http://mint-a".into(), "http://mint-b".into()], }; save_accepted_mints(tmp.path(), &mints).await.unwrap(); let loaded = load_accepted_mints(tmp.path()).await.unwrap(); assert_eq!(loaded.mints.len(), 2); } #[test] fn test_transaction_type_serialization() { let tx = EcashTransaction { id: "tx-test".into(), tx_type: TransactionType::StreamingPayment, amount_sats: 100, timestamp: "2025-01-01T00:00:00Z".into(), description: "test".into(), mint_url: String::new(), peer: String::new(), }; let json = serde_json::to_string(&tx).unwrap(); assert!(json.contains("\"streamingpayment\"")); } #[test] fn test_default_mint_url() { assert_eq!(default_mint_url(), "https://mint.minibits.cash/Bitcoin"); } #[test] fn test_swap_fee() { assert_eq!(swap_fee(105, 100), 5); // Defensive: never underflow if mint quotes oddly. assert_eq!(swap_fee(100, 100), 0); assert_eq!(swap_fee(90, 100), 0); } #[tokio::test] async fn test_is_mint_trusted_home_always() { let tmp = TempDir::new().unwrap(); // Home mint is trusted even with no accepted-mints file. assert!(is_mint_trusted(tmp.path(), &default_mint_url()) .await .unwrap()); // Trailing slash on the home URL still matches. assert!( is_mint_trusted(tmp.path(), "https://mint.minibits.cash/Bitcoin/") .await .unwrap() ); } #[tokio::test] async fn test_is_mint_trusted_respects_accepted_list() { let tmp = TempDir::new().unwrap(); save_accepted_mints( tmp.path(), &AcceptedMints { mints: vec![default_mint_url(), "https://mint.example.com".into()], }, ) .await .unwrap(); assert!(is_mint_trusted(tmp.path(), "https://mint.example.com") .await .unwrap()); // Normalized comparison ignores a trailing slash. assert!(is_mint_trusted(tmp.path(), "https://mint.example.com/") .await .unwrap()); // A mint not on the list is not trusted. assert!(!is_mint_trusted(tmp.path(), "https://evil.example.com") .await .unwrap()); } #[tokio::test] async fn test_swap_between_mints_rejects_identical() { let tmp = TempDir::new().unwrap(); let err = swap_between_mints( tmp.path(), &default_mint_url(), "https://mint.minibits.cash/Bitcoin/", 100, 10, ) .await .unwrap_err(); assert!(err.to_string().contains("identical")); } #[tokio::test] async fn test_swap_between_mints_rejects_untrusted_target() { let tmp = TempDir::new().unwrap(); let err = swap_between_mints( tmp.path(), &default_mint_url(), "https://untrusted.example.com", 100, 10, ) .await .unwrap_err(); assert!(err.to_string().contains("trusted")); } #[tokio::test] async fn test_swap_between_mints_rejects_zero_amount() { let tmp = TempDir::new().unwrap(); let err = swap_between_mints( tmp.path(), &default_mint_url(), "https://mint.example.com", 0, 10, ) .await .unwrap_err(); assert!(err.to_string().contains("greater than zero")); } #[test] fn test_spendable_by_mint_groups_and_excludes() { let mut wallet = WalletState::default(); wallet.add_proofs( "http://mint-a", vec![ Proof { amount: 10, id: "k".into(), secret: "s1".into(), c: "c".into(), }, Proof { amount: 5, id: "k".into(), secret: "s2".into(), c: "c".into(), }, ], ); wallet.add_proofs( "http://mint-b", vec![Proof { amount: 7, id: "k".into(), secret: "s3".into(), c: "c".into(), }], ); wallet.proofs[1].spent = true; // exclude the 5 on mint-a let by_mint = wallet.spendable_by_mint(); assert_eq!( by_mint, vec![ ("http://mint-a".to_string(), 10), ("http://mint-b".to_string(), 7) ] ); } #[test] fn test_plan_payment_direct_prefers_home() { let home = default_mint_url(); let holdings = vec![(home.clone(), 100), ("https://other".into(), 100)]; // Both accepted; home should win the tie-break. let accepted = vec![("https://other".into(), true), (home.clone(), true)]; assert_eq!( plan_payment(&holdings, &accepted, 50), PaymentPlan::Direct { mint_url: home } ); } #[test] fn test_plan_payment_direct_only_accepted_mint() { let holdings = vec![("https://a".into(), 100), ("https://b".into(), 100)]; // We hold both, but the seeder only accepts b. let accepted = vec![("https://b".into(), true)]; assert_eq!( plan_payment(&holdings, &accepted, 50), PaymentPlan::Direct { mint_url: "https://b".into() } ); } #[test] fn test_plan_payment_swaps_into_trusted_target() { // We hold value on A; seeder accepts only B (trusted) which we don't hold. let holdings = vec![("https://a".into(), 100)]; let accepted = vec![("https://b".into(), true)]; assert_eq!( plan_payment(&holdings, &accepted, 50), PaymentPlan::Swap { from_mint: "https://a".into(), to_mint: "https://b".into() } ); } #[test] fn test_plan_payment_refuses_untrusted_swap_target() { // Seeder accepts only B, but B is not trusted → no swap, insufficient. let holdings = vec![("https://a".into(), 100)]; let accepted = vec![("https://b".into(), false)]; assert_eq!( plan_payment(&holdings, &accepted, 50), PaymentPlan::Insufficient ); } #[test] fn test_plan_payment_insufficient_when_no_single_source_covers() { // Total 60 across two mints, but neither alone covers 50+ for a swap and // we hold neither accepted mint directly. let holdings = vec![("https://a".into(), 30), ("https://c".into(), 30)]; let accepted = vec![("https://b".into(), true)]; assert_eq!( plan_payment(&holdings, &accepted, 50), PaymentPlan::Insufficient ); } #[test] fn test_plan_payment_direct_beats_swap() { // We hold the accepted mint directly AND could swap — Direct must win. let home = default_mint_url(); let holdings = vec![("https://b".into(), 100), (home.clone(), 100)]; let accepted = vec![("https://b".into(), true)]; assert_eq!( plan_payment(&holdings, &accepted, 50), PaymentPlan::Direct { mint_url: "https://b".into() } ); } #[tokio::test] async fn test_build_payment_token_rejects_empty_mints() { let tmp = TempDir::new().unwrap(); let err = build_payment_token(tmp.path(), &[], 100, 10) .await .unwrap_err(); assert!(err.to_string().contains("no accepted mints")); } #[tokio::test] async fn test_build_payment_token_insufficient_falls_through() { let tmp = TempDir::new().unwrap(); // Empty wallet, untrusted seeder mint → cannot pay (caller uses origin). let err = build_payment_token(tmp.path(), &["https://seeder.example.com".into()], 100, 10) .await .unwrap_err(); assert!(err.to_string().contains("cannot pay")); } #[test] fn test_route_key_normalizes_trailing_slash() { assert_eq!(route_key("https://a/", "https://b/"), "https://a|https://b"); assert_eq!(route_key("https://a", "https://b"), "https://a|https://b"); } #[tokio::test] async fn test_pending_swaps_roundtrip_and_remove() { let tmp = TempDir::new().unwrap(); assert!(load_pending_swaps(tmp.path()).await.unwrap().is_empty()); add_pending_swap( tmp.path(), PendingSwap { from_mint: "https://a".into(), to_mint: "https://b".into(), amount_sats: 100, melt_quote_id: "melt-1".into(), mint_quote_id: "mint-1".into(), created_at: "2026-06-17T00:00:00Z".into(), }, ) .await .unwrap(); let loaded = load_pending_swaps(tmp.path()).await.unwrap(); assert_eq!(loaded.len(), 1); assert_eq!(loaded[0].mint_quote_id, "mint-1"); remove_pending_swap(tmp.path(), "mint-1").await.unwrap(); assert!(load_pending_swaps(tmp.path()).await.unwrap().is_empty()); } #[tokio::test] async fn test_resume_pending_swaps_empty_is_noop() { let tmp = TempDir::new().unwrap(); assert_eq!(resume_pending_swaps(tmp.path()).await.unwrap(), 0); } #[tokio::test] async fn test_liquidity_cache_records_and_scores() { let tmp = TempDir::new().unwrap(); // Two routes into B succeed; one into C fails. record_swap_success(tmp.path(), "https://a", "https://b").await; record_swap_success(tmp.path(), "https://x", "https://b").await; record_swap_failure(tmp.path(), "https://a", "https://c").await; let liq = load_swap_liquidity(tmp.path()).await; // B reached successfully from two sources → net +2; trailing slash tolerant. assert_eq!(target_liquidity_score(&liq, "https://b/"), 2); // C only failed → net -1. assert_eq!(target_liquidity_score(&liq, "https://c"), -1); // Unknown target → neutral 0. assert_eq!(target_liquidity_score(&liq, "https://unknown"), 0); } #[tokio::test] async fn test_build_payment_token_prefers_liquid_target() { let tmp = TempDir::new().unwrap(); // Trust two non-home mints; hold value on a source mint for both. save_accepted_mints( tmp.path(), &AcceptedMints { mints: vec![ default_mint_url(), "https://liquid".into(), "https://dry".into(), ], }, ) .await .unwrap(); // Give "https://liquid" a track record so it should be preferred. record_swap_success(tmp.path(), "https://src", "https://liquid").await; // Seeder accepts both non-home mints; we only hold "https://src". let accepted = vec![ ("https://dry".into(), true), ("https://liquid".into(), true), ]; let holdings = vec![("https://src".to_string(), 1000u64)]; // Mirror build_payment_token's ordering step, then plan. let liq = load_swap_liquidity(tmp.path()).await; let mut ordered = accepted.clone(); ordered.sort_by_key(|(m, _): &(String, bool)| { std::cmp::Reverse(target_liquidity_score(&liq, m)) }); match plan_payment(&holdings, &ordered, 100) { PaymentPlan::Swap { to_mint, .. } => assert_eq!(to_mint, "https://liquid"), other => panic!("expected swap into liquid target, got {:?}", other), } } }