//! 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, 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() } /// 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) } /// Create a cashuA token string to send to a peer. pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result { let mut wallet = load_wallet(data_dir).await?; let mint_url = wallet.mint_url.clone(); // 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) } /// 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). fn default_mint_url() -> String { "http://127.0.0.1:8175".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: "http://127.0.0.1:8175".into(), ..Default::default() }; wallet.add_proofs( "http://127.0.0.1:8175", vec![Proof { amount: 42, id: "ks1".into(), secret: "test_secret".into(), c: "test_c".into(), }], ); wallet.record_tx( TransactionType::Mint, 42, "Test mint", "http://127.0.0.1:8175", "", ); 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(), "http://127.0.0.1:8175"); } }