From 23cba67c3b46aa09f7150eef408aba1476ccb880 Mon Sep 17 00:00:00 2001 From: David Caseria Date: Wed, 17 Sep 2025 12:27:54 -0400 Subject: [PATCH] MultiMintWallet Refactor (#1001) --- crates/cdk-cli/src/main.rs | 79 +- crates/cdk-cli/src/sub_commands/balance.rs | 19 +- crates/cdk-cli/src/sub_commands/burn.rs | 13 +- .../src/sub_commands/cat_device_login.rs | 20 +- crates/cdk-cli/src/sub_commands/cat_login.rs | 21 +- .../src/sub_commands/create_request.rs | 5 +- crates/cdk-cli/src/sub_commands/melt.rs | 473 +++--- crates/cdk-cli/src/sub_commands/mint.rs | 8 +- .../src/sub_commands/mint_blind_auth.rs | 21 +- crates/cdk-cli/src/sub_commands/mod.rs | 1 + .../cdk-cli/src/sub_commands/pending_mints.rs | 6 +- crates/cdk-cli/src/sub_commands/receive.rs | 55 +- crates/cdk-cli/src/sub_commands/restore.rs | 20 +- crates/cdk-cli/src/sub_commands/send.rs | 143 +- crates/cdk-cli/src/sub_commands/transfer.rs | 209 +++ .../src/sub_commands/update_mint_url.rs | 7 +- crates/cdk-cli/src/utils.rs | 69 +- crates/cdk-common/src/error.rs | 26 + crates/cdk-ffi/src/lib.rs | 2 + crates/cdk-ffi/src/multi_mint_wallet.rs | 416 +++++ crates/cdk/src/wallet/builder.rs | 6 + crates/cdk/src/wallet/mod.rs | 2 +- crates/cdk/src/wallet/multi_mint_wallet.rs | 1423 +++++++++++++++-- crates/cdk/src/wallet/payment_request.rs | 10 +- 24 files changed, 2384 insertions(+), 670 deletions(-) create mode 100644 crates/cdk-cli/src/sub_commands/transfer.rs create mode 100644 crates/cdk-ffi/src/multi_mint_wallet.rs diff --git a/crates/cdk-cli/src/main.rs b/crates/cdk-cli/src/main.rs index 32a1c3ea..2d3bbfa1 100644 --- a/crates/cdk-cli/src/main.rs +++ b/crates/cdk-cli/src/main.rs @@ -9,7 +9,7 @@ use bip39::Mnemonic; use cdk::cdk_database; use cdk::cdk_database::WalletDatabase; use cdk::nuts::CurrencyUnit; -use cdk::wallet::{HttpClient, MultiMintWallet, Wallet, WalletBuilder}; +use cdk::wallet::MultiMintWallet; #[cfg(feature = "redb")] use cdk_redb::WalletRedbDatabase; use cdk_sqlite::WalletSqliteDatabase; @@ -49,6 +49,9 @@ struct Cli { /// NWS Proxy #[arg(short, long)] proxy: Option, + /// Currency unit to use for the wallet + #[arg(short, long, default_value = "sat")] + unit: String, #[command(subcommand)] command: Commands, } @@ -67,6 +70,8 @@ enum Commands { Receive(sub_commands::receive::ReceiveSubCommand), /// Send Send(sub_commands::send::SendSubCommand), + /// Transfer tokens between mints + Transfer(sub_commands::transfer::TransferSubCommand), /// Reclaim pending proofs that are no longer pending CheckPending, /// View mint info @@ -168,62 +173,25 @@ async fn main() -> Result<()> { }; let seed = mnemonic.to_seed_normalized(""); - let mut wallets: Vec = Vec::new(); + // Parse currency unit from args + let currency_unit = CurrencyUnit::from_str(&args.unit) + .unwrap_or_else(|_| CurrencyUnit::Custom(args.unit.clone())); - let mints = localstore.get_mints().await?; - - for (mint_url, mint_info) in mints { - let units = if let Some(mint_info) = mint_info { - mint_info.supported_units().into_iter().cloned().collect() - } else { - vec![CurrencyUnit::Sat] - }; - - let proxy_client = if let Some(proxy_url) = args.proxy.as_ref() { - Some(HttpClient::with_proxy( - mint_url.clone(), + // Create MultiMintWallet with specified currency unit + // The constructor will automatically load wallets for this currency unit + let multi_mint_wallet = match &args.proxy { + Some(proxy_url) => { + // Create MultiMintWallet with proxy configuration + MultiMintWallet::new_with_proxy( + localstore.clone(), + seed, + currency_unit.clone(), proxy_url.clone(), - None, - true, - )?) - } else { - None - }; - - let seed = mnemonic.to_seed_normalized(""); - - for unit in units { - let mint_url_clone = mint_url.clone(); - let mut builder = WalletBuilder::new() - .mint_url(mint_url_clone.clone()) - .unit(unit) - .localstore(localstore.clone()) - .seed(seed); - - if let Some(http_client) = &proxy_client { - builder = builder.client(http_client.clone()); - } - - let wallet = builder.build()?; - - let wallet_clone = wallet.clone(); - - tokio::spawn(async move { - // We refresh keysets, this internally gets mint info - if let Err(err) = wallet_clone.refresh_keysets().await { - tracing::error!( - "Could not get mint quote for {}, {}", - wallet_clone.mint_url, - err - ); - } - }); - - wallets.push(wallet); + ) + .await? } - } - - let multi_mint_wallet = MultiMintWallet::new(localstore, seed, wallets); + None => MultiMintWallet::new(localstore.clone(), seed, currency_unit.clone()).await?, + }; match &args.command { Commands::DecodeToken(sub_command_args) => { @@ -239,6 +207,9 @@ async fn main() -> Result<()> { Commands::Send(sub_command_args) => { sub_commands::send::send(&multi_mint_wallet, sub_command_args).await } + Commands::Transfer(sub_command_args) => { + sub_commands::transfer::transfer(&multi_mint_wallet, sub_command_args).await + } Commands::CheckPending => { sub_commands::check_pending::check_pending(&multi_mint_wallet).await } diff --git a/crates/cdk-cli/src/sub_commands/balance.rs b/crates/cdk-cli/src/sub_commands/balance.rs index 4e25e74a..51241ff2 100644 --- a/crates/cdk-cli/src/sub_commands/balance.rs +++ b/crates/cdk-cli/src/sub_commands/balance.rs @@ -3,11 +3,24 @@ use std::collections::BTreeMap; use anyhow::Result; use cdk::mint_url::MintUrl; use cdk::nuts::CurrencyUnit; -use cdk::wallet::multi_mint_wallet::MultiMintWallet; +use cdk::wallet::MultiMintWallet; use cdk::Amount; pub async fn balance(multi_mint_wallet: &MultiMintWallet) -> Result<()> { - mint_balances(multi_mint_wallet, &CurrencyUnit::Sat).await?; + // Show individual mint balances + let mint_balances = mint_balances(multi_mint_wallet, multi_mint_wallet.unit()).await?; + + // Show total balance using the new unified interface + let total = multi_mint_wallet.total_balance().await?; + if !mint_balances.is_empty() { + println!(); + println!( + "Total balance across all wallets: {} {}", + total, + multi_mint_wallet.unit() + ); + } + Ok(()) } @@ -15,7 +28,7 @@ pub async fn mint_balances( multi_mint_wallet: &MultiMintWallet, unit: &CurrencyUnit, ) -> Result> { - let wallets: BTreeMap = multi_mint_wallet.get_balances(unit).await?; + let wallets: BTreeMap = multi_mint_wallet.get_balances().await?; let mut wallets_vec = Vec::with_capacity(wallets.len()); diff --git a/crates/cdk-cli/src/sub_commands/burn.rs b/crates/cdk-cli/src/sub_commands/burn.rs index 4fbc7dcb..f6f7d560 100644 --- a/crates/cdk-cli/src/sub_commands/burn.rs +++ b/crates/cdk-cli/src/sub_commands/burn.rs @@ -1,9 +1,5 @@ -use std::str::FromStr; - use anyhow::Result; use cdk::mint_url::MintUrl; -use cdk::nuts::CurrencyUnit; -use cdk::wallet::types::WalletKey; use cdk::wallet::MultiMintWallet; use cdk::Amount; use clap::Args; @@ -12,9 +8,6 @@ use clap::Args; pub struct BurnSubCommand { /// Mint Url mint_url: Option, - /// Currency unit e.g. sat - #[arg(default_value = "sat")] - unit: String, } pub async fn burn( @@ -22,14 +15,10 @@ pub async fn burn( sub_command_args: &BurnSubCommand, ) -> Result<()> { let mut total_burnt = Amount::ZERO; - let unit = CurrencyUnit::from_str(&sub_command_args.unit)?; match &sub_command_args.mint_url { Some(mint_url) => { - let wallet = multi_mint_wallet - .get_wallet(&WalletKey::new(mint_url.clone(), unit)) - .await - .unwrap(); + let wallet = multi_mint_wallet.get_wallet(mint_url).await.unwrap(); total_burnt = wallet.check_all_pending_proofs().await?; } None => { diff --git a/crates/cdk-cli/src/sub_commands/cat_device_login.rs b/crates/cdk-cli/src/sub_commands/cat_device_login.rs index 61f53c50..b2e6ded7 100644 --- a/crates/cdk-cli/src/sub_commands/cat_device_login.rs +++ b/crates/cdk-cli/src/sub_commands/cat_device_login.rs @@ -1,11 +1,9 @@ use std::path::Path; -use std::str::FromStr; use std::time::Duration; use anyhow::{anyhow, Result}; use cdk::mint_url::MintUrl; -use cdk::nuts::{CurrencyUnit, MintInfo}; -use cdk::wallet::types::WalletKey; +use cdk::nuts::MintInfo; use cdk::wallet::MultiMintWallet; use cdk::OidcClient; use clap::Args; @@ -18,10 +16,6 @@ use crate::token_storage; pub struct CatDeviceLoginSubCommand { /// Mint url mint_url: MintUrl, - /// Currency unit e.g. sat - #[arg(default_value = "sat")] - #[arg(short, long)] - unit: String, /// Client ID for OIDC authentication #[arg(default_value = "cashu-client")] #[arg(long)] @@ -34,17 +28,15 @@ pub async fn cat_device_login( work_dir: &Path, ) -> Result<()> { let mint_url = sub_command_args.mint_url.clone(); - let unit = CurrencyUnit::from_str(&sub_command_args.unit)?; - let wallet = match multi_mint_wallet - .get_wallet(&WalletKey::new(mint_url.clone(), unit.clone())) - .await - { + let wallet = match multi_mint_wallet.get_wallet(&mint_url).await { Some(wallet) => wallet.clone(), None => { + multi_mint_wallet.add_mint(mint_url.clone(), None).await?; multi_mint_wallet - .create_and_add_wallet(&mint_url.to_string(), unit, None) - .await? + .get_wallet(&mint_url) + .await + .expect("Wallet should exist after adding mint") } }; diff --git a/crates/cdk-cli/src/sub_commands/cat_login.rs b/crates/cdk-cli/src/sub_commands/cat_login.rs index 038f1664..a2553083 100644 --- a/crates/cdk-cli/src/sub_commands/cat_login.rs +++ b/crates/cdk-cli/src/sub_commands/cat_login.rs @@ -1,10 +1,8 @@ use std::path::Path; -use std::str::FromStr; use anyhow::{anyhow, Result}; use cdk::mint_url::MintUrl; -use cdk::nuts::{CurrencyUnit, MintInfo}; -use cdk::wallet::types::WalletKey; +use cdk::nuts::MintInfo; use cdk::wallet::MultiMintWallet; use cdk::OidcClient; use clap::Args; @@ -20,10 +18,6 @@ pub struct CatLoginSubCommand { username: String, /// Password password: String, - /// Currency unit e.g. sat - #[arg(default_value = "sat")] - #[arg(short, long)] - unit: String, /// Client ID for OIDC authentication #[arg(default_value = "cashu-client")] #[arg(long)] @@ -36,17 +30,16 @@ pub async fn cat_login( work_dir: &Path, ) -> Result<()> { let mint_url = sub_command_args.mint_url.clone(); - let unit = CurrencyUnit::from_str(&sub_command_args.unit)?; - let wallet = match multi_mint_wallet - .get_wallet(&WalletKey::new(mint_url.clone(), unit.clone())) - .await - { + let wallet = match multi_mint_wallet.get_wallet(&mint_url).await { Some(wallet) => wallet.clone(), None => { + multi_mint_wallet.add_mint(mint_url.clone(), None).await?; multi_mint_wallet - .create_and_add_wallet(&mint_url.to_string(), unit, None) - .await? + .get_wallet(&mint_url) + .await + .expect("Wallet should exist after adding mint") + .clone() } }; diff --git a/crates/cdk-cli/src/sub_commands/create_request.rs b/crates/cdk-cli/src/sub_commands/create_request.rs index 66b08534..2959536d 100644 --- a/crates/cdk-cli/src/sub_commands/create_request.rs +++ b/crates/cdk-cli/src/sub_commands/create_request.rs @@ -6,9 +6,6 @@ use clap::Args; pub struct CreateRequestSubCommand { #[arg(short, long)] amount: Option, - /// Currency unit e.g. sat - #[arg(default_value = "sat")] - unit: String, /// Quote description description: Option, /// P2PK: Public key(s) for which the token can be spent with valid signature(s) @@ -48,7 +45,7 @@ pub async fn create_request( // Gather parameters for library call let params = pr::CreateRequestParams { amount: sub_command_args.amount, - unit: sub_command_args.unit.clone(), + unit: multi_mint_wallet.unit().to_string(), description: sub_command_args.description.clone(), pubkeys: sub_command_args.pubkey.clone(), num_sigs: sub_command_args.num_sigs, diff --git a/crates/cdk-cli/src/sub_commands/melt.rs b/crates/cdk-cli/src/sub_commands/melt.rs index e03e1a11..a07db2ff 100644 --- a/crates/cdk-cli/src/sub_commands/melt.rs +++ b/crates/cdk-cli/src/sub_commands/melt.rs @@ -4,19 +4,12 @@ use anyhow::{bail, Result}; use cdk::amount::{amount_for_offer, Amount, MSAT_IN_SAT}; use cdk::mint_url::MintUrl; use cdk::nuts::{CurrencyUnit, MeltOptions}; -use cdk::wallet::multi_mint_wallet::MultiMintWallet; -use cdk::wallet::types::WalletKey; -use cdk::wallet::{MeltQuote, Wallet}; +use cdk::wallet::MultiMintWallet; use cdk::Bolt11Invoice; use clap::{Args, ValueEnum}; use lightning::offers::offer::Offer; -use tokio::task::JoinSet; -use crate::sub_commands::balance::mint_balances; -use crate::utils::{ - get_number_input, get_user_input, get_wallet_by_index, get_wallet_by_mint_url, - validate_mint_number, -}; +use crate::utils::{get_number_input, get_user_input}; #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] pub enum PaymentType { @@ -30,40 +23,17 @@ pub enum PaymentType { #[derive(Args)] pub struct MeltSubCommand { - /// Currency unit e.g. sat - #[arg(default_value = "sat")] - unit: String, /// Mpp #[arg(short, long, conflicts_with = "mint_url")] mpp: bool, /// Mint URL to use for melting #[arg(long, conflicts_with = "mpp")] mint_url: Option, - /// Payment method (bolt11 or bolt12) + /// Payment method (bolt11, bolt12, or bip353) #[arg(long, default_value = "bolt11")] method: PaymentType, } -/// Helper function to process a melt quote and execute the payment -async fn process_payment(wallet: &Wallet, quote: MeltQuote) -> Result<()> { - // Display quote information - println!("Quote ID: {}", quote.id); - println!("Amount: {}", quote.amount); - println!("Fee Reserve: {}", quote.fee_reserve); - println!("State: {}", quote.state); - println!("Expiry: {}", quote.expiry); - - // Execute the payment - let melt = wallet.melt("e.id).await?; - println!("Paid: {}", melt.state); - - if let Some(preimage) = melt.preimage { - println!("Payment preimage: {preimage}"); - } - - Ok(()) -} - /// Helper function to check if there are enough funds and create appropriate MeltOptions fn create_melt_options( available_funds: u64, @@ -95,71 +65,149 @@ pub async fn pay( multi_mint_wallet: &MultiMintWallet, sub_command_args: &MeltSubCommand, ) -> Result<()> { - let unit = CurrencyUnit::from_str(&sub_command_args.unit)?; - let mints_amounts = mint_balances(multi_mint_wallet, &unit).await?; + // Check total balance across all wallets + let total_balance = multi_mint_wallet.total_balance().await?; + if total_balance == Amount::ZERO { + bail!("No funds available"); + } if sub_command_args.mpp { - // MPP logic only works with BOLT11 currently + // Manual MPP - user specifies which mints and amounts to use if !matches!(sub_command_args.method, PaymentType::Bolt11) { bail!("MPP is only supported for BOLT11 invoices"); } - // Collect mint numbers and amounts for MPP - let (mints, mint_amounts) = collect_mpp_inputs(&mints_amounts, &sub_command_args.mint_url)?; + let bolt11_str = get_user_input("Enter bolt11 invoice")?; + let _bolt11 = Bolt11Invoice::from_str(&bolt11_str)?; // Validate invoice format - // Process BOLT11 MPP payment - let bolt11 = Bolt11Invoice::from_str(&get_user_input("Enter bolt11 invoice request")?)?; + // Show available mints and balances + let balances = multi_mint_wallet.get_balances().await?; + println!("\nAvailable mints and balances:"); + for (i, (mint_url, balance)) in balances.iter().enumerate() { + println!( + " {}: {} - {} {}", + i, + mint_url, + balance, + multi_mint_wallet.unit() + ); + } - // Get quotes from all mints - let quotes = get_mpp_quotes( - multi_mint_wallet, - &mints_amounts, - &mints, - &mint_amounts, - &unit, - &bolt11, - ) - .await?; + // Collect mint selections and amounts + let mut mint_amounts = Vec::new(); + loop { + let mint_input = get_user_input("Enter mint number to use (or 'done' to finish)")?; - // Execute all melts - execute_mpp_melts(quotes).await?; - } else { - // Get wallet either by mint URL or by index - let wallet = if let Some(mint_url) = &sub_command_args.mint_url { - // Use the provided mint URL - get_wallet_by_mint_url(multi_mint_wallet, mint_url, unit.clone()).await? - } else { - // Fallback to the index-based selection - let mint_number: usize = get_number_input("Enter mint number to melt from")?; - get_wallet_by_index(multi_mint_wallet, &mints_amounts, mint_number, unit.clone()) - .await? - }; + if mint_input.to_lowercase() == "done" || mint_input.is_empty() { + break; + } - // Find the mint amount for the selected wallet to check available funds - let mint_url = &wallet.mint_url; - let mint_amount = mints_amounts + let mint_index: usize = mint_input.parse()?; + let mint_url = balances + .iter() + .nth(mint_index) + .map(|(url, _)| url.clone()) + .ok_or_else(|| anyhow::anyhow!("Invalid mint index"))?; + + let amount: u64 = get_number_input(&format!( + "Enter amount to use from this mint ({})", + multi_mint_wallet.unit() + ))?; + mint_amounts.push((mint_url, Amount::from(amount))); + } + + if mint_amounts.is_empty() { + bail!("No mints selected for MPP payment"); + } + + // Get quotes for each mint + println!("\nGetting melt quotes..."); + let quotes = multi_mint_wallet + .mpp_melt_quote(bolt11_str, mint_amounts) + .await?; + + // Display quotes + println!("\nMelt quotes obtained:"); + for (mint_url, quote) in "es { + println!(" {} - Quote ID: {}", mint_url, quote.id); + println!(" Amount: {}, Fee: {}", quote.amount, quote.fee_reserve); + } + + // Execute the melts + let quotes_to_execute: Vec<(MintUrl, String)> = quotes .iter() - .find(|(url, _)| url == mint_url) - .map(|(_, amount)| *amount) - .ok_or_else(|| anyhow::anyhow!("Could not find balance for mint: {}", mint_url))?; + .map(|(url, quote)| (url.clone(), quote.id.clone())) + .collect(); - let available_funds = >::into(mint_amount) * MSAT_IN_SAT; + println!("\nExecuting MPP payment..."); + let results = multi_mint_wallet.mpp_melt(quotes_to_execute).await?; - // Process payment based on payment method + // Display results + println!("\nPayment results:"); + let mut total_paid = Amount::ZERO; + let mut total_fees = Amount::ZERO; + + for (mint_url, melted) in results { + println!( + " {} - Paid: {}, Fee: {}", + mint_url, melted.amount, melted.fee_paid + ); + total_paid += melted.amount; + total_fees += melted.fee_paid; + + if let Some(preimage) = melted.preimage { + println!(" Preimage: {}", preimage); + } + } + + println!("\nTotal paid: {} {}", total_paid, multi_mint_wallet.unit()); + println!("Total fees: {} {}", total_fees, multi_mint_wallet.unit()); + } else { + let available_funds = >::into(total_balance) * MSAT_IN_SAT; + + // Process payment based on payment method using new unified interface match sub_command_args.method { PaymentType::Bolt11 => { // Process BOLT11 payment - let bolt11 = Bolt11Invoice::from_str(&get_user_input("Enter bolt11 invoice")?)?; + let bolt11_str = get_user_input("Enter bolt11 invoice")?; + let bolt11 = Bolt11Invoice::from_str(&bolt11_str)?; // Determine payment amount and options - let prompt = - "Enter the amount you would like to pay in sats for this amountless invoice."; + let prompt = format!( + "Enter the amount you would like to pay in {} for this amountless invoice.", + multi_mint_wallet.unit() + ); let options = - create_melt_options(available_funds, bolt11.amount_milli_satoshis(), prompt)?; + create_melt_options(available_funds, bolt11.amount_milli_satoshis(), &prompt)?; - // Process payment - let quote = wallet.melt_quote(bolt11.to_string(), options).await?; - process_payment(&wallet, quote).await?; + // Use mint-specific functions or auto-select + let melted = if let Some(mint_url) = &sub_command_args.mint_url { + // User specified a mint - use the new mint-specific functions + let mint_url = MintUrl::from_str(mint_url)?; + + // Create a melt quote for the specific mint + let quote = multi_mint_wallet + .melt_quote(&mint_url, bolt11_str.clone(), options) + .await?; + + println!("Melt quote created:"); + println!(" Quote ID: {}", quote.id); + println!(" Amount: {}", quote.amount); + println!(" Fee Reserve: {}", quote.fee_reserve); + + // Execute the melt + multi_mint_wallet + .melt_with_mint(&mint_url, "e.id) + .await? + } else { + // Let the wallet automatically select the best mint + multi_mint_wallet.melt(&bolt11_str, options, None).await? + }; + + println!("Payment successful: {:?}", melted); + if let Some(preimage) = melted.preimage { + println!("Payment preimage: {}", preimage); + } } PaymentType::Bolt12 => { // Process BOLT12 payment (offer) @@ -168,26 +216,117 @@ pub async fn pay( .map_err(|e| anyhow::anyhow!("Invalid BOLT12 offer: {:?}", e))?; // Determine if offer has an amount - let prompt = - "Enter the amount you would like to pay in sats for this amountless offer:"; + let prompt = format!( + "Enter the amount you would like to pay in {} for this amountless offer:", + multi_mint_wallet.unit() + ); let amount_msat = match amount_for_offer(&offer, &CurrencyUnit::Msat) { Ok(amount) => Some(u64::from(amount)), Err(_) => None, }; - let options = create_melt_options(available_funds, amount_msat, prompt)?; + let options = create_melt_options(available_funds, amount_msat, &prompt)?; + + // Get wallet for BOLT12 + let wallet = if let Some(mint_url) = &sub_command_args.mint_url { + // User specified a mint + let mint_url = MintUrl::from_str(mint_url)?; + multi_mint_wallet + .get_wallet(&mint_url) + .await + .ok_or_else(|| anyhow::anyhow!("Mint {} not found", mint_url))? + } else { + // Show available mints and let user select + let balances = multi_mint_wallet.get_balances().await?; + println!("\nAvailable mints:"); + for (i, (mint_url, balance)) in balances.iter().enumerate() { + println!( + " {}: {} - {} {}", + i, + mint_url, + balance, + multi_mint_wallet.unit() + ); + } + + let mint_number: usize = get_number_input("Enter mint number to melt from")?; + let selected_mint = balances + .iter() + .nth(mint_number) + .map(|(url, _)| url) + .ok_or_else(|| anyhow::anyhow!("Invalid mint number"))?; + + multi_mint_wallet + .get_wallet(selected_mint) + .await + .ok_or_else(|| anyhow::anyhow!("Mint {} not found", selected_mint))? + }; // Get melt quote for BOLT12 let quote = wallet.melt_bolt12_quote(offer_str, options).await?; - process_payment(&wallet, quote).await?; + + // Display quote info + println!("Melt quote created:"); + println!(" Quote ID: {}", quote.id); + println!(" Amount: {}", quote.amount); + println!(" Fee Reserve: {}", quote.fee_reserve); + println!(" State: {}", quote.state); + println!(" Expiry: {}", quote.expiry); + + // Execute the melt + let melted = wallet.melt("e.id).await?; + println!( + "Payment successful: Paid {} with fee {}", + melted.amount, melted.fee_paid + ); + if let Some(preimage) = melted.preimage { + println!("Payment preimage: {}", preimage); + } } PaymentType::Bip353 => { - let bip353_addr = get_user_input("Enter Bip353 address.")?; + let bip353_addr = get_user_input("Enter Bip353 address")?; - let prompt = - "Enter the amount you would like to pay in sats for this amountless offer:"; + let prompt = format!( + "Enter the amount you would like to pay in {} for this amountless offer:", + multi_mint_wallet.unit() + ); // BIP353 payments are always amountless for now - let options = create_melt_options(available_funds, None, prompt)?; + let options = create_melt_options(available_funds, None, &prompt)?; + + // Get wallet for BIP353 + let wallet = if let Some(mint_url) = &sub_command_args.mint_url { + // User specified a mint + let mint_url = MintUrl::from_str(mint_url)?; + multi_mint_wallet + .get_wallet(&mint_url) + .await + .ok_or_else(|| anyhow::anyhow!("Mint {} not found", mint_url))? + } else { + // Show available mints and let user select + let balances = multi_mint_wallet.get_balances().await?; + println!("\nAvailable mints:"); + for (i, (mint_url, balance)) in balances.iter().enumerate() { + println!( + " {}: {} - {} {}", + i, + mint_url, + balance, + multi_mint_wallet.unit() + ); + } + + let mint_number: usize = get_number_input("Enter mint number to melt from")?; + let selected_mint = balances + .iter() + .nth(mint_number) + .map(|(url, _)| url) + .ok_or_else(|| anyhow::anyhow!("Invalid mint number"))?; + + multi_mint_wallet + .get_wallet(selected_mint) + .await + .ok_or_else(|| anyhow::anyhow!("Mint {} not found", selected_mint))? + }; // Get melt quote for BIP353 address (internally resolves and gets BOLT12 quote) let quote = wallet @@ -196,153 +335,27 @@ pub async fn pay( options.expect("Amount is required").amount_msat(), ) .await?; - process_payment(&wallet, quote).await?; + + // Display quote info + println!("Melt quote created:"); + println!(" Quote ID: {}", quote.id); + println!(" Amount: {}", quote.amount); + println!(" Fee Reserve: {}", quote.fee_reserve); + println!(" State: {}", quote.state); + println!(" Expiry: {}", quote.expiry); + + // Execute the melt + let melted = wallet.melt("e.id).await?; + println!( + "Payment successful: Paid {} with fee {}", + melted.amount, melted.fee_paid + ); + if let Some(preimage) = melted.preimage { + println!("Payment preimage: {}", preimage); + } } } } Ok(()) } - -/// Collect mint numbers and amounts for MPP payments -fn collect_mpp_inputs( - mints_amounts: &[(MintUrl, Amount)], - mint_url_opt: &Option, -) -> Result<(Vec, Vec)> { - let mut mints = Vec::new(); - let mut mint_amounts = Vec::new(); - - // If a specific mint URL was provided, try to use it as the first mint - if let Some(mint_url) = mint_url_opt { - println!("Using mint URL {mint_url} as the first mint for MPP payment."); - - // Find the index of this mint in the mints_amounts list - if let Some(mint_index) = mints_amounts - .iter() - .position(|(url, _)| url.to_string() == *mint_url) - { - mints.push(mint_index); - let melt_amount: u64 = - get_number_input("Enter amount to mint from this mint in sats.")?; - mint_amounts.push(melt_amount); - } else { - println!( - "Warning: Mint URL not found or no balance. Continuing with manual selection." - ); - } - } - - // Continue with regular mint selection - loop { - let mint_number: String = - get_user_input("Enter mint number to melt from and -1 when done.")?; - - if mint_number == "-1" || mint_number.is_empty() { - break; - } - - let mint_number: usize = mint_number.parse()?; - validate_mint_number(mint_number, mints_amounts.len())?; - - mints.push(mint_number); - let melt_amount: u64 = get_number_input("Enter amount to mint from this mint in sats.")?; - mint_amounts.push(melt_amount); - } - - if mints.is_empty() { - bail!("No mints selected for MPP payment"); - } - - Ok((mints, mint_amounts)) -} - -/// Get quotes from all mints for MPP payment -async fn get_mpp_quotes( - multi_mint_wallet: &MultiMintWallet, - mints_amounts: &[(MintUrl, Amount)], - mints: &[usize], - mint_amounts: &[u64], - unit: &CurrencyUnit, - bolt11: &Bolt11Invoice, -) -> Result> { - let mut quotes = JoinSet::new(); - - for (mint, amount) in mints.iter().zip(mint_amounts) { - let wallet = mints_amounts[*mint].0.clone(); - - let wallet = multi_mint_wallet - .get_wallet(&WalletKey::new(wallet, unit.clone())) - .await - .expect("Known wallet"); - let options = MeltOptions::new_mpp(*amount * 1000); - - let bolt11_clone = bolt11.clone(); - - quotes.spawn(async move { - let quote = wallet - .melt_quote(bolt11_clone.to_string(), Some(options)) - .await; - - (wallet, quote) - }); - } - - let quotes_results = quotes.join_all().await; - - // Validate all quotes succeeded - let mut valid_quotes = Vec::new(); - for (wallet, quote_result) in quotes_results { - match quote_result { - Ok(quote) => { - println!( - "Melt quote {} for mint {} of amount {} with fee {}.", - quote.id, wallet.mint_url, quote.amount, quote.fee_reserve - ); - valid_quotes.push((wallet, quote)); - } - Err(err) => { - tracing::error!("Could not get quote for {}: {:?}", wallet.mint_url, err); - bail!("Could not get melt quote for {}", wallet.mint_url); - } - } - } - - Ok(valid_quotes) -} - -/// Execute all melts for MPP payment -async fn execute_mpp_melts(quotes: Vec<(Wallet, MeltQuote)>) -> Result<()> { - let mut melts = JoinSet::new(); - - for (wallet, quote) in quotes { - melts.spawn(async move { - let melt = wallet.melt("e.id).await; - (wallet, melt) - }); - } - - let melts = melts.join_all().await; - - let mut error = false; - - for (wallet, melt) in melts { - match melt { - Ok(melt) => { - println!( - "Melt for {} paid {} with fee of {} ", - wallet.mint_url, melt.amount, melt.fee_paid - ); - } - Err(err) => { - println!("Melt for {} failed with {}", wallet.mint_url, err); - error = true; - } - } - } - - if error { - bail!("Could not complete all melts"); - } - - Ok(()) -} diff --git a/crates/cdk-cli/src/sub_commands/mint.rs b/crates/cdk-cli/src/sub_commands/mint.rs index 670e72dc..84ca828a 100644 --- a/crates/cdk-cli/src/sub_commands/mint.rs +++ b/crates/cdk-cli/src/sub_commands/mint.rs @@ -4,7 +4,7 @@ use anyhow::{anyhow, Result}; use cdk::amount::SplitTarget; use cdk::mint_url::MintUrl; use cdk::nuts::nut00::ProofsMethods; -use cdk::nuts::{CurrencyUnit, PaymentMethod}; +use cdk::nuts::PaymentMethod; use cdk::wallet::MultiMintWallet; use cdk::{Amount, StreamExt}; use clap::Args; @@ -18,9 +18,6 @@ pub struct MintSubCommand { mint_url: MintUrl, /// Amount amount: Option, - /// Currency unit e.g. sat - #[arg(default_value = "sat")] - unit: String, /// Quote description #[serde(skip_serializing_if = "Option::is_none")] description: Option, @@ -46,10 +43,9 @@ pub async fn mint( sub_command_args: &MintSubCommand, ) -> Result<()> { let mint_url = sub_command_args.mint_url.clone(); - let unit = CurrencyUnit::from_str(&sub_command_args.unit)?; let description: Option = sub_command_args.description.clone(); - let wallet = get_or_create_wallet(multi_mint_wallet, &mint_url, unit).await?; + let wallet = get_or_create_wallet(multi_mint_wallet, &mint_url).await?; let payment_method = PaymentMethod::from_str(&sub_command_args.method)?; diff --git a/crates/cdk-cli/src/sub_commands/mint_blind_auth.rs b/crates/cdk-cli/src/sub_commands/mint_blind_auth.rs index cf55b583..8e14341f 100644 --- a/crates/cdk-cli/src/sub_commands/mint_blind_auth.rs +++ b/crates/cdk-cli/src/sub_commands/mint_blind_auth.rs @@ -1,10 +1,8 @@ use std::path::Path; -use std::str::FromStr; use anyhow::{anyhow, Result}; use cdk::mint_url::MintUrl; -use cdk::nuts::{CurrencyUnit, MintInfo}; -use cdk::wallet::types::WalletKey; +use cdk::nuts::MintInfo; use cdk::wallet::MultiMintWallet; use cdk::{Amount, OidcClient}; use clap::Args; @@ -21,10 +19,6 @@ pub struct MintBlindAuthSubCommand { /// Cat (access token) #[arg(long)] cat: Option, - /// Currency unit e.g. sat - #[arg(default_value = "sat")] - #[arg(short, long)] - unit: String, } pub async fn mint_blind_auth( @@ -33,17 +27,16 @@ pub async fn mint_blind_auth( work_dir: &Path, ) -> Result<()> { let mint_url = sub_command_args.mint_url.clone(); - let unit = CurrencyUnit::from_str(&sub_command_args.unit)?; - let wallet = match multi_mint_wallet - .get_wallet(&WalletKey::new(mint_url.clone(), unit.clone())) - .await - { + let wallet = match multi_mint_wallet.get_wallet(&mint_url).await { Some(wallet) => wallet.clone(), None => { + multi_mint_wallet.add_mint(mint_url.clone(), None).await?; multi_mint_wallet - .create_and_add_wallet(&mint_url.to_string(), unit, None) - .await? + .get_wallet(&mint_url) + .await + .expect("Wallet should exist after adding mint") + .clone() } }; diff --git a/crates/cdk-cli/src/sub_commands/mod.rs b/crates/cdk-cli/src/sub_commands/mod.rs index aaf1cb92..0a52cf12 100644 --- a/crates/cdk-cli/src/sub_commands/mod.rs +++ b/crates/cdk-cli/src/sub_commands/mod.rs @@ -16,4 +16,5 @@ pub mod pending_mints; pub mod receive; pub mod restore; pub mod send; +pub mod transfer; pub mod update_mint_url; diff --git a/crates/cdk-cli/src/sub_commands/pending_mints.rs b/crates/cdk-cli/src/sub_commands/pending_mints.rs index a752d11c..b033f9ee 100644 --- a/crates/cdk-cli/src/sub_commands/pending_mints.rs +++ b/crates/cdk-cli/src/sub_commands/pending_mints.rs @@ -2,11 +2,9 @@ use anyhow::Result; use cdk::wallet::MultiMintWallet; pub async fn mint_pending(multi_mint_wallet: &MultiMintWallet) -> Result<()> { - let amounts = multi_mint_wallet.check_all_mint_quotes(None).await?; + let amount = multi_mint_wallet.check_all_mint_quotes(None).await?; - for (unit, amount) in amounts { - println!("Unit: {unit}, Amount: {amount}"); - } + println!("Amount: {amount}"); Ok(()) } diff --git a/crates/cdk-cli/src/sub_commands/receive.rs b/crates/cdk-cli/src/sub_commands/receive.rs index 0bb219d7..ee04c524 100644 --- a/crates/cdk-cli/src/sub_commands/receive.rs +++ b/crates/cdk-cli/src/sub_commands/receive.rs @@ -4,11 +4,11 @@ use std::str::FromStr; use std::time::Duration; use anyhow::{anyhow, Result}; +use cdk::mint_url::MintUrl; use cdk::nuts::{SecretKey, Token}; use cdk::util::unix_time; use cdk::wallet::multi_mint_wallet::MultiMintWallet; -use cdk::wallet::types::WalletKey; -use cdk::wallet::ReceiveOptions; +use cdk::wallet::{MultiMintReceiveOptions, ReceiveOptions}; use cdk::Amount; use clap::Args; use nostr_sdk::nips::nip04; @@ -36,6 +36,12 @@ pub struct ReceiveSubCommand { /// Preimage #[arg(short, long, action = clap::ArgAction::Append)] preimage: Vec, + /// Allow receiving from untrusted mints (mints not already in the wallet) + #[arg(long, default_value = "false")] + allow_untrusted: bool, + /// Transfer tokens from untrusted mints to this mint + #[arg(long, value_name = "MINT_URL")] + transfer_to: Option, } pub async fn receive( @@ -69,6 +75,8 @@ pub async fn receive( token_str, &signing_keys, &sub_command_args.preimage, + sub_command_args.allow_untrusted, + sub_command_args.transfer_to.as_deref(), ) .await? } @@ -109,6 +117,8 @@ pub async fn receive( token_str, &signing_keys, &sub_command_args.preimage, + sub_command_args.allow_untrusted, + sub_command_args.transfer_to.as_deref(), ) .await { @@ -135,29 +145,40 @@ async fn receive_token( token_str: &str, signing_keys: &[SecretKey], preimage: &[String], + allow_untrusted: bool, + transfer_to: Option<&str>, ) -> Result { let token: Token = Token::from_str(token_str)?; let mint_url = token.mint_url()?; - let unit = token.unit().unwrap_or_default(); - if multi_mint_wallet - .get_wallet(&WalletKey::new(mint_url.clone(), unit.clone())) - .await - .is_none() - { - get_or_create_wallet(multi_mint_wallet, &mint_url, unit).await?; + // Parse transfer_to mint URL if provided + let transfer_to_mint = if let Some(mint_str) = transfer_to { + Some(MintUrl::from_str(mint_str)?) + } else { + None + }; + + // Check if the mint is already trusted + let is_trusted = multi_mint_wallet.get_wallet(&mint_url).await.is_some(); + + // If mint is not trusted and we don't allow untrusted, add it first (old behavior) + if !is_trusted && !allow_untrusted { + get_or_create_wallet(multi_mint_wallet, &mint_url).await?; } + // Create multi-mint receive options + let multi_mint_options = MultiMintReceiveOptions::default() + .allow_untrusted(allow_untrusted) + .transfer_to_mint(transfer_to_mint) + .receive_options(ReceiveOptions { + p2pk_signing_keys: signing_keys.to_vec(), + preimages: preimage.to_vec(), + ..Default::default() + }); + let amount = multi_mint_wallet - .receive( - token_str, - ReceiveOptions { - p2pk_signing_keys: signing_keys.to_vec(), - preimages: preimage.to_vec(), - ..Default::default() - }, - ) + .receive(token_str, multi_mint_options) .await?; Ok(amount) } diff --git a/crates/cdk-cli/src/sub_commands/restore.rs b/crates/cdk-cli/src/sub_commands/restore.rs index 9f12e847..56247a4a 100644 --- a/crates/cdk-cli/src/sub_commands/restore.rs +++ b/crates/cdk-cli/src/sub_commands/restore.rs @@ -1,9 +1,5 @@ -use std::str::FromStr; - use anyhow::Result; use cdk::mint_url::MintUrl; -use cdk::nuts::CurrencyUnit; -use cdk::wallet::types::WalletKey; use cdk::wallet::MultiMintWallet; use clap::Args; @@ -11,27 +7,23 @@ use clap::Args; pub struct RestoreSubCommand { /// Mint Url mint_url: MintUrl, - /// Currency unit e.g. sat - #[arg(default_value = "sat")] - unit: String, } pub async fn restore( multi_mint_wallet: &MultiMintWallet, sub_command_args: &RestoreSubCommand, ) -> Result<()> { - let unit = CurrencyUnit::from_str(&sub_command_args.unit)?; let mint_url = sub_command_args.mint_url.clone(); - let wallet = match multi_mint_wallet - .get_wallet(&WalletKey::new(mint_url.clone(), unit.clone())) - .await - { + let wallet = match multi_mint_wallet.get_wallet(&mint_url).await { Some(wallet) => wallet.clone(), None => { + multi_mint_wallet.add_mint(mint_url.clone(), None).await?; multi_mint_wallet - .create_and_add_wallet(&mint_url.to_string(), unit, None) - .await? + .get_wallet(&mint_url) + .await + .expect("Wallet should exist after adding mint") + .clone() } }; diff --git a/crates/cdk-cli/src/sub_commands/send.rs b/crates/cdk-cli/src/sub_commands/send.rs index e1b37bff..0c8fb61f 100644 --- a/crates/cdk-cli/src/sub_commands/send.rs +++ b/crates/cdk-cli/src/sub_commands/send.rs @@ -1,16 +1,14 @@ use std::str::FromStr; use anyhow::{anyhow, Result}; -use cdk::nuts::{Conditions, CurrencyUnit, PublicKey, SpendingConditions}; +use cdk::mint_url::MintUrl; +use cdk::nuts::{Conditions, PublicKey, SpendingConditions}; use cdk::wallet::types::SendKind; use cdk::wallet::{MultiMintWallet, SendMemo, SendOptions}; use cdk::Amount; use clap::Args; -use crate::sub_commands::balance::mint_balances; -use crate::utils::{ - check_sufficient_funds, get_number_input, get_wallet_by_index, get_wallet_by_mint_url, -}; +use crate::utils::get_number_input; #[derive(Args)] pub struct SendSubCommand { @@ -50,39 +48,38 @@ pub struct SendSubCommand { /// Mint URL to use for sending #[arg(long)] mint_url: Option, - /// Currency unit e.g. sat - #[arg(default_value = "sat")] - unit: String, + /// Allow transferring funds from other mints if the target mint has insufficient balance + #[arg(long)] + allow_transfer: bool, + /// Maximum amount to transfer from other mints + #[arg(long)] + max_transfer_amount: Option, + /// Specific mints allowed for transfers (can be specified multiple times) + #[arg(long, action = clap::ArgAction::Append)] + allowed_mints: Vec, + /// Specific mints to exclude from transfers (can be specified multiple times) + #[arg(long, action = clap::ArgAction::Append)] + excluded_mints: Vec, } pub async fn send( multi_mint_wallet: &MultiMintWallet, sub_command_args: &SendSubCommand, ) -> Result<()> { - let unit = CurrencyUnit::from_str(&sub_command_args.unit)?; - let mints_amounts = mint_balances(multi_mint_wallet, &unit).await?; + let token_amount = Amount::from(get_number_input::(&format!( + "Enter value of token in {}", + multi_mint_wallet.unit() + ))?); - // Get wallet either by mint URL or by index - let wallet = if let Some(mint_url) = &sub_command_args.mint_url { - // Use the provided mint URL - get_wallet_by_mint_url(multi_mint_wallet, mint_url, unit).await? - } else { - // Fallback to the index-based selection - let mint_number: usize = get_number_input("Enter mint number to create token")?; - get_wallet_by_index(multi_mint_wallet, &mints_amounts, mint_number, unit).await? - }; - - let token_amount = Amount::from(get_number_input::("Enter value of token in sats")?); - - // Find the mint amount for the selected wallet to check if we have sufficient funds - let mint_url = &wallet.mint_url; - let mint_amount = mints_amounts - .iter() - .find(|(url, _)| url == mint_url) - .map(|(_, amount)| *amount) - .ok_or_else(|| anyhow!("Could not find balance for mint: {}", mint_url))?; - - check_sufficient_funds(mint_amount, token_amount)?; + // Check total balance across all wallets + let total_balance = multi_mint_wallet.total_balance().await?; + if total_balance < token_amount { + return Err(anyhow!( + "Insufficient funds. Total balance: {}, Required: {}", + total_balance, + token_amount + )); + } let conditions = match (&sub_command_args.preimage, &sub_command_args.hash) { (Some(_), Some(_)) => { @@ -206,22 +203,74 @@ pub async fn send( (false, None) => SendKind::OnlineExact, }; - let prepared_send = wallet - .prepare_send( - token_amount, - SendOptions { - memo: sub_command_args.memo.clone().map(|memo| SendMemo { - memo, - include_memo: true, - }), - send_kind, - include_fee: sub_command_args.include_fee, - conditions, - ..Default::default() - }, - ) - .await?; - let token = prepared_send.confirm(None).await?; + let send_options = SendOptions { + memo: sub_command_args.memo.clone().map(|memo| SendMemo { + memo, + include_memo: true, + }), + send_kind, + include_fee: sub_command_args.include_fee, + conditions, + ..Default::default() + }; + + // Parse allowed and excluded mints from CLI arguments + let allowed_mints: Result, _> = sub_command_args + .allowed_mints + .iter() + .map(|url| MintUrl::from_str(url)) + .collect(); + let allowed_mints = allowed_mints?; + + let excluded_mints: Result, _> = sub_command_args + .excluded_mints + .iter() + .map(|url| MintUrl::from_str(url)) + .collect(); + let excluded_mints = excluded_mints?; + + // Create MultiMintSendOptions from CLI arguments + let multi_mint_options = cdk::wallet::multi_mint_wallet::MultiMintSendOptions { + allow_transfer: sub_command_args.allow_transfer, + max_transfer_amount: sub_command_args.max_transfer_amount.map(Amount::from), + allowed_mints, + excluded_mints, + send_options: send_options.clone(), + }; + + // Use the new unified interface + let token = if let Some(mint_url) = &sub_command_args.mint_url { + // User specified a mint, use that specific wallet + let mint_url = cdk::mint_url::MintUrl::from_str(mint_url)?; + let prepared = multi_mint_wallet + .prepare_send(mint_url, token_amount, multi_mint_options) + .await?; + + // Confirm the prepared send (single mint) + let memo = send_options.memo.clone(); + prepared.confirm(memo).await? + } else { + // Let the wallet automatically select the best mint + // First, get balances to find a mint with sufficient funds + let balances = multi_mint_wallet.get_balances().await?; + + // Find a mint with sufficient balance + let mint_url = balances + .into_iter() + .find(|(_, balance)| *balance >= token_amount) + .map(|(mint_url, _)| mint_url) + .ok_or_else(|| { + anyhow::anyhow!("No mint has sufficient balance for the requested amount") + })?; + + let prepared = multi_mint_wallet + .prepare_send(mint_url, token_amount, multi_mint_options) + .await?; + + // Confirm the prepared send (multi mint) + let memo = send_options.memo.clone(); + prepared.confirm(memo).await? + }; match sub_command_args.v3 { true => { diff --git a/crates/cdk-cli/src/sub_commands/transfer.rs b/crates/cdk-cli/src/sub_commands/transfer.rs new file mode 100644 index 00000000..e8042b29 --- /dev/null +++ b/crates/cdk-cli/src/sub_commands/transfer.rs @@ -0,0 +1,209 @@ +use std::str::FromStr; + +use anyhow::{bail, Result}; +use cdk::mint_url::MintUrl; +use cdk::wallet::multi_mint_wallet::TransferMode; +use cdk::wallet::MultiMintWallet; +use cdk::Amount; +use clap::Args; + +use crate::utils::get_number_input; + +#[derive(Args)] +pub struct TransferSubCommand { + /// Source mint URL to transfer from (optional - will prompt if not provided) + #[arg(long)] + source_mint: Option, + /// Target mint URL to transfer to (optional - will prompt if not provided) + #[arg(long)] + target_mint: Option, + /// Amount to transfer (optional - will prompt if not provided) + #[arg(short, long, conflicts_with = "full_balance")] + amount: Option, + /// Transfer all available balance from source mint + #[arg(long, conflicts_with = "amount")] + full_balance: bool, +} + +/// Helper function to select a mint from available mints +async fn select_mint( + multi_mint_wallet: &MultiMintWallet, + prompt: &str, + exclude_mint: Option<&MintUrl>, +) -> Result { + let balances = multi_mint_wallet.get_balances().await?; + + // Filter out excluded mint if provided + let available_mints: Vec<_> = balances + .iter() + .filter(|(url, _)| exclude_mint.is_none_or(|excluded| url != &excluded)) + .collect(); + + if available_mints.is_empty() { + bail!("No available mints found"); + } + + println!("\nAvailable mints:"); + for (i, (mint_url, balance)) in available_mints.iter().enumerate() { + println!( + " {}: {} - {} {}", + i, + mint_url, + balance, + multi_mint_wallet.unit() + ); + } + + let mint_number: usize = get_number_input(prompt)?; + available_mints + .get(mint_number) + .map(|(url, _)| (*url).clone()) + .ok_or_else(|| anyhow::anyhow!("Invalid mint number")) +} + +pub async fn transfer( + multi_mint_wallet: &MultiMintWallet, + sub_command_args: &TransferSubCommand, +) -> Result<()> { + // Check total balance across all wallets + let total_balance = multi_mint_wallet.total_balance().await?; + if total_balance == Amount::ZERO { + bail!("No funds available"); + } + + // Get source mint URL either from args or by prompting user + let source_mint_url = if let Some(source_mint) = &sub_command_args.source_mint { + let url = MintUrl::from_str(source_mint)?; + // Verify the mint is in the wallet + if !multi_mint_wallet.has_mint(&url).await { + bail!( + "Source mint {} is not in the wallet. Please add it first.", + url + ); + } + url + } else { + // Show available mints and let user select source + select_mint( + multi_mint_wallet, + "Enter source mint number to transfer from", + None, + ) + .await? + }; + + // Get target mint URL either from args or by prompting user + let target_mint_url = if let Some(target_mint) = &sub_command_args.target_mint { + let url = MintUrl::from_str(target_mint)?; + // Verify the mint is in the wallet + if !multi_mint_wallet.has_mint(&url).await { + bail!( + "Target mint {} is not in the wallet. Please add it first.", + url + ); + } + url + } else { + // Show available mints (excluding source) and let user select target + select_mint( + multi_mint_wallet, + "Enter target mint number to transfer to", + Some(&source_mint_url), + ) + .await? + }; + + // Ensure source and target are different + if source_mint_url == target_mint_url { + bail!("Source and target mints must be different"); + } + + // Check source mint balance + let balances = multi_mint_wallet.get_balances().await?; + let source_balance = balances + .get(&source_mint_url) + .copied() + .unwrap_or(Amount::ZERO); + + if source_balance == Amount::ZERO { + bail!("Source mint has no balance to transfer"); + } + + // Determine transfer mode based on user input + let transfer_mode = if sub_command_args.full_balance { + println!( + "\nTransferring full balance ({} {}) from {} to {}...", + source_balance, + multi_mint_wallet.unit(), + source_mint_url, + target_mint_url + ); + TransferMode::FullBalance + } else { + let amount = match sub_command_args.amount { + Some(amt) => Amount::from(amt), + None => Amount::from(get_number_input::(&format!( + "Enter amount to transfer in {}", + multi_mint_wallet.unit() + ))?), + }; + + if source_balance < amount { + bail!( + "Insufficient funds in source mint. Available: {} {}, Required: {} {}", + source_balance, + multi_mint_wallet.unit(), + amount, + multi_mint_wallet.unit() + ); + } + + println!( + "\nTransferring {} {} from {} to {}...", + amount, + multi_mint_wallet.unit(), + source_mint_url, + target_mint_url + ); + TransferMode::ExactReceive(amount) + }; + + // Perform the transfer + let transfer_result = multi_mint_wallet + .transfer(&source_mint_url, &target_mint_url, transfer_mode) + .await?; + + println!("\nTransfer completed successfully!"); + println!( + "Amount sent: {} {}", + transfer_result.amount_sent, + multi_mint_wallet.unit() + ); + println!( + "Amount received: {} {}", + transfer_result.amount_received, + multi_mint_wallet.unit() + ); + if transfer_result.fees_paid > Amount::ZERO { + println!( + "Fees paid: {} {}", + transfer_result.fees_paid, + multi_mint_wallet.unit() + ); + } + println!("\nUpdated balances:"); + println!( + " Source mint ({}): {} {}", + source_mint_url, + transfer_result.source_balance_after, + multi_mint_wallet.unit() + ); + println!( + " Target mint ({}): {} {}", + target_mint_url, + transfer_result.target_balance_after, + multi_mint_wallet.unit() + ); + + Ok(()) +} diff --git a/crates/cdk-cli/src/sub_commands/update_mint_url.rs b/crates/cdk-cli/src/sub_commands/update_mint_url.rs index b67495b1..51afc6fc 100644 --- a/crates/cdk-cli/src/sub_commands/update_mint_url.rs +++ b/crates/cdk-cli/src/sub_commands/update_mint_url.rs @@ -1,7 +1,5 @@ use anyhow::{anyhow, Result}; use cdk::mint_url::MintUrl; -use cdk::nuts::CurrencyUnit; -use cdk::wallet::types::WalletKey; use cdk::wallet::MultiMintWallet; use clap::Args; @@ -23,10 +21,7 @@ pub async fn update_mint_url( } = sub_command_args; let mut wallet = multi_mint_wallet - .get_wallet(&WalletKey::new( - sub_command_args.old_mint_url.clone(), - CurrencyUnit::Sat, - )) + .get_wallet(&sub_command_args.old_mint_url) .await .ok_or(anyhow!("Unknown mint url"))? .clone(); diff --git a/crates/cdk-cli/src/utils.rs b/crates/cdk-cli/src/utils.rs index c7100509..63396cc3 100644 --- a/crates/cdk-cli/src/utils.rs +++ b/crates/cdk-cli/src/utils.rs @@ -1,12 +1,9 @@ use std::io::{self, Write}; use std::str::FromStr; -use anyhow::{bail, Result}; +use anyhow::Result; use cdk::mint_url::MintUrl; -use cdk::nuts::CurrencyUnit; use cdk::wallet::multi_mint_wallet::MultiMintWallet; -use cdk::wallet::types::WalletKey; -use cdk::Amount; /// Helper function to get user input with a prompt pub fn get_user_input(prompt: &str) -> Result { @@ -28,73 +25,21 @@ where Ok(number) } -/// Helper function to validate a mint number against available mints -pub fn validate_mint_number(mint_number: usize, mint_count: usize) -> Result<()> { - if mint_number >= mint_count { - bail!("Invalid mint number"); - } - Ok(()) -} - -/// Helper function to check if there are enough funds for an operation -pub fn check_sufficient_funds(available: Amount, required: Amount) -> Result<()> { - if required.gt(&available) { - bail!("Not enough funds"); - } - Ok(()) -} - -/// Helper function to get a wallet from the multi-mint wallet by mint URL -pub async fn get_wallet_by_mint_url( - multi_mint_wallet: &MultiMintWallet, - mint_url_str: &str, - unit: CurrencyUnit, -) -> Result { - let mint_url = MintUrl::from_str(mint_url_str)?; - - let wallet_key = WalletKey::new(mint_url.clone(), unit); - let wallet = multi_mint_wallet - .get_wallet(&wallet_key) - .await - .ok_or_else(|| anyhow::anyhow!("Wallet not found for mint URL: {}", mint_url_str))?; - - Ok(wallet.clone()) -} - -/// Helper function to get a wallet from the multi-mint wallet -pub async fn get_wallet_by_index( - multi_mint_wallet: &MultiMintWallet, - mint_amounts: &[(MintUrl, Amount)], - mint_number: usize, - unit: CurrencyUnit, -) -> Result { - validate_mint_number(mint_number, mint_amounts.len())?; - - let wallet_key = WalletKey::new(mint_amounts[mint_number].0.clone(), unit); - let wallet = multi_mint_wallet - .get_wallet(&wallet_key) - .await - .ok_or_else(|| anyhow::anyhow!("Wallet not found"))?; - - Ok(wallet.clone()) -} - /// Helper function to create or get a wallet pub async fn get_or_create_wallet( multi_mint_wallet: &MultiMintWallet, mint_url: &MintUrl, - unit: CurrencyUnit, ) -> Result { - match multi_mint_wallet - .get_wallet(&WalletKey::new(mint_url.clone(), unit.clone())) - .await - { + match multi_mint_wallet.get_wallet(mint_url).await { Some(wallet) => Ok(wallet.clone()), None => { tracing::debug!("Wallet does not exist creating.."); - multi_mint_wallet - .create_and_add_wallet(&mint_url.to_string(), unit, None) + multi_mint_wallet.add_mint(mint_url.clone(), None).await?; + Ok(multi_mint_wallet + .get_wallet(mint_url) .await + .expect("Wallet should exist after adding mint") + .clone()) } } } diff --git a/crates/cdk-common/src/error.rs b/crates/cdk-common/src/error.rs index 9e0f8fd7..b20d1da4 100644 --- a/crates/cdk-common/src/error.rs +++ b/crates/cdk-common/src/error.rs @@ -250,6 +250,32 @@ pub enum Error { /// Preimage not provided #[error("Preimage not provided")] PreimageNotProvided, + + // MultiMint Wallet Errors + /// Currency unit mismatch in MultiMintWallet + #[error("Currency unit mismatch: wallet uses {expected}, but {found} provided")] + MultiMintCurrencyUnitMismatch { + /// Expected currency unit + expected: CurrencyUnit, + /// Found currency unit + found: CurrencyUnit, + }, + /// Unknown mint in MultiMintWallet + #[error("Unknown mint: {mint_url}")] + UnknownMint { + /// URL of the unknown mint + mint_url: String, + }, + /// Transfer between mints timed out + #[error("Transfer timeout: failed to transfer {amount} from {source_mint} to {target_mint}")] + TransferTimeout { + /// Source mint URL + source_mint: String, + /// Target mint URL + target_mint: String, + /// Amount that failed to transfer + amount: Amount, + }, /// Insufficient Funds #[error("Insufficient funds")] InsufficientFunds, diff --git a/crates/cdk-ffi/src/lib.rs b/crates/cdk-ffi/src/lib.rs index c8c37c33..37433e54 100644 --- a/crates/cdk-ffi/src/lib.rs +++ b/crates/cdk-ffi/src/lib.rs @@ -4,11 +4,13 @@ pub mod database; pub mod error; +pub mod multi_mint_wallet; pub mod types; pub mod wallet; pub use database::*; pub use error::*; +pub use multi_mint_wallet::*; pub use types::*; pub use wallet::*; diff --git a/crates/cdk-ffi/src/multi_mint_wallet.rs b/crates/cdk-ffi/src/multi_mint_wallet.rs new file mode 100644 index 00000000..4de429cf --- /dev/null +++ b/crates/cdk-ffi/src/multi_mint_wallet.rs @@ -0,0 +1,416 @@ +//! FFI MultiMintWallet bindings + +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::Arc; + +use bip39::Mnemonic; +use cdk::wallet::multi_mint_wallet::{ + MultiMintReceiveOptions as CdkMultiMintReceiveOptions, + MultiMintSendOptions as CdkMultiMintSendOptions, MultiMintWallet as CdkMultiMintWallet, + TransferMode as CdkTransferMode, TransferResult as CdkTransferResult, +}; + +use crate::error::FfiError; +use crate::types::*; + +/// FFI-compatible MultiMintWallet +#[derive(uniffi::Object)] +pub struct MultiMintWallet { + inner: Arc, +} + +#[uniffi::export(async_runtime = "tokio")] +impl MultiMintWallet { + /// Create a new MultiMintWallet from mnemonic using WalletDatabase trait + #[uniffi::constructor] + pub async fn new( + unit: CurrencyUnit, + mnemonic: String, + db: Arc, + ) -> Result { + // Parse mnemonic and generate seed without passphrase + let m = Mnemonic::parse(&mnemonic) + .map_err(|e| FfiError::InvalidMnemonic { msg: e.to_string() })?; + let seed = m.to_seed_normalized(""); + + // Convert the FFI database trait to a CDK database implementation + let localstore = crate::database::create_cdk_database_from_ffi(db); + + let wallet = CdkMultiMintWallet::new(localstore, seed, unit.into()).await?; + + Ok(Self { + inner: Arc::new(wallet), + }) + } + + /// Create a new MultiMintWallet with proxy configuration + #[uniffi::constructor] + pub async fn new_with_proxy( + unit: CurrencyUnit, + mnemonic: String, + db: Arc, + proxy_url: String, + ) -> Result { + // Parse mnemonic and generate seed without passphrase + let m = Mnemonic::parse(&mnemonic) + .map_err(|e| FfiError::InvalidMnemonic { msg: e.to_string() })?; + let seed = m.to_seed_normalized(""); + + // Convert the FFI database trait to a CDK database implementation + let localstore = crate::database::create_cdk_database_from_ffi(db); + + // Parse proxy URL + let proxy_url = + url::Url::parse(&proxy_url).map_err(|e| FfiError::InvalidUrl { msg: e.to_string() })?; + + let wallet = + CdkMultiMintWallet::new_with_proxy(localstore, seed, unit.into(), proxy_url).await?; + + Ok(Self { + inner: Arc::new(wallet), + }) + } + + /// Get the currency unit for this wallet + pub fn unit(&self) -> CurrencyUnit { + self.inner.unit().clone().into() + } + + /// Add a mint to this MultiMintWallet + pub async fn add_mint( + &self, + mint_url: MintUrl, + target_proof_count: Option, + ) -> Result<(), FfiError> { + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?; + self.inner + .add_mint(cdk_mint_url, target_proof_count.map(|c| c as usize)) + .await?; + Ok(()) + } + + /// Remove mint from MultiMintWallet + pub async fn remove_mint(&self, mint_url: MintUrl) { + let url_str = mint_url.url.clone(); + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into().unwrap_or_else(|_| { + // If conversion fails, we can't remove the mint, but we shouldn't panic + // This is a best-effort operation + cdk::mint_url::MintUrl::from_str(&url_str).unwrap_or_else(|_| { + // Last resort: create a dummy URL that won't match anything + cdk::mint_url::MintUrl::from_str("https://invalid.mint").unwrap() + }) + }); + self.inner.remove_mint(&cdk_mint_url).await; + } + + /// Check if mint is in wallet + pub async fn has_mint(&self, mint_url: MintUrl) -> bool { + if let Ok(cdk_mint_url) = mint_url.try_into() { + self.inner.has_mint(&cdk_mint_url).await + } else { + false + } + } + + /// Get wallet balances for all mints + pub async fn get_balances(&self) -> Result { + let balances = self.inner.get_balances().await?; + let mut balance_map = HashMap::new(); + for (mint_url, amount) in balances { + balance_map.insert(mint_url.to_string(), amount.into()); + } + Ok(balance_map) + } + + /// Get total balance across all mints + pub async fn total_balance(&self) -> Result { + let total = self.inner.total_balance().await?; + Ok(total.into()) + } + + /// List proofs for all mints + pub async fn list_proofs(&self) -> Result { + let proofs = self.inner.list_proofs().await?; + let mut proofs_by_mint = HashMap::new(); + for (mint_url, mint_proofs) in proofs { + let ffi_proofs: Vec> = mint_proofs + .into_iter() + .map(|p| Arc::new(p.into())) + .collect(); + proofs_by_mint.insert(mint_url.to_string(), ffi_proofs); + } + Ok(proofs_by_mint) + } + + /// Receive token + pub async fn receive( + &self, + token: Arc, + options: MultiMintReceiveOptions, + ) -> Result { + let amount = self + .inner + .receive(&token.to_string(), options.into()) + .await?; + Ok(amount.into()) + } + + /// Restore wallets for a specific mint + pub async fn restore(&self, mint_url: MintUrl) -> Result { + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?; + let amount = self.inner.restore(&cdk_mint_url).await?; + Ok(amount.into()) + } + + /// Prepare a send operation from a specific mint + pub async fn prepare_send( + &self, + mint_url: MintUrl, + amount: Amount, + options: MultiMintSendOptions, + ) -> Result, FfiError> { + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?; + let prepared = self + .inner + .prepare_send(cdk_mint_url, amount.into(), options.into()) + .await?; + Ok(Arc::new(prepared.into())) + } + + /// Get a mint quote from a specific mint + pub async fn mint_quote( + &self, + mint_url: MintUrl, + amount: Amount, + description: Option, + ) -> Result { + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?; + let quote = self + .inner + .mint_quote(&cdk_mint_url, amount.into(), description) + .await?; + Ok(quote.into()) + } + + /// Mint tokens at a specific mint + pub async fn mint( + &self, + mint_url: MintUrl, + quote_id: String, + spending_conditions: Option, + ) -> Result { + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?; + let conditions = spending_conditions.map(|sc| sc.try_into()).transpose()?; + + let proofs = self + .inner + .mint(&cdk_mint_url, "e_id, conditions) + .await?; + Ok(proofs.into_iter().map(|p| Arc::new(p.into())).collect()) + } + + /// Get a melt quote from a specific mint + pub async fn melt_quote( + &self, + mint_url: MintUrl, + request: String, + options: Option, + ) -> Result { + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?; + let cdk_options = options.map(Into::into); + let quote = self + .inner + .melt_quote(&cdk_mint_url, request, cdk_options) + .await?; + Ok(quote.into()) + } + + /// Melt tokens (pay a bolt11 invoice) + pub async fn melt( + &self, + bolt11: String, + options: Option, + max_fee: Option, + ) -> Result { + let cdk_options = options.map(Into::into); + let cdk_max_fee = max_fee.map(Into::into); + let melted = self.inner.melt(&bolt11, cdk_options, cdk_max_fee).await?; + Ok(melted.into()) + } + + /// Transfer funds between mints + pub async fn transfer( + &self, + source_mint: MintUrl, + target_mint: MintUrl, + transfer_mode: TransferMode, + ) -> Result { + let source_cdk: cdk::mint_url::MintUrl = source_mint.try_into()?; + let target_cdk: cdk::mint_url::MintUrl = target_mint.try_into()?; + let result = self + .inner + .transfer(&source_cdk, &target_cdk, transfer_mode.into()) + .await?; + Ok(result.into()) + } + + /// Swap proofs with automatic wallet selection + pub async fn swap( + &self, + amount: Option, + spending_conditions: Option, + ) -> Result, FfiError> { + let conditions = spending_conditions.map(|sc| sc.try_into()).transpose()?; + + let result = self.inner.swap(amount.map(Into::into), conditions).await?; + + Ok(result.map(|proofs| proofs.into_iter().map(|p| Arc::new(p.into())).collect())) + } + + /// List transactions from all mints + pub async fn list_transactions( + &self, + direction: Option, + ) -> Result, FfiError> { + let cdk_direction = direction.map(Into::into); + let transactions = self.inner.list_transactions(cdk_direction).await?; + Ok(transactions.into_iter().map(Into::into).collect()) + } + + /// Check all mint quotes and mint if paid + pub async fn check_all_mint_quotes( + &self, + mint_url: Option, + ) -> Result { + let cdk_mint_url = mint_url.map(|url| url.try_into()).transpose()?; + let amount = self.inner.check_all_mint_quotes(cdk_mint_url).await?; + Ok(amount.into()) + } + + /// Consolidate proofs across all mints + pub async fn consolidate(&self) -> Result { + let amount = self.inner.consolidate().await?; + Ok(amount.into()) + } + + /// Get list of mint URLs + pub async fn get_mint_urls(&self) -> Vec { + let wallets = self.inner.get_wallets().await; + wallets.iter().map(|w| w.mint_url.to_string()).collect() + } + + /// Verify token DLEQ proofs + pub async fn verify_token_dleq(&self, token: Arc) -> Result<(), FfiError> { + let cdk_token = token.inner.clone(); + self.inner.verify_token_dleq(&cdk_token).await?; + Ok(()) + } +} + +/// Transfer mode for mint-to-mint transfers +#[derive(Debug, Clone, uniffi::Enum)] +pub enum TransferMode { + /// Transfer exact amount to target (target receives specified amount) + ExactReceive { amount: Amount }, + /// Transfer all available balance (source will be emptied) + FullBalance, +} + +impl From for CdkTransferMode { + fn from(mode: TransferMode) -> Self { + match mode { + TransferMode::ExactReceive { amount } => CdkTransferMode::ExactReceive(amount.into()), + TransferMode::FullBalance => CdkTransferMode::FullBalance, + } + } +} + +/// Result of a transfer operation with detailed breakdown +#[derive(Debug, Clone, uniffi::Record)] +pub struct TransferResult { + /// Amount deducted from source mint + pub amount_sent: Amount, + /// Amount received at target mint + pub amount_received: Amount, + /// Total fees paid for the transfer + pub fees_paid: Amount, + /// Remaining balance in source mint after transfer + pub source_balance_after: Amount, + /// New balance in target mint after transfer + pub target_balance_after: Amount, +} + +impl From for TransferResult { + fn from(result: CdkTransferResult) -> Self { + Self { + amount_sent: result.amount_sent.into(), + amount_received: result.amount_received.into(), + fees_paid: result.fees_paid.into(), + source_balance_after: result.source_balance_after.into(), + target_balance_after: result.target_balance_after.into(), + } + } +} + +/// Options for receiving tokens in multi-mint context +#[derive(Debug, Clone, Default, uniffi::Record)] +pub struct MultiMintReceiveOptions { + /// Whether to allow receiving from untrusted (not yet added) mints + pub allow_untrusted: bool, + /// Mint URL to transfer tokens to from untrusted mints (None means keep in original mint) + pub transfer_to_mint: Option, + /// Base receive options to apply to the wallet receive + pub receive_options: ReceiveOptions, +} + +impl From for CdkMultiMintReceiveOptions { + fn from(options: MultiMintReceiveOptions) -> Self { + let mut opts = CdkMultiMintReceiveOptions::new(); + opts.allow_untrusted = options.allow_untrusted; + opts.transfer_to_mint = options.transfer_to_mint.and_then(|url| url.try_into().ok()); + opts.receive_options = options.receive_options.into(); + opts + } +} + +/// Options for sending tokens in multi-mint context +#[derive(Debug, Clone, Default, uniffi::Record)] +pub struct MultiMintSendOptions { + /// Whether to allow transferring funds from other mints if needed + pub allow_transfer: bool, + /// Maximum amount to transfer from other mints (optional limit) + pub max_transfer_amount: Option, + /// Specific mint URLs allowed for transfers (empty means all mints allowed) + pub allowed_mints: Vec, + /// Specific mint URLs to exclude from transfers + pub excluded_mints: Vec, + /// Base send options to apply to the wallet send + pub send_options: SendOptions, +} + +impl From for CdkMultiMintSendOptions { + fn from(options: MultiMintSendOptions) -> Self { + let mut opts = CdkMultiMintSendOptions::new(); + opts.allow_transfer = options.allow_transfer; + opts.max_transfer_amount = options.max_transfer_amount.map(Into::into); + opts.allowed_mints = options + .allowed_mints + .into_iter() + .filter_map(|url| url.try_into().ok()) + .collect(); + opts.excluded_mints = options + .excluded_mints + .into_iter() + .filter_map(|url| url.try_into().ok()) + .collect(); + opts.send_options = options.send_options.into(); + opts + } +} + +/// Type alias for balances by mint URL +pub type BalanceMap = HashMap; + +/// Type alias for proofs by mint URL +pub type ProofsByMint = HashMap>>; diff --git a/crates/cdk/src/wallet/builder.rs b/crates/cdk/src/wallet/builder.rs index 2b2583d0..500b77a4 100644 --- a/crates/cdk/src/wallet/builder.rs +++ b/crates/cdk/src/wallet/builder.rs @@ -111,6 +111,12 @@ impl WalletBuilder { self } + /// Set a custom client connector from Arc + pub fn shared_client(mut self, client: Arc) -> Self { + self.client = Some(client); + self + } + /// Set auth CAT (Clear Auth Token) #[cfg(feature = "auth")] pub fn set_auth_cat(mut self, cat: String) -> Self { diff --git a/crates/cdk/src/wallet/mod.rs b/crates/cdk/src/wallet/mod.rs index 8a80f92b..43c738d0 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/crates/cdk/src/wallet/mod.rs @@ -61,7 +61,7 @@ pub use mint_connector::transport::Transport as HttpTransport; #[cfg(feature = "auth")] pub use mint_connector::AuthHttpClient; pub use mint_connector::{HttpClient, MintConnector}; -pub use multi_mint_wallet::MultiMintWallet; +pub use multi_mint_wallet::{MultiMintReceiveOptions, MultiMintSendOptions, MultiMintWallet}; pub use receive::ReceiveOptions; pub use send::{PreparedSend, SendMemo, SendOptions}; pub use types::{MeltQuote, MintQuote, SendKind}; diff --git a/crates/cdk/src/wallet/multi_mint_wallet.rs b/crates/cdk/src/wallet/multi_mint_wallet.rs index 3850620b..c71a7269 100644 --- a/crates/cdk/src/wallet/multi_mint_wallet.rs +++ b/crates/cdk/src/wallet/multi_mint_wallet.rs @@ -3,95 +3,251 @@ //! Wrapper around core [`Wallet`] that enables the use of multiple mint unit //! pairs -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; use std::str::FromStr; use std::sync::Arc; use anyhow::Result; use cdk_common::database; use cdk_common::database::WalletDatabase; -use cdk_common::wallet::{Transaction, TransactionDirection, WalletKey}; +use cdk_common::wallet::{Transaction, TransactionDirection}; use tokio::sync::RwLock; use tracing::instrument; use zeroize::Zeroize; +use super::builder::WalletBuilder; use super::receive::ReceiveOptions; use super::send::{PreparedSend, SendOptions}; use super::Error; use crate::amount::SplitTarget; use crate::mint_url::MintUrl; +use crate::nuts::nut00::ProofsMethods; +use crate::nuts::nut23::QuoteState; use crate::nuts::{CurrencyUnit, MeltOptions, Proof, Proofs, SpendingConditions, Token}; use crate::types::Melted; use crate::wallet::types::MintQuote; -use crate::{ensure_cdk, Amount, Wallet}; +use crate::{Amount, Wallet}; + +// Transfer timeout constants +/// Total timeout for waiting for Lightning payment confirmation during transfers +/// This needs to be long enough to handle slow networks and Lightning routing +const TRANSFER_PAYMENT_TIMEOUT_SECS: u64 = 120; // 2 minutes + +/// Transfer mode for mint-to-mint transfers +#[derive(Debug, Clone)] +pub enum TransferMode { + /// Transfer exact amount to target (target receives specified amount) + ExactReceive(Amount), + /// Transfer all available balance (source will be emptied) + FullBalance, +} + +/// Result of a transfer operation with detailed breakdown +#[derive(Debug, Clone)] +pub struct TransferResult { + /// Amount deducted from source mint + pub amount_sent: Amount, + /// Amount received at target mint + pub amount_received: Amount, + /// Total fees paid for the transfer + pub fees_paid: Amount, + /// Remaining balance in source mint after transfer + pub source_balance_after: Amount, + /// New balance in target mint after transfer + pub target_balance_after: Amount, +} /// Multi Mint Wallet -#[derive(Debug, Clone)] +/// +/// A wallet that manages multiple mints but supports only one currency unit. +/// This simplifies the interface by removing the need to specify both mint and unit. +/// +/// # Examples +/// +/// ## Creating and using a multi-mint wallet +/// ```ignore +/// # use cdk::wallet::MultiMintWallet; +/// # use cdk::mint_url::MintUrl; +/// # use cdk::Amount; +/// # use cdk::nuts::CurrencyUnit; +/// # use std::sync::Arc; +/// # async fn example() -> Result<(), Box> { +/// // Create a multi-mint wallet with a database +/// // For real usage, you would use cdk_sqlite::wallet::memory::empty().await? or similar +/// let seed = [0u8; 64]; // Use a secure random seed in production +/// let database = cdk_sqlite::wallet::memory::empty().await?; +/// +/// let wallet = MultiMintWallet::new( +/// Arc::new(database), +/// seed, +/// CurrencyUnit::Sat, +/// ).await?; +/// +/// // Add mints to the wallet +/// let mint_url1: MintUrl = "https://mint1.example.com".parse()?; +/// let mint_url2: MintUrl = "https://mint2.example.com".parse()?; +/// wallet.add_mint(mint_url1.clone(), None).await?; +/// wallet.add_mint(mint_url2, None).await?; +/// +/// // Check total balance across all mints +/// let balance = wallet.total_balance().await?; +/// println!("Total balance: {} sats", balance); +/// +/// // Send tokens from a specific mint +/// let prepared = wallet.prepare_send( +/// mint_url1, +/// Amount::from(100), +/// Default::default() +/// ).await?; +/// let token = prepared.confirm(None).await?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone)] pub struct MultiMintWallet { /// Storage backend - pub localstore: Arc + Send + Sync>, + localstore: Arc + Send + Sync>, seed: [u8; 64], - /// Wallets - pub wallets: Arc>>, + /// The currency unit this wallet supports + unit: CurrencyUnit, + /// Wallets indexed by mint URL + wallets: Arc>>, + /// Proxy configuration for HTTP clients (optional) + proxy_config: Option, } impl MultiMintWallet { - /// Create a new [MultiMintWallet] with initial wallets - pub fn new( + /// Create a new [MultiMintWallet] for a specific currency unit + pub async fn new( localstore: Arc + Send + Sync>, seed: [u8; 64], - wallets: Vec, - ) -> Self { - Self { + unit: CurrencyUnit, + ) -> Result { + let wallet = Self { localstore, seed, - wallets: Arc::new(RwLock::new( - wallets - .into_iter() - .map(|w| (WalletKey::new(w.mint_url.clone(), w.unit.clone()), w)) - .collect(), - )), - } - } - - /// Adds a [Wallet] to this [MultiMintWallet] - #[instrument(skip(self, wallet))] - pub async fn add_wallet(&self, wallet: Wallet) { - let wallet_key = WalletKey::new(wallet.mint_url.clone(), wallet.unit.clone()); - - let mut wallets = self.wallets.write().await; - - wallets.insert(wallet_key, wallet); - } - - /// Creates a new [Wallet] and adds it to this [MultiMintWallet] - pub async fn create_and_add_wallet( - &self, - mint_url: &str, - unit: CurrencyUnit, - target_proof_count: Option, - ) -> Result { - let wallet = Wallet::new( - mint_url, unit, - self.localstore.clone(), - self.seed, - target_proof_count, - )?; + wallets: Arc::new(RwLock::new(BTreeMap::new())), + proxy_config: None, + }; - wallet.fetch_mint_info().await?; - - self.add_wallet(wallet.clone()).await; + // Automatically load wallets from database for this currency unit + wallet.load_wallets().await?; Ok(wallet) } - /// Remove Wallet from MultiMintWallet - #[instrument(skip(self))] - pub async fn remove_wallet(&self, wallet_key: &WalletKey) { - let mut wallets = self.wallets.write().await; + /// Create a new [MultiMintWallet] with proxy configuration + /// + /// All wallets in this MultiMintWallet will use the specified proxy. + /// This allows you to route all mint connections through a proxy server. + pub async fn new_with_proxy( + localstore: Arc + Send + Sync>, + seed: [u8; 64], + unit: CurrencyUnit, + proxy_url: url::Url, + ) -> Result { + let wallet = Self { + localstore, + seed, + unit, + wallets: Arc::new(RwLock::new(BTreeMap::new())), + proxy_config: Some(proxy_url), + }; - wallets.remove(wallet_key); + // Automatically load wallets from database for this currency unit + wallet.load_wallets().await?; + + Ok(wallet) + } + + /// Adds a mint to this [MultiMintWallet] + #[instrument(skip(self))] + pub async fn add_mint( + &self, + mint_url: MintUrl, + target_proof_count: Option, + ) -> Result<(), Error> { + let wallet = if let Some(proxy_url) = &self.proxy_config { + // Create wallet with proxy-configured client + let client = crate::wallet::HttpClient::with_proxy( + mint_url.clone(), + proxy_url.clone(), + None, + true, + ) + .unwrap_or_else(|_| { + #[cfg(feature = "auth")] + { + crate::wallet::HttpClient::new(mint_url.clone(), None) + } + #[cfg(not(feature = "auth"))] + { + crate::wallet::HttpClient::new(mint_url.clone()) + } + }); + WalletBuilder::new() + .mint_url(mint_url.clone()) + .unit(self.unit.clone()) + .localstore(self.localstore.clone()) + .seed(self.seed) + .target_proof_count(target_proof_count.unwrap_or(3)) + .client(client) + .build()? + } else { + // Create wallet with default client + Wallet::new( + &mint_url.to_string(), + self.unit.clone(), + self.localstore.clone(), + self.seed, + target_proof_count, + )? + }; + + wallet.fetch_mint_info().await?; + wallet.refresh_keysets().await?; + + let mut wallets = self.wallets.write().await; + wallets.insert(mint_url, wallet); + + Ok(()) + } + + /// Remove mint from MultiMintWallet + #[instrument(skip(self))] + pub async fn remove_mint(&self, mint_url: &MintUrl) { + let mut wallets = self.wallets.write().await; + wallets.remove(mint_url); + } + + /// Load all wallets from database that have proofs for this currency unit + #[instrument(skip(self))] + async fn load_wallets(&self) -> Result<(), Error> { + let mints = self.localstore.get_mints().await.map_err(Error::Database)?; + + // Get all proofs for this currency unit to determine which mints are relevant + let all_proofs = self + .localstore + .get_proofs(None, Some(self.unit.clone()), None, None) + .await + .map_err(Error::Database)?; + + for (mint_url, _mint_info) in mints { + // Check if this mint has any proofs for the specified currency unit + // or if we have no proofs at all (initial setup) + let mint_has_proofs_for_unit = + all_proofs.is_empty() || all_proofs.iter().any(|proof| proof.mint_url == mint_url); + + if mint_has_proofs_for_unit { + // Add mint to the MultiMintWallet if not already present + if !self.has_mint(&mint_url).await { + self.add_mint(mint_url, None).await?; + } + } + } + + Ok(()) } /// Get Wallets from MultiMintWallet @@ -102,29 +258,29 @@ impl MultiMintWallet { /// Get Wallet from MultiMintWallet #[instrument(skip(self))] - pub async fn get_wallet(&self, wallet_key: &WalletKey) -> Option { - self.wallets.read().await.get(wallet_key).cloned() + pub async fn get_wallet(&self, mint_url: &MintUrl) -> Option { + self.wallets.read().await.get(mint_url).cloned() } - /// Check if mint unit pair is in wallet + /// Check if mint is in wallet #[instrument(skip(self))] - pub async fn has(&self, wallet_key: &WalletKey) -> bool { - self.wallets.read().await.contains_key(wallet_key) + pub async fn has_mint(&self, mint_url: &MintUrl) -> bool { + self.wallets.read().await.contains_key(mint_url) } - /// Get wallet balances + /// Get the currency unit for this wallet + pub fn unit(&self) -> &CurrencyUnit { + &self.unit + } + + /// Get wallet balances for all mints #[instrument(skip(self))] - pub async fn get_balances( - &self, - unit: &CurrencyUnit, - ) -> Result, Error> { + pub async fn get_balances(&self) -> Result, Error> { let mut balances = BTreeMap::new(); - for (WalletKey { mint_url, unit: u }, wallet) in self.wallets.read().await.iter() { - if unit == u { - let wallet_balance = wallet.total_balance().await?; - balances.insert(mint_url.clone(), wallet_balance); - } + for (mint_url, wallet) in self.wallets.read().await.iter() { + let wallet_balance = wallet.total_balance().await?; + balances.insert(mint_url.clone(), wallet_balance); } Ok(balances) @@ -132,14 +288,12 @@ impl MultiMintWallet { /// List proofs. #[instrument(skip(self))] - pub async fn list_proofs( - &self, - ) -> Result, CurrencyUnit)>, Error> { + pub async fn list_proofs(&self) -> Result>, Error> { let mut mint_proofs = BTreeMap::new(); - for (WalletKey { mint_url, unit: u }, wallet) in self.wallets.read().await.iter() { + for (mint_url, wallet) in self.wallets.read().await.iter() { let wallet_proofs = wallet.get_unspent_proofs().await?; - mint_proofs.insert(mint_url.clone(), (wallet_proofs, u.clone())); + mint_proofs.insert(mint_url.clone(), wallet_proofs); } Ok(mint_proofs) } @@ -162,34 +316,430 @@ impl MultiMintWallet { Ok(transactions) } - /// Prepare to send + /// Get total balance across all wallets (since all wallets use the same currency unit) + #[instrument(skip(self))] + pub async fn total_balance(&self) -> Result { + let mut total = Amount::ZERO; + for (_, wallet) in self.wallets.read().await.iter() { + total += wallet.total_balance().await?; + } + Ok(total) + } + + /// Prepare to send tokens from a specific mint with optional transfer from other mints + /// + /// This method ensures that sends always happen from only one mint. If the specified + /// mint doesn't have sufficient balance and `allow_transfer` is enabled in options, + /// it will first transfer funds from other mints to the target mint. #[instrument(skip(self))] pub async fn prepare_send( &self, - wallet_key: &WalletKey, + mint_url: MintUrl, amount: Amount, - opts: SendOptions, + opts: MultiMintSendOptions, ) -> Result { + // Ensure the mint exists let wallets = self.wallets.read().await; - let wallet = wallets - .get(wallet_key) - .ok_or(Error::UnknownWallet(wallet_key.clone()))?; + let target_wallet = wallets.get(&mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; - wallet.prepare_send(amount, opts).await + // Check current balance of target mint + let target_balance = target_wallet.total_balance().await?; + + // If target mint has sufficient balance, prepare send directly + if target_balance >= amount { + return target_wallet.prepare_send(amount, opts.send_options).await; + } + + // If transfer is not allowed, return insufficient funds error + if !opts.allow_transfer { + return Err(Error::InsufficientFunds); + } + + // Calculate how much we need to transfer + let transfer_needed = amount - target_balance; + + // Check if transfer amount exceeds max_transfer_amount + if let Some(max_transfer) = opts.max_transfer_amount { + if transfer_needed > max_transfer { + return Err(Error::InsufficientFunds); + } + } + + // Find source wallets with available funds for transfer + let mut available_for_transfer = Amount::ZERO; + let mut source_mints = Vec::new(); + + for (source_mint_url, wallet) in wallets.iter() { + if source_mint_url == &mint_url { + continue; // Skip the target mint + } + + // Check if this mint is excluded from transfers + if opts.excluded_mints.contains(source_mint_url) { + continue; + } + + // Check if we have a restricted allowed list and this mint isn't in it + if !opts.allowed_mints.is_empty() && !opts.allowed_mints.contains(source_mint_url) { + continue; + } + + let balance = wallet.total_balance().await?; + if balance > Amount::ZERO { + source_mints.push((source_mint_url.clone(), balance)); + available_for_transfer += balance; + } + } + + // Check if we have enough funds across all mints + if available_for_transfer < transfer_needed { + return Err(Error::InsufficientFunds); + } + + // Drop the read lock before performing transfers + drop(wallets); + + // Perform transfers from source wallets to target wallet + self.transfer_parallel(&mint_url, transfer_needed, source_mints) + .await?; + + // Now prepare the send from the target mint + let wallets = self.wallets.read().await; + let target_wallet = wallets.get(&mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; + + target_wallet.prepare_send(amount, opts.send_options).await + } + + /// Transfer funds from a single source wallet to target mint using Lightning Network (melt/mint) + /// + /// This function properly accounts for fees by handling different transfer modes: + /// - ExactReceive: Target receives exactly the specified amount, source pays amount + fees + /// - FullBalance: All source balance is transferred, target receives balance - fees + pub async fn transfer( + &self, + source_mint_url: &MintUrl, + target_mint_url: &MintUrl, + mode: TransferMode, + ) -> Result { + // Get wallets for the specified mints and clone them to release the lock + let (source_wallet, target_wallet) = { + let wallets = self.wallets.read().await; + let source = wallets + .get(source_mint_url) + .ok_or(Error::UnknownMint { + mint_url: source_mint_url.to_string(), + })? + .clone(); + let target = wallets + .get(target_mint_url) + .ok_or(Error::UnknownMint { + mint_url: target_mint_url.to_string(), + })? + .clone(); + (source, target) + }; + + // Get initial balance + let source_balance_initial = source_wallet.total_balance().await?; + + // Handle different transfer modes + let (final_mint_quote, final_melt_quote) = match mode { + TransferMode::ExactReceive(amount) => { + self.handle_exact_receive_transfer( + &source_wallet, + &target_wallet, + amount, + source_balance_initial, + ) + .await? + } + TransferMode::FullBalance => { + self.handle_full_balance_transfer( + &source_wallet, + &target_wallet, + source_balance_initial, + ) + .await? + } + }; + + // Execute the transfer + let (melted, actual_receive_amount) = self + .execute_transfer( + &source_wallet, + &target_wallet, + &final_mint_quote, + &final_melt_quote, + ) + .await?; + + // Get final balances + let source_balance_final = source_wallet.total_balance().await?; + let target_balance_final = target_wallet.total_balance().await?; + + let amount_sent = source_balance_initial - source_balance_final; + let fees_paid = melted.fee_paid; + + tracing::info!( + "Transferred {} from {} to {} via Lightning (sent: {} sats, received: {} sats, fee: {} sats)", + amount_sent, + source_wallet.mint_url, + target_wallet.mint_url, + amount_sent, + actual_receive_amount, + fees_paid + ); + + Ok(TransferResult { + amount_sent, + amount_received: actual_receive_amount, + fees_paid, + source_balance_after: source_balance_final, + target_balance_after: target_balance_final, + }) + } + + /// Handle exact receive transfer mode - target gets exactly the specified amount + async fn handle_exact_receive_transfer( + &self, + source_wallet: &Wallet, + target_wallet: &Wallet, + amount: Amount, + source_balance: Amount, + ) -> Result<(MintQuote, crate::wallet::types::MeltQuote), Error> { + // Step 1: Create mint quote at target mint for the exact amount we want to receive + let mint_quote = target_wallet.mint_quote(amount, None).await?; + + // Step 2: Create melt quote at source mint for the invoice + let melt_quote = source_wallet + .melt_quote(mint_quote.request.clone(), None) + .await?; + + // Step 3: Check if source has enough balance for the total amount needed (amount + melt fees) + let total_needed = melt_quote.amount + melt_quote.fee_reserve; + if source_balance < total_needed { + return Err(Error::InsufficientFunds); + } + + Ok((mint_quote, melt_quote)) + } + + /// Handle full balance transfer mode - all source balance is transferred + async fn handle_full_balance_transfer( + &self, + source_wallet: &Wallet, + target_wallet: &Wallet, + source_balance: Amount, + ) -> Result<(MintQuote, crate::wallet::types::MeltQuote), Error> { + if source_balance == Amount::ZERO { + return Err(Error::InsufficientFunds); + } + + // Step 1: Create melt quote for full balance to discover fees + // We need to create a dummy mint quote first to get an invoice + let dummy_mint_quote = target_wallet.mint_quote(source_balance, None).await?; + let probe_melt_quote = source_wallet + .melt_quote(dummy_mint_quote.request.clone(), None) + .await?; + + // Step 2: Calculate actual receive amount (balance - fees) + let receive_amount = source_balance + .checked_sub(probe_melt_quote.fee_reserve) + .ok_or(Error::InsufficientFunds)?; + + if receive_amount == Amount::ZERO { + return Err(Error::InsufficientFunds); + } + + // Step 3: Create final mint quote for the net amount + let final_mint_quote = target_wallet.mint_quote(receive_amount, None).await?; + + // Step 4: Create final melt quote with the new invoice + let final_melt_quote = source_wallet + .melt_quote(final_mint_quote.request.clone(), None) + .await?; + + Ok((final_mint_quote, final_melt_quote)) + } + + /// Execute the actual transfer using the prepared quotes + async fn execute_transfer( + &self, + source_wallet: &Wallet, + target_wallet: &Wallet, + final_mint_quote: &MintQuote, + final_melt_quote: &crate::wallet::types::MeltQuote, + ) -> Result<(Melted, Amount), Error> { + // Step 1: Subscribe to mint quote updates before melting + let mut subscription = target_wallet + .subscribe(super::WalletSubscription::Bolt11MintQuoteState(vec![ + final_mint_quote.id.clone(), + ])) + .await; + + // Step 2: Melt from source wallet using the final melt quote + let melted = source_wallet.melt(&final_melt_quote.id).await?; + + // Step 3: Wait for payment confirmation via subscription + tracing::debug!( + "Waiting for Lightning payment confirmation (max {} seconds) for transfer from {} to {}", + TRANSFER_PAYMENT_TIMEOUT_SECS, + source_wallet.mint_url, + target_wallet.mint_url + ); + + // Wait for payment notification with overall timeout + let timeout_duration = tokio::time::Duration::from_secs(TRANSFER_PAYMENT_TIMEOUT_SECS); + + loop { + match tokio::time::timeout(timeout_duration, subscription.recv()).await { + Ok(Some(notification)) => { + // Check if this is a mint quote response with paid state + if let crate::nuts::nut17::NotificationPayload::MintQuoteBolt11Response( + quote_response, + ) = notification + { + if quote_response.state == QuoteState::Paid { + // Quote is paid, now mint the tokens + target_wallet + .mint( + &final_mint_quote.id, + crate::amount::SplitTarget::default(), + None, + ) + .await?; + break; + } + } + } + Ok(None) => { + // Subscription closed + tracing::warn!("Subscription closed while waiting for mint quote payment"); + return Err(Error::TransferTimeout { + source_mint: source_wallet.mint_url.to_string(), + target_mint: target_wallet.mint_url.to_string(), + amount: final_mint_quote.amount.unwrap_or(Amount::ZERO), + }); + } + Err(_) => { + // Overall timeout reached + tracing::warn!( + "Transfer timed out after {} seconds waiting for Lightning payment confirmation", + TRANSFER_PAYMENT_TIMEOUT_SECS + ); + return Err(Error::TransferTimeout { + source_mint: source_wallet.mint_url.to_string(), + target_mint: target_wallet.mint_url.to_string(), + amount: final_mint_quote.amount.unwrap_or(Amount::ZERO), + }); + } + } + } + + let actual_receive_amount = final_mint_quote.amount.unwrap_or(Amount::ZERO); + Ok((melted, actual_receive_amount)) + } + + /// Transfer funds from multiple source wallets to target mint in parallel + async fn transfer_parallel( + &self, + target_mint_url: &MintUrl, + total_amount: Amount, + source_mints: Vec<(MintUrl, Amount)>, + ) -> Result<(), Error> { + let mut remaining_amount = total_amount; + let mut transfer_tasks = Vec::new(); + + // Create transfer tasks for each source wallet + for (source_mint_url, available_balance) in source_mints { + if remaining_amount == Amount::ZERO { + break; + } + + let transfer_amount = std::cmp::min(remaining_amount, available_balance); + remaining_amount -= transfer_amount; + + let self_clone = self.clone(); + let source_mint_url = source_mint_url.clone(); + let target_mint_url = target_mint_url.clone(); + + // Spawn parallel transfer task + #[cfg(not(target_arch = "wasm32"))] + let task = tokio::spawn(async move { + self_clone + .transfer( + &source_mint_url, + &target_mint_url, + TransferMode::ExactReceive(transfer_amount), + ) + .await + .map(|result| result.amount_received) + }); + + #[cfg(target_arch = "wasm32")] + let task = tokio::task::spawn_local(async move { + self_clone + .transfer( + &source_mint_url, + &target_mint_url, + TransferMode::ExactReceive(transfer_amount), + ) + .await + .map(|result| result.amount_received) + }); + + transfer_tasks.push(task); + } + + // Wait for all transfers to complete + let mut total_transferred = Amount::ZERO; + for task in transfer_tasks { + match task.await { + Ok(Ok(amount)) => { + total_transferred += amount; + } + Ok(Err(e)) => { + tracing::error!("Transfer failed: {}", e); + return Err(e); + } + Err(e) => { + tracing::error!("Transfer task panicked: {}", e); + return Err(Error::Internal); + } + } + } + + // Check if we transferred less than expected (accounting for fees) + // We don't return an error here as fees are expected + if total_transferred < total_amount { + let fee_paid = total_amount - total_transferred; + tracing::info!( + "Transfer completed with fees: requested {}, received {}, total fees {}", + total_amount, + total_transferred, + fee_paid + ); + } + + Ok(()) } /// Mint quote for wallet #[instrument(skip(self))] pub async fn mint_quote( &self, - wallet_key: &WalletKey, + mint_url: &MintUrl, amount: Amount, description: Option, ) -> Result { let wallets = self.wallets.read().await; - let wallet = wallets - .get(wallet_key) - .ok_or(Error::UnknownWallet(wallet_key.clone()))?; + let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; wallet.mint_quote(amount, description).await } @@ -197,78 +747,114 @@ impl MultiMintWallet { /// Check all mint quotes /// If quote is paid, wallet will mint #[instrument(skip(self))] - pub async fn check_all_mint_quotes( - &self, - wallet_key: Option, - ) -> Result, Error> { - let mut amount_minted = HashMap::new(); - match wallet_key { - Some(wallet_key) => { + pub async fn check_all_mint_quotes(&self, mint_url: Option) -> Result { + let mut total_amount = Amount::ZERO; + match mint_url { + Some(mint_url) => { let wallets = self.wallets.read().await; - let wallet = wallets - .get(&wallet_key) - .ok_or(Error::UnknownWallet(wallet_key.clone()))?; + let wallet = wallets.get(&mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; - let amount = wallet.check_all_mint_quotes().await?; - amount_minted.insert(wallet.unit.clone(), amount); + total_amount = wallet.check_all_mint_quotes().await?; } None => { for (_, wallet) in self.wallets.read().await.iter() { let amount = wallet.check_all_mint_quotes().await?; - - amount_minted - .entry(wallet.unit.clone()) - .and_modify(|b| *b += amount) - .or_insert(amount); + total_amount += amount; } } } - Ok(amount_minted) + Ok(total_amount) } /// Mint a specific quote #[instrument(skip(self))] pub async fn mint( &self, - wallet_key: &WalletKey, + mint_url: &MintUrl, quote_id: &str, conditions: Option, ) -> Result { let wallets = self.wallets.read().await; - let wallet = wallets - .get(wallet_key) - .ok_or(Error::UnknownWallet(wallet_key.clone()))?; + let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; wallet .mint(quote_id, SplitTarget::default(), conditions) .await } - /// Receive token - /// Wallet must be already added to multimintwallet + /// Receive token with multi-mint options + /// + /// This method can: + /// - Receive tokens from trusted mints (already added to the wallet) + /// - Optionally receive from untrusted mints by adding them to the wallet + /// - Optionally transfer tokens from untrusted mints to a trusted mint (and remove the untrusted mint) + /// + /// # Examples + /// ```no_run + /// # use cdk::wallet::{MultiMintWallet, MultiMintReceiveOptions}; + /// # use cdk::mint_url::MintUrl; + /// # async fn example(wallet: MultiMintWallet) -> Result<(), Box> { + /// // Receive from a trusted mint + /// let token = "cashuAey..."; + /// let amount = wallet.receive(token, MultiMintReceiveOptions::default()).await?; + /// + /// // Receive from untrusted mint and add it to the wallet + /// let options = MultiMintReceiveOptions::default() + /// .allow_untrusted(true); + /// let amount = wallet.receive(token, options).await?; + /// + /// // Receive from untrusted mint, transfer to trusted mint, then remove untrusted mint + /// let trusted_mint: MintUrl = "https://trusted.mint".parse()?; + /// let options = MultiMintReceiveOptions::default() + /// .transfer_to_mint(Some(trusted_mint)); + /// let amount = wallet.receive(token, options).await?; + /// # Ok(()) + /// # } + /// ``` #[instrument(skip_all)] pub async fn receive( &self, encoded_token: &str, - opts: ReceiveOptions, + opts: MultiMintReceiveOptions, ) -> Result { let token_data = Token::from_str(encoded_token)?; let unit = token_data.unit().unwrap_or_default(); - let mint_url = token_data.mint_url()?; - - // Check that all mints in tokes have wallets - let wallet_key = WalletKey::new(mint_url.clone(), unit.clone()); - if !self.has(&wallet_key).await { - return Err(Error::UnknownWallet(wallet_key.clone())); + // Ensure the token uses the same currency unit as this wallet + if unit != self.unit { + return Err(Error::MultiMintCurrencyUnitMismatch { + expected: self.unit.clone(), + found: unit, + }); + } + + let mint_url = token_data.mint_url()?; + let is_trusted = self.has_mint(&mint_url).await; + + // If mint is not trusted and we don't allow untrusted mints, error + if !is_trusted && !opts.allow_untrusted { + return Err(Error::UnknownMint { + mint_url: mint_url.to_string(), + }); + } + + // If mint is untrusted and we need to transfer, ensure we have a target mint + let should_transfer = !is_trusted && opts.transfer_to_mint.is_some(); + + // Add the untrusted mint temporarily if needed + if !is_trusted { + self.add_mint(mint_url.clone(), None).await?; } - let wallet_key = WalletKey::new(mint_url.clone(), unit); let wallets = self.wallets.read().await; - let wallet = wallets - .get(&wallet_key) - .ok_or(Error::UnknownWallet(wallet_key.clone()))?; + let wallet = wallets.get(&mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; // We need the keysets information to properly convert from token proof to proof let keysets_info = match self @@ -284,56 +870,81 @@ impl MultiMintWallet { let mut amount_received = Amount::ZERO; - let mut mint_errors = None; - match wallet - .receive_proofs(proofs, opts, token_data.memo().clone()) + .receive_proofs(proofs, opts.receive_options, token_data.memo().clone()) .await { Ok(amount) => { amount_received += amount; } Err(err) => { - tracing::error!("Could no receive proofs for mint: {}", err); - mint_errors = Some(err); + // If we added the mint temporarily for transfer only, remove it before returning error + if !is_trusted && opts.transfer_to_mint.is_some() { + drop(wallets); + self.remove_mint(&mint_url).await; + } + return Err(err); } } - match mint_errors { - None => Ok(amount_received), - Some(err) => Err(err), + drop(wallets); + + // If we should transfer to a trusted mint, do so now + if should_transfer { + if let Some(target_mint) = opts.transfer_to_mint { + // Ensure target mint exists and is trusted + if !self.has_mint(&target_mint).await { + // Clean up untrusted mint if we're only using it for transfer + self.remove_mint(&mint_url).await; + return Err(Error::UnknownMint { + mint_url: target_mint.to_string(), + }); + } + + // Transfer the entire balance from the untrusted mint to the target mint + // Use FullBalance mode for efficient transfer of all funds + let transfer_result = self + .transfer(&mint_url, &target_mint, TransferMode::FullBalance) + .await; + + // Handle transfer result - log details but don't fail if balance was zero + match transfer_result { + Ok(result) => { + if result.amount_sent > Amount::ZERO { + tracing::info!( + "Transferred {} sats from untrusted mint {} to trusted mint {} (received: {}, fees: {})", + result.amount_sent, + mint_url, + target_mint, + result.amount_received, + result.fees_paid + ); + } + } + Err(Error::InsufficientFunds) => { + // No balance to transfer, which is fine + tracing::debug!("No balance to transfer from untrusted mint {}", mint_url); + } + Err(e) => return Err(e), + } + + // Remove the untrusted mint after transfer + self.remove_mint(&mint_url).await; + } } - } + // Note: If allow_untrusted is true but no transfer is requested, + // the untrusted mint is kept in the wallet (as intended) - /// Pay an bolt11 invoice from specific wallet - #[instrument(skip(self, bolt11))] - pub async fn pay_invoice_for_wallet( - &self, - bolt11: &str, - options: Option, - wallet_key: &WalletKey, - max_fee: Option, - ) -> Result { - let wallets = self.wallets.read().await; - let wallet = wallets - .get(wallet_key) - .ok_or(Error::UnknownWallet(wallet_key.clone()))?; - - let quote = wallet.melt_quote(bolt11.to_string(), options).await?; - if let Some(max_fee) = max_fee { - ensure_cdk!(quote.fee_reserve <= max_fee, Error::MaxFeeExceeded); - } - - wallet.melt("e.id).await + Ok(amount_received) } /// Restore #[instrument(skip(self))] - pub async fn restore(&self, wallet_key: &WalletKey) -> Result { + pub async fn restore(&self, mint_url: &MintUrl) -> Result { let wallets = self.wallets.read().await; - let wallet = wallets - .get(wallet_key) - .ok_or(Error::UnknownWallet(wallet_key.clone()))?; + let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; wallet.restore().await } @@ -342,32 +953,335 @@ impl MultiMintWallet { #[instrument(skip(self, token))] pub async fn verify_token_p2pk( &self, - wallet_key: &WalletKey, token: &Token, conditions: SpendingConditions, ) -> Result<(), Error> { + let mint_url = token.mint_url()?; let wallets = self.wallets.read().await; - let wallet = wallets - .get(wallet_key) - .ok_or(Error::UnknownWallet(wallet_key.clone()))?; + let wallet = wallets.get(&mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; wallet.verify_token_p2pk(token, conditions).await } /// Verifys all proofs in token have valid dleq proof #[instrument(skip(self, token))] - pub async fn verify_token_dleq( - &self, - wallet_key: &WalletKey, - token: &Token, - ) -> Result<(), Error> { + pub async fn verify_token_dleq(&self, token: &Token) -> Result<(), Error> { + let mint_url = token.mint_url()?; let wallets = self.wallets.read().await; - let wallet = wallets - .get(wallet_key) - .ok_or(Error::UnknownWallet(wallet_key.clone()))?; + let wallet = wallets.get(&mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; wallet.verify_token_dleq(token).await } + + /// Create a melt quote for a specific mint + #[instrument(skip(self, bolt11))] + pub async fn melt_quote( + &self, + mint_url: &MintUrl, + bolt11: String, + options: Option, + ) -> Result { + let wallets = self.wallets.read().await; + let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; + + wallet.melt_quote(bolt11, options).await + } + + /// Melt (pay invoice) from a specific mint using a quote ID + #[instrument(skip(self))] + pub async fn melt_with_mint( + &self, + mint_url: &MintUrl, + quote_id: &str, + ) -> Result { + let wallets = self.wallets.read().await; + let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; + + wallet.melt(quote_id).await + } + + /// Create MPP (Multi-Path Payment) melt quotes from multiple mints + /// + /// This function allows manual specification of which mints and amounts to use for MPP. + /// Returns a vector of (MintUrl, MeltQuote) pairs. + #[instrument(skip(self, bolt11))] + pub async fn mpp_melt_quote( + &self, + bolt11: String, + mint_amounts: Vec<(MintUrl, Amount)>, + ) -> Result, Error> { + let mut quotes = Vec::new(); + let mut tasks = Vec::new(); + + // Spawn parallel tasks to get quotes from each mint + for (mint_url, amount) in mint_amounts { + let wallets = self.wallets.read().await; + let wallet = wallets + .get(&mint_url) + .ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })? + .clone(); + drop(wallets); + + let bolt11_clone = bolt11.clone(); + let mint_url_clone = mint_url.clone(); + + // Convert amount to millisats for MeltOptions + let amount_msat = u64::from(amount) * 1000; + let options = Some(MeltOptions::new_mpp(amount_msat)); + + #[cfg(not(target_arch = "wasm32"))] + let task = tokio::spawn(async move { + let quote = wallet.melt_quote(bolt11_clone, options).await; + (mint_url_clone, quote) + }); + + #[cfg(target_arch = "wasm32")] + let task = tokio::task::spawn_local(async move { + let quote = wallet.melt_quote(bolt11_clone, options).await; + (mint_url_clone, quote) + }); + + tasks.push(task); + } + + // Collect all quote results + for task in tasks { + match task.await { + Ok((mint_url, Ok(quote))) => { + quotes.push((mint_url, quote)); + } + Ok((mint_url, Err(e))) => { + tracing::error!("Failed to get melt quote from {}: {}", mint_url, e); + return Err(e); + } + Err(e) => { + tracing::error!("Task failed: {}", e); + return Err(Error::Internal); + } + } + } + + Ok(quotes) + } + + /// Execute MPP melts using previously obtained quotes + #[instrument(skip(self))] + pub async fn mpp_melt( + &self, + quotes: Vec<(MintUrl, String)>, // (mint_url, quote_id) + ) -> Result, Error> { + let mut results = Vec::new(); + let mut tasks = Vec::new(); + + for (mint_url, quote_id) in quotes { + let wallets = self.wallets.read().await; + let wallet = wallets + .get(&mint_url) + .ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })? + .clone(); + drop(wallets); + + let mint_url_clone = mint_url.clone(); + + #[cfg(not(target_arch = "wasm32"))] + let task = tokio::spawn(async move { + let melted = wallet.melt("e_id).await; + (mint_url_clone, melted) + }); + + #[cfg(target_arch = "wasm32")] + let task = tokio::task::spawn_local(async move { + let melted = wallet.melt("e_id).await; + (mint_url_clone, melted) + }); + + tasks.push(task); + } + + // Collect all melt results + for task in tasks { + match task.await { + Ok((mint_url, Ok(melted))) => { + results.push((mint_url, melted)); + } + Ok((mint_url, Err(e))) => { + tracing::error!("Failed to melt from {}: {}", mint_url, e); + return Err(e); + } + Err(e) => { + tracing::error!("Task failed: {}", e); + return Err(Error::Internal); + } + } + } + + Ok(results) + } + + /// Melt (pay invoice) with automatic wallet selection (deprecated, use specific mint functions for better control) + /// + /// Automatically selects the best wallet to pay from based on: + /// - Available balance + /// - Fees + /// + /// # Examples + /// ```no_run + /// # use cdk::wallet::MultiMintWallet; + /// # use cdk::Amount; + /// # use std::sync::Arc; + /// # async fn example(wallet: Arc) -> Result<(), Box> { + /// // Pay a lightning invoice from any mint with sufficient balance + /// let invoice = "lnbc100n1p..."; + /// + /// let result = wallet.melt(invoice, None, None).await?; + /// println!("Paid {} sats, fee was {} sats", result.amount, result.fee_paid); + /// # Ok(()) + /// # } + /// ``` + #[instrument(skip(self, bolt11))] + pub async fn melt( + &self, + bolt11: &str, + options: Option, + max_fee: Option, + ) -> Result { + // Parse the invoice to get the amount + let invoice = bolt11 + .parse::() + .map_err(Error::Invoice)?; + + let amount = invoice + .amount_milli_satoshis() + .map(|msats| Amount::from(msats / 1000)) + .ok_or(Error::InvoiceAmountUndefined)?; + + let wallets = self.wallets.read().await; + let mut eligible_wallets = Vec::new(); + + for (mint_url, wallet) in wallets.iter() { + let balance = wallet.total_balance().await?; + if balance >= amount { + eligible_wallets.push((mint_url.clone(), wallet.clone())); + } + } + + if eligible_wallets.is_empty() { + return Err(Error::InsufficientFunds); + } + + // Try to get quotes from eligible wallets and select the best one + let mut best_quote = None; + let mut best_wallet = None; + + for (_, wallet) in eligible_wallets.iter() { + match wallet.melt_quote(bolt11.to_string(), options).await { + Ok(quote) => { + if let Some(max_fee) = max_fee { + if quote.fee_reserve > max_fee { + continue; + } + } + + if best_quote.is_none() { + best_quote = Some(quote); + best_wallet = Some(wallet.clone()); + } else if let Some(ref existing_quote) = best_quote { + if quote.fee_reserve < existing_quote.fee_reserve { + best_quote = Some(quote); + best_wallet = Some(wallet.clone()); + } + } + } + Err(_) => continue, + } + } + + if let (Some(quote), Some(wallet)) = (best_quote, best_wallet) { + return wallet.melt("e.id).await; + } + + Err(Error::InsufficientFunds) + } + + /// Swap proofs with automatic wallet selection + #[instrument(skip(self))] + pub async fn swap( + &self, + amount: Option, + conditions: Option, + ) -> Result, Error> { + // Find a wallet that has proofs + let wallets = self.wallets.read().await; + + for (_, wallet) in wallets.iter() { + let balance = wallet.total_balance().await?; + if balance > Amount::ZERO { + // Try to swap with this wallet + let proofs = wallet.get_unspent_proofs().await?; + if !proofs.is_empty() { + return wallet + .swap(amount, SplitTarget::default(), proofs, conditions, false) + .await; + } + } + } + + Err(Error::InsufficientFunds) + } + + /// Consolidate proofs from multiple wallets into fewer, larger proofs + /// This can help reduce the number of proofs and optimize wallet performance + #[instrument(skip(self))] + pub async fn consolidate(&self) -> Result { + let mut total_consolidated = Amount::ZERO; + let wallets = self.wallets.read().await; + + for (mint_url, wallet) in wallets.iter() { + // Get all unspent proofs for this wallet + let proofs = wallet.get_unspent_proofs().await?; + if proofs.len() > 1 { + // Consolidate by swapping all proofs for a single set + let proofs_amount = proofs.total_amount()?; + + // Swap for optimized proof set + match wallet + .swap( + Some(proofs_amount), + SplitTarget::default(), + proofs, + None, + false, + ) + .await + { + Ok(_) => { + total_consolidated += proofs_amount; + } + Err(e) => { + tracing::warn!( + "Failed to consolidate proofs for mint {:?}: {}", + mint_url, + e + ); + } + } + } + } + + Ok(total_consolidated) + } } impl Drop for MultiMintWallet { @@ -375,3 +1289,186 @@ impl Drop for MultiMintWallet { self.seed.zeroize(); } } + +/// Multi-Mint Receive Options +/// +/// Controls how tokens are received, especially from untrusted mints +#[derive(Debug, Clone, Default)] +pub struct MultiMintReceiveOptions { + /// Whether to allow receiving from untrusted (not yet added) mints + pub allow_untrusted: bool, + /// Mint to transfer tokens to from untrusted mints (None means keep in original mint) + pub transfer_to_mint: Option, + /// Base receive options to apply to the wallet receive + pub receive_options: ReceiveOptions, +} + +impl MultiMintReceiveOptions { + /// Create new default options + pub fn new() -> Self { + Default::default() + } + + /// Allow receiving from untrusted mints + pub fn allow_untrusted(mut self, allow: bool) -> Self { + self.allow_untrusted = allow; + self + } + + /// Set mint to transfer tokens to from untrusted mints + pub fn transfer_to_mint(mut self, mint_url: Option) -> Self { + self.transfer_to_mint = mint_url; + self + } + + /// Set the base receive options for the wallet operation + pub fn receive_options(mut self, options: ReceiveOptions) -> Self { + self.receive_options = options; + self + } +} + +/// Multi-Mint Send Options +/// +/// Controls transfer behavior when the target mint doesn't have sufficient balance +#[derive(Debug, Clone, Default)] +pub struct MultiMintSendOptions { + /// Whether to allow transferring funds from other mints to the sending mint + /// if the sending mint doesn't have sufficient balance + pub allow_transfer: bool, + /// Maximum amount to transfer from other mints (optional limit) + pub max_transfer_amount: Option, + /// Specific mints allowed for transfers (empty means all mints allowed) + pub allowed_mints: Vec, + /// Specific mints to exclude from transfers + pub excluded_mints: Vec, + /// Base send options to apply to the wallet send + pub send_options: SendOptions, +} + +impl MultiMintSendOptions { + /// Create new default options + pub fn new() -> Self { + Default::default() + } + + /// Enable transferring funds from other mints if needed + pub fn allow_transfer(mut self, allow: bool) -> Self { + self.allow_transfer = allow; + self + } + + /// Set maximum amount to transfer from other mints + pub fn max_transfer_amount(mut self, amount: Amount) -> Self { + self.max_transfer_amount = Some(amount); + self + } + + /// Add a mint to the allowed list for transfers + pub fn allow_mint(mut self, mint_url: MintUrl) -> Self { + self.allowed_mints.push(mint_url); + self + } + + /// Set all allowed mints for transfers + pub fn allowed_mints(mut self, mints: Vec) -> Self { + self.allowed_mints = mints; + self + } + + /// Add a mint to exclude from transfers + pub fn exclude_mint(mut self, mint_url: MintUrl) -> Self { + self.excluded_mints.push(mint_url); + self + } + + /// Set all excluded mints for transfers + pub fn excluded_mints(mut self, mints: Vec) -> Self { + self.excluded_mints = mints; + self + } + + /// Set the base send options for the wallet operation + pub fn send_options(mut self, options: SendOptions) -> Self { + self.send_options = options; + self + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use cdk_common::database::WalletDatabase; + + use super::*; + + async fn create_test_multi_wallet() -> MultiMintWallet { + let localstore: Arc + Send + Sync> = Arc::new( + cdk_sqlite::wallet::memory::empty() + .await + .expect("Failed to create in-memory database"), + ); + let seed = [0u8; 64]; + MultiMintWallet::new(localstore, seed, CurrencyUnit::Sat) + .await + .expect("Failed to create MultiMintWallet") + } + + #[tokio::test] + async fn test_total_balance_empty() { + let multi_wallet = create_test_multi_wallet().await; + let balance = multi_wallet.total_balance().await.unwrap(); + assert_eq!(balance, Amount::ZERO); + } + + #[tokio::test] + async fn test_prepare_send_insufficient_funds() { + use std::str::FromStr; + + let multi_wallet = create_test_multi_wallet().await; + let mint_url = MintUrl::from_str("https://mint1.example.com").unwrap(); + let options = MultiMintSendOptions::new(); + + let result = multi_wallet + .prepare_send(mint_url, Amount::from(1000), options) + .await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_consolidate_empty() { + let multi_wallet = create_test_multi_wallet().await; + let result = multi_wallet.consolidate().await.unwrap(); + assert_eq!(result, Amount::ZERO); + } + + #[tokio::test] + async fn test_multi_mint_wallet_creation() { + let multi_wallet = create_test_multi_wallet().await; + assert!(multi_wallet.wallets.try_read().is_ok()); + } + + #[tokio::test] + async fn test_multi_mint_send_options() { + use std::str::FromStr; + + let mint1 = MintUrl::from_str("https://mint1.example.com").unwrap(); + let mint2 = MintUrl::from_str("https://mint2.example.com").unwrap(); + let mint3 = MintUrl::from_str("https://mint3.example.com").unwrap(); + + let options = MultiMintSendOptions::new() + .allow_transfer(true) + .max_transfer_amount(Amount::from(500)) + .allow_mint(mint1.clone()) + .allow_mint(mint2.clone()) + .exclude_mint(mint3.clone()) + .send_options(SendOptions::default()); + + assert!(options.allow_transfer); + assert_eq!(options.max_transfer_amount, Some(Amount::from(500))); + assert_eq!(options.allowed_mints, vec![mint1, mint2]); + assert_eq!(options.excluded_mints, vec![mint3]); + } +} diff --git a/crates/cdk/src/wallet/payment_request.rs b/crates/cdk/src/wallet/payment_request.rs index f042872d..f4c6ce3a 100644 --- a/crates/cdk/src/wallet/payment_request.rs +++ b/crates/cdk/src/wallet/payment_request.rs @@ -22,7 +22,7 @@ use crate::nuts::nut11::{Conditions, SigFlag, SpendingConditions}; use crate::nuts::nut18::Nut10SecretRequest; use crate::nuts::{CurrencyUnit, Transport}; #[cfg(feature = "nostr")] -use crate::wallet::ReceiveOptions; +use crate::wallet::MultiMintReceiveOptions; use crate::wallet::{MultiMintWallet, SendOptions}; use crate::Wallet; @@ -356,7 +356,7 @@ impl MultiMintWallet { ) -> Result<(PaymentRequest, Option), Error> { // Collect available mints for the selected unit let mints = self - .get_balances(&CurrencyUnit::from_str(¶ms.unit)?) + .get_balances() .await? .keys() .cloned() @@ -458,7 +458,7 @@ impl MultiMintWallet { ) -> Result { // Collect available mints for the selected unit let mints = self - .get_balances(&CurrencyUnit::from_str(¶ms.unit)?) + .get_balances() .await? .keys() .cloned() @@ -536,7 +536,7 @@ impl MultiMintWallet { ); let amount = self - .receive(&token.to_string(), ReceiveOptions::default()) + .receive(&token.to_string(), MultiMintReceiveOptions::default()) .await?; // Stop after first successful receipt @@ -602,7 +602,7 @@ impl MultiMintWallet { ); let amount = self - .receive(&token.to_string(), ReceiveOptions::default()) + .receive(&token.to_string(), MultiMintReceiveOptions::default()) .await?; return Ok(amount);