| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271 |
- //! MultiMint Wallet
- //!
- //! Wrapper around core [`Wallet`] that enables the use of multiple mint unit
- //! pairs
- use std::collections::BTreeMap;
- use std::ops::Deref;
- use std::str::FromStr;
- use std::sync::Arc;
- use anyhow::Result;
- use cdk_common::database::WalletDatabase;
- use cdk_common::task::spawn;
- use cdk_common::wallet::{MeltQuote, Transaction, TransactionDirection, TransactionId};
- use cdk_common::{database, KeySetInfo};
- 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, State, Token};
- use crate::types::Melted;
- #[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
- use crate::wallet::mint_connector::transport::tor_transport::TorAsync;
- use crate::wallet::types::MintQuote;
- 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,
- }
- /// Data extracted from a token including mint URL, proofs, and memo
- #[derive(Debug, Clone)]
- pub struct TokenData {
- /// The mint URL from the token
- pub mint_url: MintUrl,
- /// The proofs contained in the token
- pub proofs: Proofs,
- /// The memo from the token, if present
- pub memo: Option<String>,
- /// Value of token
- pub value: Amount,
- /// Unit of token
- pub unit: CurrencyUnit,
- /// Fee to redeem
- ///
- /// If the token is for a proof that we do not know, we cannot get the fee.
- /// To avoid just erroring and still allow decoding, this is an option.
- /// None does not mean there is no fee, it means we do not know the fee.
- pub redeem_fee: Option<Amount>,
- }
- /// Configuration for individual wallets within MultiMintWallet
- #[derive(Clone, Default, Debug)]
- pub struct WalletConfig {
- /// Custom mint connector implementation
- pub mint_connector: Option<Arc<dyn super::MintConnector + Send + Sync>>,
- /// Custom auth connector implementation
- #[cfg(feature = "auth")]
- pub auth_connector: Option<Arc<dyn super::auth::AuthMintConnector + Send + Sync>>,
- /// Target number of proofs to maintain at each denomination
- pub target_proof_count: Option<usize>,
- }
- impl WalletConfig {
- /// Create a new empty WalletConfig
- pub fn new() -> Self {
- Self::default()
- }
- /// Set custom mint connector
- pub fn with_mint_connector(
- mut self,
- connector: Arc<dyn super::MintConnector + Send + Sync>,
- ) -> Self {
- self.mint_connector = Some(connector);
- self
- }
- /// Set custom auth connector
- #[cfg(feature = "auth")]
- pub fn with_auth_connector(
- mut self,
- connector: Arc<dyn super::auth::AuthMintConnector + Send + Sync>,
- ) -> Self {
- self.auth_connector = Some(connector);
- self
- }
- /// Set target proof count
- pub fn with_target_proof_count(mut self, count: usize) -> Self {
- self.target_proof_count = Some(count);
- self
- }
- }
- /// Multi Mint Wallet
- ///
- /// 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<dyn std::error::Error>> {
- /// // 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()).await?;
- /// wallet.add_mint(mint_url2).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
- localstore: Arc<dyn WalletDatabase<database::Error> + Send + Sync>,
- seed: [u8; 64],
- /// The currency unit this wallet supports
- unit: CurrencyUnit,
- /// Wallets indexed by mint URL
- wallets: Arc<RwLock<BTreeMap<MintUrl, Wallet>>>,
- /// Proxy configuration for HTTP clients (optional)
- proxy_config: Option<url::Url>,
- /// Shared Tor transport to be cloned into each TorHttpClient (if enabled)
- #[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
- shared_tor_transport: Option<TorAsync>,
- }
- impl MultiMintWallet {
- /// Create a new [MultiMintWallet] for a specific currency unit
- pub async fn new(
- localstore: Arc<dyn WalletDatabase<database::Error> + Send + Sync>,
- seed: [u8; 64],
- unit: CurrencyUnit,
- ) -> Result<Self, Error> {
- let wallet = Self {
- localstore,
- seed,
- unit,
- wallets: Arc::new(RwLock::new(BTreeMap::new())),
- proxy_config: None,
- #[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
- shared_tor_transport: None,
- };
- // Automatically load wallets from database for this currency unit
- wallet.load_wallets().await?;
- Ok(wallet)
- }
- /// 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<dyn WalletDatabase<database::Error> + Send + Sync>,
- seed: [u8; 64],
- unit: CurrencyUnit,
- proxy_url: url::Url,
- ) -> Result<Self, Error> {
- let wallet = Self {
- localstore,
- seed,
- unit,
- wallets: Arc::new(RwLock::new(BTreeMap::new())),
- proxy_config: Some(proxy_url),
- #[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
- shared_tor_transport: None,
- };
- // Automatically load wallets from database for this currency unit
- wallet.load_wallets().await?;
- Ok(wallet)
- }
- /// Create a new [MultiMintWallet] with Tor transport for all wallets
- ///
- /// When the `tor` feature is enabled (and not on wasm32), this constructor
- /// creates a single Tor transport (TorAsync) that is cloned into each
- /// TorHttpClient used by per-mint Wallets. This ensures only one Tor instance
- /// is bootstrapped and shared across wallets.
- #[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
- pub async fn new_with_tor(
- localstore: Arc<dyn WalletDatabase<database::Error> + Send + Sync>,
- seed: [u8; 64],
- unit: CurrencyUnit,
- ) -> Result<Self, Error> {
- let wallet = Self {
- localstore,
- seed,
- unit,
- wallets: Arc::new(RwLock::new(BTreeMap::new())),
- proxy_config: None,
- shared_tor_transport: Some(TorAsync::new()),
- };
- // Automatically load wallets from database for this currency unit
- wallet.load_wallets().await?;
- Ok(wallet)
- }
- /// Adds a mint to this [MultiMintWallet]
- ///
- /// Creates a wallet for the specified mint using default or global settings.
- /// For custom configuration, use `add_mint_with_config()`.
- #[instrument(skip(self))]
- pub async fn add_mint(&self, mint_url: MintUrl) -> Result<(), Error> {
- // Create wallet with default settings
- let wallet = self
- .create_wallet_with_config(mint_url.clone(), None)
- .await?;
- // Insert into wallets map
- let mut wallets = self.wallets.write().await;
- wallets.insert(mint_url, wallet);
- Ok(())
- }
- /// Adds a mint to this [MultiMintWallet] with custom configuration
- ///
- /// The provided configuration is used to create the wallet with custom connectors
- /// and settings. Configuration is stored within the Wallet instance itself.
- #[instrument(skip(self))]
- pub async fn add_mint_with_config(
- &self,
- mint_url: MintUrl,
- config: WalletConfig,
- ) -> Result<(), Error> {
- // Create wallet with the provided config
- let wallet = self
- .create_wallet_with_config(mint_url.clone(), Some(&config))
- .await?;
- // Insert into wallets map
- let mut wallets = self.wallets.write().await;
- wallets.insert(mint_url, wallet);
- Ok(())
- }
- /// Set or update configuration for a mint
- ///
- /// If the wallet already exists, it will be updated with the new config.
- /// If the wallet doesn't exist, it will be created with the specified config.
- #[instrument(skip(self))]
- pub async fn set_mint_config(
- &self,
- mint_url: MintUrl,
- config: WalletConfig,
- ) -> Result<(), Error> {
- // Check if wallet already exists
- if self.has_mint(&mint_url).await {
- // Update existing wallet in place
- let mut wallets = self.wallets.write().await;
- if let Some(wallet) = wallets.get_mut(&mint_url) {
- // Update target_proof_count if provided
- if let Some(count) = config.target_proof_count {
- wallet.set_target_proof_count(count);
- }
- // Update connector if provided
- if let Some(connector) = config.mint_connector {
- wallet.set_client(connector);
- }
- // TODO: Handle auth_connector if provided
- #[cfg(feature = "auth")]
- if let Some(_auth_connector) = config.auth_connector {
- // For now, we can't easily inject auth_connector into the wallet
- // This would require additional work on the Wallet API
- // We'll note this as a future enhancement
- }
- }
- Ok(())
- } else {
- // Wallet doesn't exist, create it with the provided config
- self.add_mint_with_config(mint_url, config).await
- }
- }
- /// Set the auth client (AuthWallet) for a specific mint
- ///
- /// This allows updating the auth wallet for an existing mint wallet without recreating it.
- #[cfg(feature = "auth")]
- #[instrument(skip_all)]
- pub async fn set_auth_client(
- &self,
- mint_url: &MintUrl,
- auth_wallet: Option<super::auth::AuthWallet>,
- ) -> Result<(), Error> {
- let wallets = self.wallets.read().await;
- let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
- mint_url: mint_url.to_string(),
- })?;
- wallet.set_auth_client(auth_wallet).await;
- 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);
- }
- /// Internal: Create wallet with optional custom configuration
- ///
- /// Priority order for configuration:
- /// 1. Custom connector from config (if provided)
- /// 2. Global settings (proxy/Tor)
- /// 3. Default HttpClient
- async fn create_wallet_with_config(
- &self,
- mint_url: MintUrl,
- config: Option<&WalletConfig>,
- ) -> Result<Wallet, Error> {
- // Check if custom connector is provided in config
- if let Some(cfg) = config {
- if let Some(custom_connector) = &cfg.mint_connector {
- // Use custom connector with WalletBuilder
- let builder = WalletBuilder::new()
- .mint_url(mint_url.clone())
- .unit(self.unit.clone())
- .localstore(self.localstore.clone())
- .seed(self.seed)
- .target_proof_count(cfg.target_proof_count.unwrap_or(3))
- .shared_client(custom_connector.clone());
- // TODO: Handle auth_connector if provided
- #[cfg(feature = "auth")]
- if let Some(_auth_connector) = &cfg.auth_connector {
- // For now, we can't easily inject auth_connector into the wallet
- // This would require additional work on the Wallet/WalletBuilder API
- // We'll note this as a future enhancement
- }
- return builder.build();
- }
- }
- // Fall back to existing logic: proxy/Tor/default
- let target_proof_count = config.and_then(|c| c.target_proof_count).unwrap_or(3);
- 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)
- .client(client)
- .build()?
- } else {
- #[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
- if let Some(tor) = &self.shared_tor_transport {
- // Create wallet with Tor transport client, cloning the shared transport
- let client = {
- let transport = tor.clone();
- #[cfg(feature = "auth")]
- {
- crate::wallet::TorHttpClient::with_transport(
- mint_url.clone(),
- transport,
- None,
- )
- }
- #[cfg(not(feature = "auth"))]
- {
- crate::wallet::TorHttpClient::with_transport(mint_url.clone(), transport)
- }
- };
- WalletBuilder::new()
- .mint_url(mint_url.clone())
- .unit(self.unit.clone())
- .localstore(self.localstore.clone())
- .seed(self.seed)
- .target_proof_count(target_proof_count)
- .client(client)
- .build()?
- } else {
- // Create wallet with default client
- Wallet::new(
- &mint_url.to_string(),
- self.unit.clone(),
- self.localstore.clone(),
- self.seed,
- Some(target_proof_count),
- )?
- }
- #[cfg(not(all(feature = "tor", not(target_arch = "wasm32"))))]
- {
- // Create wallet with default client
- Wallet::new(
- &mint_url.to_string(),
- self.unit.clone(),
- self.localstore.clone(),
- self.seed,
- Some(target_proof_count),
- )?
- }
- };
- Ok(wallet)
- }
- /// 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.clone()).await?
- }
- }
- }
- Ok(())
- }
- /// Get Wallets from MultiMintWallet
- #[instrument(skip(self))]
- pub async fn get_wallets(&self) -> Vec<Wallet> {
- self.wallets.read().await.values().cloned().collect()
- }
- /// Get Wallet from MultiMintWallet
- #[instrument(skip(self))]
- pub async fn get_wallet(&self, mint_url: &MintUrl) -> Option<Wallet> {
- self.wallets.read().await.get(mint_url).cloned()
- }
- /// Check if mint is in wallet
- #[instrument(skip(self))]
- pub async fn has_mint(&self, mint_url: &MintUrl) -> bool {
- self.wallets.read().await.contains_key(mint_url)
- }
- /// Get the currency unit for this wallet
- pub fn unit(&self) -> &CurrencyUnit {
- &self.unit
- }
- /// Get keysets for a mint url
- pub async fn get_mint_keysets(&self, mint_url: &MintUrl) -> Result<Vec<KeySetInfo>, Error> {
- 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.get_mint_keysets().await
- }
- /// Get token data (mint URL and proofs) from a token
- ///
- /// This method extracts the mint URL and proofs from a token. It will automatically
- /// fetch the keysets from the mint if needed to properly decode the proofs.
- ///
- /// The mint must already be added to the wallet. If the mint is not in the wallet,
- /// use `add_mint` first or set `allow_untrusted` in receive options.
- ///
- /// # Arguments
- ///
- /// * `token` - The token to extract data from
- ///
- /// # Returns
- ///
- /// A `TokenData` struct containing the mint URL and proofs
- ///
- /// # Example
- ///
- /// ```no_run
- /// # use cdk::wallet::MultiMintWallet;
- /// # use cdk::nuts::Token;
- /// # use std::str::FromStr;
- /// # async fn example(wallet: &MultiMintWallet) -> Result<(), Box<dyn std::error::Error>> {
- /// let token = Token::from_str("cashuA...")?;
- /// let token_data = wallet.get_token_data(&token).await?;
- /// println!("Mint: {}", token_data.mint_url);
- /// println!("Proofs: {} total", token_data.proofs.len());
- /// # Ok(())
- /// # }
- /// ```
- #[instrument(skip(self, token))]
- pub async fn get_token_data(&self, token: &Token) -> Result<TokenData, Error> {
- let mint_url = token.mint_url()?;
- // Get the keysets for this mint
- let keysets = self.get_mint_keysets(&mint_url).await?;
- // Extract proofs using the keysets
- let proofs = token.proofs(&keysets)?;
- // Get the memo
- let memo = token.memo().clone();
- let redeem_fee = self.get_proofs_fee(&mint_url, &proofs).await.ok();
- Ok(TokenData {
- value: proofs.total_amount()?,
- mint_url,
- proofs,
- memo,
- unit: token.unit().unwrap_or_default(),
- redeem_fee,
- })
- }
- /// Get wallet balances for all mints
- #[instrument(skip(self))]
- pub async fn get_balances(&self) -> Result<BTreeMap<MintUrl, Amount>, Error> {
- let mut balances = BTreeMap::new();
- 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)
- }
- /// List proofs.
- #[instrument(skip(self))]
- pub async fn list_proofs(&self) -> Result<BTreeMap<MintUrl, Vec<Proof>>, Error> {
- let mut mint_proofs = BTreeMap::new();
- 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);
- }
- Ok(mint_proofs)
- }
- /// NUT-07 Check the state of proofs with a specific mint
- #[instrument(skip(self, proofs))]
- pub async fn check_proofs_state(
- &self,
- mint_url: &MintUrl,
- proofs: Proofs,
- ) -> Result<Vec<State>, Error> {
- let wallet = self.get_wallet(mint_url).await.ok_or(Error::UnknownMint {
- mint_url: mint_url.to_string(),
- })?;
- let states = wallet.check_proofs_spent(proofs).await?;
- Ok(states.into_iter().map(|s| s.state).collect())
- }
- /// Fee required to redeem proof set
- #[instrument(skip(self, proofs))]
- pub async fn get_proofs_fee(
- &self,
- mint_url: &MintUrl,
- proofs: &Proofs,
- ) -> Result<Amount, Error> {
- let wallets = self.wallets.read().await;
- let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
- mint_url: mint_url.to_string(),
- })?;
- Ok(wallet.get_proofs_fee(proofs).await?.total)
- }
- /// List transactions
- #[instrument(skip(self))]
- pub async fn list_transactions(
- &self,
- direction: Option<TransactionDirection>,
- ) -> Result<Vec<Transaction>, Error> {
- let mut transactions = Vec::new();
- for (_, wallet) in self.wallets.read().await.iter() {
- let wallet_transactions = wallet.list_transactions(direction).await?;
- transactions.extend(wallet_transactions);
- }
- transactions.sort();
- Ok(transactions)
- }
- /// Get proofs for a transaction by transaction ID
- ///
- /// This retrieves all proofs associated with a transaction. If `mint_url` is provided,
- /// it will only check that specific mint's wallet. Otherwise, it searches across all
- /// wallets to find which mint the transaction belongs to.
- ///
- /// # Arguments
- ///
- /// * `id` - The transaction ID
- /// * `mint_url` - Optional mint URL to check directly, avoiding iteration over all wallets
- #[instrument(skip(self))]
- pub async fn get_proofs_for_transaction(
- &self,
- id: TransactionId,
- mint_url: Option<MintUrl>,
- ) -> Result<Proofs, Error> {
- let wallets = self.wallets.read().await;
- // If mint_url is provided, try that wallet directly
- if let Some(mint_url) = mint_url {
- if let Some(wallet) = wallets.get(&mint_url) {
- // Verify the transaction exists in this wallet
- if wallet.get_transaction(id).await?.is_some() {
- return wallet.get_proofs_for_transaction(id).await;
- }
- }
- // Transaction not found in specified mint
- return Err(Error::TransactionNotFound);
- }
- // No mint_url provided, search across all wallets
- for (mint_url, wallet) in wallets.iter() {
- if let Some(transaction) = wallet.get_transaction(id).await? {
- // Verify the transaction belongs to this wallet's mint
- if &transaction.mint_url == mint_url {
- return wallet.get_proofs_for_transaction(id).await;
- }
- }
- }
- // Transaction not found in any wallet
- Err(Error::TransactionNotFound)
- }
- /// Get total balance across all wallets (since all wallets use the same currency unit)
- #[instrument(skip(self))]
- pub async fn total_balance(&self) -> Result<Amount, Error> {
- 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,
- mint_url: MintUrl,
- amount: Amount,
- opts: MultiMintSendOptions,
- ) -> Result<PreparedSend, Error> {
- // Ensure the mint exists
- let wallets = self.wallets.read().await;
- let target_wallet = wallets.get(&mint_url).ok_or(Error::UnknownMint {
- mint_url: mint_url.to_string(),
- })?;
- // 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<TransferResult, Error> {
- // 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.deref()
- {
- 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
- let task = spawn(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,
- mint_url: &MintUrl,
- amount: Amount,
- description: Option<String>,
- ) -> Result<MintQuote, Error> {
- let wallets = self.wallets.read().await;
- let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
- mint_url: mint_url.to_string(),
- })?;
- wallet.mint_quote(amount, description).await
- }
- /// Check a specific mint quote status
- #[instrument(skip(self))]
- pub async fn check_mint_quote(
- &self,
- mint_url: &MintUrl,
- quote_id: &str,
- ) -> Result<MintQuote, Error> {
- let wallets = self.wallets.read().await;
- let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
- mint_url: mint_url.to_string(),
- })?;
- // Check the quote state from the mint
- wallet.mint_quote_state(quote_id).await?;
- // Get the updated quote from local storage
- let quote = wallet
- .localstore
- .get_mint_quote(quote_id)
- .await
- .map_err(Error::Database)?
- .ok_or(Error::UnknownQuote)?;
- Ok(quote)
- }
- /// Check all mint quotes
- /// If quote is paid, wallet will mint
- #[instrument(skip(self))]
- pub async fn check_all_mint_quotes(&self, mint_url: Option<MintUrl>) -> Result<Amount, Error> {
- let mut total_amount = Amount::ZERO;
- match mint_url {
- Some(mint_url) => {
- let wallets = self.wallets.read().await;
- let wallet = wallets.get(&mint_url).ok_or(Error::UnknownMint {
- mint_url: mint_url.to_string(),
- })?;
- 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?;
- total_amount += amount;
- }
- }
- }
- Ok(total_amount)
- }
- /// Mint a specific quote
- #[instrument(skip(self))]
- pub async fn mint(
- &self,
- mint_url: &MintUrl,
- quote_id: &str,
- conditions: Option<SpendingConditions>,
- ) -> Result<Proofs, Error> {
- let wallets = self.wallets.read().await;
- let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
- mint_url: mint_url.to_string(),
- })?;
- wallet
- .mint(quote_id, SplitTarget::default(), conditions)
- .await
- }
- /// Wait for a mint quote to be paid and automatically mint the proofs
- #[cfg(not(target_arch = "wasm32"))]
- #[instrument(skip(self))]
- pub async fn wait_for_mint_quote(
- &self,
- mint_url: &MintUrl,
- quote_id: &str,
- split_target: SplitTarget,
- conditions: Option<SpendingConditions>,
- timeout_secs: u64,
- ) -> Result<Proofs, Error> {
- let wallets = self.wallets.read().await;
- let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
- mint_url: mint_url.to_string(),
- })?;
- // Get the mint quote from local storage
- let quote = wallet
- .localstore
- .get_mint_quote(quote_id)
- .await
- .map_err(Error::Database)?
- .ok_or(Error::UnknownQuote)?;
- // Wait for the quote to be paid and mint the proofs
- let timeout_duration = tokio::time::Duration::from_secs(timeout_secs);
- wallet
- .wait_and_mint_quote(quote, split_target, conditions, timeout_duration)
- .await
- }
- /// 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<dyn std::error::Error>> {
- /// // 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: MultiMintReceiveOptions,
- ) -> Result<Amount, Error> {
- let token_data = Token::from_str(encoded_token)?;
- let unit = token_data.unit().unwrap_or_default();
- // 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()).await?;
- }
- let wallets = self.wallets.read().await;
- 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
- .localstore
- .get_mint_keysets(token_data.mint_url()?)
- .await?
- {
- Some(keysets_info) => keysets_info,
- // Hit the keysets endpoint if we don't have the keysets for this Mint
- None => wallet.load_mint_keysets().await?,
- };
- let proofs = token_data.proofs(&keysets_info)?;
- let mut amount_received = Amount::ZERO;
- match wallet
- .receive_proofs(proofs, opts.receive_options, token_data.memo().clone())
- .await
- {
- Ok(amount) => {
- amount_received += amount;
- }
- Err(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);
- }
- }
- 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)
- Ok(amount_received)
- }
- /// Restore
- #[instrument(skip(self))]
- pub async fn restore(&self, mint_url: &MintUrl) -> Result<Amount, Error> {
- let wallets = self.wallets.read().await;
- let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
- mint_url: mint_url.to_string(),
- })?;
- wallet.restore().await
- }
- /// Verify token matches p2pk conditions
- #[instrument(skip(self, token))]
- pub async fn verify_token_p2pk(
- &self,
- token: &Token,
- conditions: SpendingConditions,
- ) -> Result<(), Error> {
- let mint_url = token.mint_url()?;
- let wallets = self.wallets.read().await;
- let wallet = wallets.get(&mint_url).ok_or(Error::UnknownMint {
- mint_url: mint_url.to_string(),
- })?;
- wallet.verify_token_p2pk(token, conditions).await
- }
- /// Verifies all proofs in token have valid dleq proof
- #[instrument(skip(self, token))]
- 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(&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<MeltOptions>,
- ) -> Result<crate::wallet::types::MeltQuote, Error> {
- 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<Melted, Error> {
- 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
- }
- /// Melt specific proofs from a specific mint using a quote ID
- ///
- /// This method allows melting proofs that may not be in the wallet's database,
- /// similar to how `receive_proofs` handles external proofs. The proofs will be
- /// added to the database and used for the melt operation.
- ///
- /// # Arguments
- ///
- /// * `mint_url` - The mint to use for the melt operation
- /// * `quote_id` - The melt quote ID (obtained from `melt_quote`)
- /// * `proofs` - The proofs to melt (can be external proofs not in the wallet's database)
- ///
- /// # Returns
- ///
- /// A `Melted` result containing the payment details and any change proofs
- #[instrument(skip(self, proofs))]
- pub async fn melt_proofs(
- &self,
- mint_url: &MintUrl,
- quote_id: &str,
- proofs: Proofs,
- ) -> Result<Melted, Error> {
- let wallets = self.wallets.read().await;
- let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
- mint_url: mint_url.to_string(),
- })?;
- wallet.melt_proofs(quote_id, proofs).await
- }
- /// Check a specific melt quote status
- #[instrument(skip(self))]
- pub async fn check_melt_quote(
- &self,
- mint_url: &MintUrl,
- quote_id: &str,
- ) -> Result<MeltQuote, Error> {
- let wallets = self.wallets.read().await;
- let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
- mint_url: mint_url.to_string(),
- })?;
- // Check the quote state from the mint
- wallet.melt_quote_status(quote_id).await?;
- // Get the updated quote from local storage
- let quote = wallet
- .localstore
- .get_melt_quote(quote_id)
- .await
- .map_err(Error::Database)?
- .ok_or(Error::UnknownQuote)?;
- Ok(quote)
- }
- /// 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<Vec<(MintUrl, crate::wallet::types::MeltQuote)>, 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));
- let task = spawn(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<Vec<(MintUrl, Melted)>, 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();
- let task = spawn(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<MultiMintWallet>) -> Result<(), Box<dyn std::error::Error>> {
- /// // 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<MeltOptions>,
- max_fee: Option<Amount>,
- ) -> Result<Melted, Error> {
- // Parse the invoice to get the amount
- let invoice = bolt11
- .parse::<crate::Bolt11Invoice>()
- .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<Amount>,
- conditions: Option<SpendingConditions>,
- ) -> Result<Option<Proofs>, 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<Amount, Error> {
- 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)
- }
- /// Mint blind auth tokens for a specific mint
- ///
- /// This is a convenience method that calls the underlying wallet's mint_blind_auth.
- #[cfg(feature = "auth")]
- #[instrument(skip_all)]
- pub async fn mint_blind_auth(
- &self,
- mint_url: &MintUrl,
- amount: Amount,
- ) -> Result<Proofs, Error> {
- let wallets = self.wallets.read().await;
- let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
- mint_url: mint_url.to_string(),
- })?;
- wallet.mint_blind_auth(amount).await
- }
- /// Get unspent auth proofs for a specific mint
- ///
- /// This is a convenience method that calls the underlying wallet's get_unspent_auth_proofs.
- #[cfg(feature = "auth")]
- #[instrument(skip_all)]
- pub async fn get_unspent_auth_proofs(
- &self,
- mint_url: &MintUrl,
- ) -> Result<Vec<cdk_common::AuthProof>, Error> {
- let wallets = self.wallets.read().await;
- let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
- mint_url: mint_url.to_string(),
- })?;
- wallet.get_unspent_auth_proofs().await
- }
- /// Set Clear Auth Token (CAT) for authentication at a specific mint
- ///
- /// This is a convenience method that calls the underlying wallet's set_cat.
- #[cfg(feature = "auth")]
- #[instrument(skip_all)]
- pub async fn set_cat(&self, mint_url: &MintUrl, cat: String) -> Result<(), Error> {
- let wallets = self.wallets.read().await;
- let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
- mint_url: mint_url.to_string(),
- })?;
- wallet.set_cat(cat).await
- }
- /// Set refresh token for authentication at a specific mint
- ///
- /// This is a convenience method that calls the underlying wallet's set_refresh_token.
- #[cfg(feature = "auth")]
- #[instrument(skip_all)]
- pub async fn set_refresh_token(
- &self,
- mint_url: &MintUrl,
- refresh_token: String,
- ) -> Result<(), Error> {
- let wallets = self.wallets.read().await;
- let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
- mint_url: mint_url.to_string(),
- })?;
- wallet.set_refresh_token(refresh_token).await
- }
- /// Refresh CAT token for a specific mint
- ///
- /// This is a convenience method that calls the underlying wallet's refresh_access_token.
- #[cfg(feature = "auth")]
- #[instrument(skip(self))]
- pub async fn refresh_access_token(&self, mint_url: &MintUrl) -> Result<(), Error> {
- let wallets = self.wallets.read().await;
- let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
- mint_url: mint_url.to_string(),
- })?;
- wallet.refresh_access_token().await
- }
- /// Query mint for current mint information
- ///
- /// This is a convenience method that calls the underlying wallet's fetch_mint_info.
- #[instrument(skip(self))]
- pub async fn fetch_mint_info(
- &self,
- mint_url: &MintUrl,
- ) -> Result<Option<crate::nuts::MintInfo>, Error> {
- let wallets = self.wallets.read().await;
- let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
- mint_url: mint_url.to_string(),
- })?;
- wallet.fetch_mint_info().await
- }
- /// Get mint info for all wallets
- ///
- /// This method loads the mint info for each wallet in the MultiMintWallet
- /// and returns a map of mint URLs to their corresponding mint info.
- ///
- /// Uses cached mint info when available, only fetching from the mint if the cache
- /// has expired.
- #[instrument(skip(self))]
- pub async fn get_all_mint_info(
- &self,
- ) -> Result<BTreeMap<MintUrl, crate::nuts::MintInfo>, Error> {
- let mut mint_infos = BTreeMap::new();
- for (mint_url, wallet) in self.wallets.read().await.iter() {
- let mint_info = wallet.load_mint_info().await?;
- mint_infos.insert(mint_url.clone(), mint_info);
- }
- Ok(mint_infos)
- }
- /// Melt Quote for BIP353 human-readable address
- ///
- /// This method resolves a BIP353 address (e.g., "alice@example.com") to a Lightning offer
- /// and then creates a melt quote for that offer at the specified mint.
- ///
- /// # Arguments
- ///
- /// * `mint_url` - The mint to use for creating the melt quote
- /// * `bip353_address` - Human-readable address in the format "user@domain.com"
- /// * `amount_msat` - Amount to pay in millisatoshis
- ///
- /// # Returns
- ///
- /// A `MeltQuote` that can be used to execute the payment
- #[cfg(all(feature = "bip353", not(target_arch = "wasm32")))]
- #[instrument(skip(self, amount_msat))]
- pub async fn melt_bip353_quote(
- &self,
- mint_url: &MintUrl,
- bip353_address: &str,
- amount_msat: impl Into<Amount>,
- ) -> Result<crate::wallet::types::MeltQuote, Error> {
- let wallets = self.wallets.read().await;
- let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
- mint_url: mint_url.to_string(),
- })?;
- wallet.melt_bip353_quote(bip353_address, amount_msat).await
- }
- /// Melt Quote for Lightning address
- ///
- /// This method resolves a Lightning address (e.g., "alice@example.com") to a Lightning invoice
- /// and then creates a melt quote for that invoice at the specified mint.
- ///
- /// # Arguments
- ///
- /// * `mint_url` - The mint to use for creating the melt quote
- /// * `lightning_address` - Lightning address in the format "user@domain.com"
- /// * `amount_msat` - Amount to pay in millisatoshis
- ///
- /// # Returns
- ///
- /// A `MeltQuote` that can be used to execute the payment
- #[instrument(skip(self, amount_msat))]
- pub async fn melt_lightning_address_quote(
- &self,
- mint_url: &MintUrl,
- lightning_address: &str,
- amount_msat: impl Into<Amount>,
- ) -> Result<crate::wallet::types::MeltQuote, Error> {
- let wallets = self.wallets.read().await;
- let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
- mint_url: mint_url.to_string(),
- })?;
- wallet
- .melt_lightning_address_quote(lightning_address, amount_msat)
- .await
- }
- /// Get a melt quote for a human-readable address
- ///
- /// This method accepts a human-readable address that could be either a BIP353 address
- /// or a Lightning address. It intelligently determines which to try based on mint support:
- ///
- /// 1. If the mint supports Bolt12, it tries BIP353 first
- /// 2. Falls back to Lightning address only if BIP353 DNS resolution fails
- /// 3. If BIP353 resolves but fails at the mint, it does NOT fall back to Lightning address
- /// 4. If the mint doesn't support Bolt12, it tries Lightning address directly
- ///
- /// # Arguments
- ///
- /// * `mint_url` - The mint to use for creating the melt quote
- /// * `address` - Human-readable address (BIP353 or Lightning address)
- /// * `amount_msat` - Amount to pay in millisatoshis
- #[cfg(all(feature = "bip353", feature = "wallet", not(target_arch = "wasm32")))]
- #[instrument(skip(self, amount_msat))]
- pub async fn melt_human_readable_quote(
- &self,
- mint_url: &MintUrl,
- address: &str,
- amount_msat: impl Into<Amount>,
- ) -> Result<crate::wallet::types::MeltQuote, Error> {
- let wallets = self.wallets.read().await;
- let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
- mint_url: mint_url.to_string(),
- })?;
- wallet.melt_human_readable_quote(address, amount_msat).await
- }
- }
- impl Drop for MultiMintWallet {
- fn drop(&mut self) {
- 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<MintUrl>,
- /// 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<MintUrl>) -> 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<Amount>,
- /// Specific mints allowed for transfers (empty means all mints allowed)
- pub allowed_mints: Vec<MintUrl>,
- /// Specific mints to exclude from transfers
- pub excluded_mints: Vec<MintUrl>,
- /// 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<MintUrl>) -> 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<MintUrl>) -> 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<dyn WalletDatabase<database::Error> + 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]);
- }
- #[tokio::test]
- async fn test_get_mint_keysets_unknown_mint() {
- use std::str::FromStr;
- let multi_wallet = create_test_multi_wallet().await;
- let mint_url = MintUrl::from_str("https://unknown-mint.example.com").unwrap();
- // Should error when trying to get keysets for a mint that hasn't been added
- let result = multi_wallet.get_mint_keysets(&mint_url).await;
- assert!(result.is_err());
- match result {
- Err(Error::UnknownMint { mint_url: url }) => {
- assert!(url.contains("unknown-mint.example.com"));
- }
- _ => panic!("Expected UnknownMint error"),
- }
- }
- #[tokio::test]
- async fn test_multi_mint_receive_options() {
- use std::str::FromStr;
- let mint_url = MintUrl::from_str("https://trusted.mint.example.com").unwrap();
- // Test default options
- let default_opts = MultiMintReceiveOptions::default();
- assert!(!default_opts.allow_untrusted);
- assert!(default_opts.transfer_to_mint.is_none());
- // Test builder pattern
- let opts = MultiMintReceiveOptions::new()
- .allow_untrusted(true)
- .transfer_to_mint(Some(mint_url.clone()));
- assert!(opts.allow_untrusted);
- assert_eq!(opts.transfer_to_mint, Some(mint_url));
- }
- #[tokio::test]
- async fn test_get_token_data_unknown_mint() {
- use std::str::FromStr;
- let multi_wallet = create_test_multi_wallet().await;
- // Create a token from a mint that isn't in the wallet
- // This is a valid token structure pointing to an unknown mint
- let token_str = "cashuBpGF0gaJhaUgArSaMTR9YJmFwgaNhYQFhc3hAOWE2ZGJiODQ3YmQyMzJiYTc2ZGIwZGYxOTcyMTZiMjlkM2I4Y2MxNDU1M2NkMjc4MjdmYzFjYzk0MmZlZGI0ZWFjWCEDhhhUP_trhpXfStS6vN6So0qWvc2X3O4NfM-Y1HISZ5JhZGlUaGFuayB5b3VhbXVodHRwOi8vbG9jYWxob3N0OjMzMzhhdWNzYXQ=";
- let token = Token::from_str(token_str).unwrap();
- // Should error because the mint (localhost:3338) hasn't been added
- let result = multi_wallet.get_token_data(&token).await;
- assert!(result.is_err());
- match result {
- Err(Error::UnknownMint { mint_url }) => {
- assert!(mint_url.contains("localhost:3338"));
- }
- _ => panic!("Expected UnknownMint error"),
- }
- }
- #[test]
- fn test_token_data_struct() {
- use std::str::FromStr;
- let mint_url = MintUrl::from_str("https://example.mint.com").unwrap();
- let proofs = vec![];
- let memo = Some("Test memo".to_string());
- let token_data = TokenData {
- value: Amount::ZERO,
- mint_url: mint_url.clone(),
- proofs: proofs.clone(),
- memo: memo.clone(),
- unit: CurrencyUnit::Sat,
- redeem_fee: None,
- };
- assert_eq!(token_data.mint_url, mint_url);
- assert_eq!(token_data.proofs.len(), 0);
- assert_eq!(token_data.memo, memo);
- // Test with no memo
- let token_data_no_memo = TokenData {
- value: Amount::ZERO,
- mint_url: mint_url.clone(),
- proofs: vec![],
- memo: None,
- unit: CurrencyUnit::Sat,
- redeem_fee: None,
- };
- assert!(token_data_no_memo.memo.is_none());
- }
- }
|