From b23b48c3fe18a3d940c8b8042b1e08dade77671a Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Sun, 29 Sep 2024 14:08:34 +0200 Subject: [PATCH] refactor: wallet into multiple mods --- crates/cdk/src/wallet/balance.rs | 69 ++ crates/cdk/src/wallet/keysets.rs | 95 ++ crates/cdk/src/wallet/melt.rs | 310 ++++++ crates/cdk/src/wallet/mint.rs | 274 +++++ crates/cdk/src/wallet/mod.rs | 1681 +----------------------------- crates/cdk/src/wallet/proofs.rs | 277 +++++ crates/cdk/src/wallet/receive.rs | 224 ++++ crates/cdk/src/wallet/send.rs | 212 ++++ crates/cdk/src/wallet/swap.rs | 308 ++++++ 9 files changed, 1783 insertions(+), 1667 deletions(-) create mode 100644 crates/cdk/src/wallet/balance.rs create mode 100644 crates/cdk/src/wallet/keysets.rs create mode 100644 crates/cdk/src/wallet/melt.rs create mode 100644 crates/cdk/src/wallet/mint.rs create mode 100644 crates/cdk/src/wallet/proofs.rs create mode 100644 crates/cdk/src/wallet/receive.rs create mode 100644 crates/cdk/src/wallet/send.rs create mode 100644 crates/cdk/src/wallet/swap.rs diff --git a/crates/cdk/src/wallet/balance.rs b/crates/cdk/src/wallet/balance.rs new file mode 100644 index 000000000..8fe277b9d --- /dev/null +++ b/crates/cdk/src/wallet/balance.rs @@ -0,0 +1,69 @@ +use std::collections::HashMap; + +use tracing::instrument; + +use crate::{ + nuts::{CurrencyUnit, State}, + Amount, Error, Wallet, +}; + +impl Wallet { + /// Total unspent balance of wallet + #[instrument(skip(self))] + pub async fn total_balance(&self) -> Result { + let proofs = self + .localstore + .get_proofs( + Some(self.mint_url.clone()), + Some(self.unit), + Some(vec![State::Unspent]), + None, + ) + .await?; + let balance = Amount::try_sum(proofs.iter().map(|p| p.proof.amount))?; + + Ok(balance) + } + + /// Total pending balance + #[instrument(skip(self))] + pub async fn total_pending_balance(&self) -> Result, Error> { + let proofs = self + .localstore + .get_proofs( + Some(self.mint_url.clone()), + Some(self.unit), + Some(vec![State::Pending]), + None, + ) + .await?; + + let balances = proofs.iter().fold(HashMap::new(), |mut acc, proof| { + *acc.entry(proof.unit).or_insert(Amount::ZERO) += proof.proof.amount; + acc + }); + + Ok(balances) + } + + /// Total reserved balance + #[instrument(skip(self))] + pub async fn total_reserved_balance(&self) -> Result, Error> { + let proofs = self + .localstore + .get_proofs( + Some(self.mint_url.clone()), + Some(self.unit), + Some(vec![State::Reserved]), + None, + ) + .await?; + + let balances = proofs.iter().fold(HashMap::new(), |mut acc, proof| { + *acc.entry(proof.unit).or_insert(Amount::ZERO) += proof.proof.amount; + acc + }); + + Ok(balances) + } +} diff --git a/crates/cdk/src/wallet/keysets.rs b/crates/cdk/src/wallet/keysets.rs new file mode 100644 index 000000000..a8c1f27ad --- /dev/null +++ b/crates/cdk/src/wallet/keysets.rs @@ -0,0 +1,95 @@ +use tracing::instrument; + +use crate::nuts::Id; +use crate::nuts::KeySetInfo; +use crate::nuts::Keys; +use crate::Error; +use crate::Wallet; + +impl Wallet { + /// Get keys for mint keyset + /// + /// Selected keys from localstore if they are already known + /// If they are not known queries mint for keyset id and stores the [`Keys`] + #[instrument(skip(self))] + pub async fn get_keyset_keys(&self, keyset_id: Id) -> Result { + let keys = if let Some(keys) = self.localstore.get_keys(&keyset_id).await? { + keys + } else { + let keys = self + .client + .get_mint_keyset(self.mint_url.clone().try_into()?, keyset_id) + .await?; + + self.localstore.add_keys(keys.keys.clone()).await?; + + keys.keys + }; + + Ok(keys) + } + + /// Get keysets for mint + /// + /// Queries mint for all keysets + #[instrument(skip(self))] + pub async fn get_mint_keysets(&self) -> Result, Error> { + let keysets = self + .client + .get_mint_keysets(self.mint_url.clone().try_into()?) + .await?; + + self.localstore + .add_mint_keysets(self.mint_url.clone(), keysets.keysets.clone()) + .await?; + + Ok(keysets.keysets) + } + + /// Get active keyset for mint + /// + /// Queries mint for current keysets then gets [`Keys`] for any unknown + /// keysets + #[instrument(skip(self))] + pub async fn get_active_mint_keyset(&self) -> Result { + let keysets = self + .client + .get_mint_keysets(self.mint_url.clone().try_into()?) + .await?; + let keysets = keysets.keysets; + + self.localstore + .add_mint_keysets(self.mint_url.clone(), keysets.clone()) + .await?; + + let active_keysets = keysets + .clone() + .into_iter() + .filter(|k| k.active && k.unit == self.unit) + .collect::>(); + + match self + .localstore + .get_mint_keysets(self.mint_url.clone()) + .await? + { + Some(known_keysets) => { + let unknown_keysets: Vec<&KeySetInfo> = keysets + .iter() + .filter(|k| known_keysets.contains(k)) + .collect(); + + for keyset in unknown_keysets { + self.get_keyset_keys(keyset.id).await?; + } + } + None => { + for keyset in keysets { + self.get_keyset_keys(keyset.id).await?; + } + } + } + + active_keysets.first().ok_or(Error::NoActiveKeyset).cloned() + } +} diff --git a/crates/cdk/src/wallet/melt.rs b/crates/cdk/src/wallet/melt.rs new file mode 100644 index 000000000..2bff1a831 --- /dev/null +++ b/crates/cdk/src/wallet/melt.rs @@ -0,0 +1,310 @@ +use std::str::FromStr; + +use lightning_invoice::Bolt11Invoice; +use tracing::instrument; + +use crate::{ + dhke::construct_proofs, + nuts::{ + CurrencyUnit, MeltQuoteBolt11Response, MeltQuoteState, PreMintSecrets, Proofs, PublicKey, + State, + }, + types::{Melted, ProofInfo}, + util::unix_time, + Amount, Error, Wallet, +}; + +use super::MeltQuote; + +impl Wallet { + /// Melt Quote + /// # Synopsis + /// ```rust + /// use std::sync::Arc; + /// + /// use cdk::cdk_database::WalletMemoryDatabase; + /// use cdk::nuts::CurrencyUnit; + /// use cdk::wallet::Wallet; + /// use rand::Rng; + /// + /// #[tokio::main] + /// async fn main() -> anyhow::Result<()> { + /// let seed = rand::thread_rng().gen::<[u8; 32]>(); + /// let mint_url = "https://testnut.cashu.space"; + /// let unit = CurrencyUnit::Sat; + /// + /// let localstore = WalletMemoryDatabase::default(); + /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap(); + /// let bolt11 = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq".to_string(); + /// let quote = wallet.melt_quote(bolt11, None).await?; + /// + /// Ok(()) + /// } + /// ``` + #[instrument(skip(self, request))] + pub async fn melt_quote( + &self, + request: String, + mpp: Option, + ) -> Result { + let invoice = Bolt11Invoice::from_str(&request)?; + + let request_amount = invoice + .amount_milli_satoshis() + .ok_or(Error::InvoiceAmountUndefined)?; + + let amount = match self.unit { + CurrencyUnit::Sat => Amount::from(request_amount / 1000), + CurrencyUnit::Msat => Amount::from(request_amount), + _ => return Err(Error::UnitUnsupported), + }; + + let quote_res = self + .client + .post_melt_quote(self.mint_url.clone().try_into()?, self.unit, invoice, mpp) + .await?; + + if quote_res.amount != amount { + return Err(Error::IncorrectQuoteAmount); + } + + let quote = MeltQuote { + id: quote_res.quote, + amount, + request, + unit: self.unit, + fee_reserve: quote_res.fee_reserve, + state: quote_res.state, + expiry: quote_res.expiry, + payment_preimage: quote_res.payment_preimage, + }; + + self.localstore.add_melt_quote(quote.clone()).await?; + + Ok(quote) + } + + /// Melt quote status + #[instrument(skip(self, quote_id))] + pub async fn melt_quote_status( + &self, + quote_id: &str, + ) -> Result { + let response = self + .client + .get_melt_quote_status(self.mint_url.clone().try_into()?, quote_id) + .await?; + + match self.localstore.get_melt_quote(quote_id).await? { + Some(quote) => { + let mut quote = quote; + + quote.state = response.state; + self.localstore.add_melt_quote(quote).await?; + } + None => { + tracing::info!("Quote melt {} unknown", quote_id); + } + } + + Ok(response) + } + + /// Melt specific proofs + #[instrument(skip(self, proofs))] + pub async fn melt_proofs(&self, quote_id: &str, proofs: Proofs) -> Result { + let quote_info = self.localstore.get_melt_quote(quote_id).await?; + let quote_info = if let Some(quote) = quote_info { + if quote.expiry.le(&unix_time()) { + return Err(Error::ExpiredQuote(quote.expiry, unix_time())); + } + + quote.clone() + } else { + return Err(Error::UnknownQuote); + }; + + let proofs_total = Amount::try_sum(proofs.iter().map(|p| p.amount))?; + if proofs_total < quote_info.amount + quote_info.fee_reserve { + return Err(Error::InsufficientFunds); + } + + let ys = proofs + .iter() + .map(|p| p.y()) + .collect::, _>>()?; + self.localstore.set_pending_proofs(ys).await?; + + let active_keyset_id = self.get_active_mint_keyset().await?.id; + + let count = self + .localstore + .get_keyset_counter(&active_keyset_id) + .await?; + + let count = count.map_or(0, |c| c + 1); + + let premint_secrets = PreMintSecrets::from_xpriv_blank( + active_keyset_id, + count, + self.xpriv, + proofs_total - quote_info.amount, + )?; + + let melt_response = self + .client + .post_melt( + self.mint_url.clone().try_into()?, + quote_id.to_string(), + proofs.clone(), + Some(premint_secrets.blinded_messages()), + ) + .await; + + let melt_response = match melt_response { + Ok(melt_response) => melt_response, + Err(err) => { + tracing::error!("Could not melt: {}", err); + tracing::info!("Checking status of input proofs."); + + self.reclaim_unspent(proofs).await?; + + return Err(err); + } + }; + + let active_keys = self + .localstore + .get_keys(&active_keyset_id) + .await? + .ok_or(Error::NoActiveKeyset)?; + + let change_proofs = match melt_response.change { + Some(change) => { + let num_change_proof = change.len(); + + let num_change_proof = match ( + premint_secrets.len() < num_change_proof, + premint_secrets.secrets().len() < num_change_proof, + ) { + (true, _) | (_, true) => { + tracing::error!("Mismatch in change promises to change"); + premint_secrets.len() + } + _ => num_change_proof, + }; + + Some(construct_proofs( + change, + premint_secrets.rs()[..num_change_proof].to_vec(), + premint_secrets.secrets()[..num_change_proof].to_vec(), + &active_keys, + )?) + } + None => None, + }; + + let state = match melt_response.paid { + true => MeltQuoteState::Paid, + false => MeltQuoteState::Unpaid, + }; + + let melted = Melted::from_proofs( + state, + melt_response.payment_preimage, + quote_info.amount, + proofs.clone(), + change_proofs.clone(), + )?; + + let change_proof_infos = match change_proofs { + Some(change_proofs) => { + tracing::debug!( + "Change amount returned from melt: {}", + Amount::try_sum(change_proofs.iter().map(|p| p.amount))? + ); + + // Update counter for keyset + self.localstore + .increment_keyset_counter(&active_keyset_id, change_proofs.len() as u32) + .await?; + + change_proofs + .into_iter() + .map(|proof| { + ProofInfo::new( + proof, + self.mint_url.clone(), + State::Unspent, + quote_info.unit, + ) + }) + .collect::, _>>()? + } + None => Vec::new(), + }; + + self.localstore.remove_melt_quote("e_info.id).await?; + + let deleted_ys = proofs + .iter() + .map(|p| p.y()) + .collect::, _>>()?; + self.localstore + .update_proofs(change_proof_infos, deleted_ys) + .await?; + + Ok(melted) + } + + /// Melt + /// # Synopsis + /// ```rust, no_run + /// use std::sync::Arc; + /// + /// use cdk::cdk_database::WalletMemoryDatabase; + /// use cdk::nuts::CurrencyUnit; + /// use cdk::wallet::Wallet; + /// use rand::Rng; + /// + /// #[tokio::main] + /// async fn main() -> anyhow::Result<()> { + /// let seed = rand::thread_rng().gen::<[u8; 32]>(); + /// let mint_url = "https://testnut.cashu.space"; + /// let unit = CurrencyUnit::Sat; + /// + /// let localstore = WalletMemoryDatabase::default(); + /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap(); + /// let bolt11 = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq".to_string(); + /// let quote = wallet.melt_quote(bolt11, None).await?; + /// let quote_id = quote.id; + /// + /// let _ = wallet.melt("e_id).await?; + /// + /// Ok(()) + /// } + #[instrument(skip(self))] + pub async fn melt(&self, quote_id: &str) -> Result { + let quote_info = self.localstore.get_melt_quote(quote_id).await?; + + let quote_info = if let Some(quote) = quote_info { + if quote.expiry.le(&unix_time()) { + return Err(Error::ExpiredQuote(quote.expiry, unix_time())); + } + + quote.clone() + } else { + return Err(Error::UnknownQuote); + }; + + let inputs_needed_amount = quote_info.amount + quote_info.fee_reserve; + + let available_proofs = self.get_proofs().await?; + + let input_proofs = self + .select_proofs_to_swap(inputs_needed_amount, available_proofs) + .await?; + + self.melt_proofs(quote_id, input_proofs).await + } +} diff --git a/crates/cdk/src/wallet/mint.rs b/crates/cdk/src/wallet/mint.rs new file mode 100644 index 000000000..6b2414529 --- /dev/null +++ b/crates/cdk/src/wallet/mint.rs @@ -0,0 +1,274 @@ +use tracing::instrument; + +use super::MintQuote; +use crate::{ + amount::SplitTarget, + dhke::construct_proofs, + nuts::{nut12, MintQuoteBolt11Response, PreMintSecrets, SpendingConditions, State}, + types::ProofInfo, + util::unix_time, + wallet::MintQuoteState, + Amount, Error, Wallet, +}; + +impl Wallet { + /// Mint Quote + /// # Synopsis + /// ```rust + /// use std::sync::Arc; + /// + /// use cdk::amount::Amount; + /// use cdk::cdk_database::WalletMemoryDatabase; + /// use cdk::nuts::CurrencyUnit; + /// use cdk::wallet::Wallet; + /// use rand::Rng; + /// + /// #[tokio::main] + /// async fn main() -> anyhow::Result<()> { + /// let seed = rand::thread_rng().gen::<[u8; 32]>(); + /// let mint_url = "https://testnut.cashu.space"; + /// let unit = CurrencyUnit::Sat; + /// + /// let localstore = WalletMemoryDatabase::default(); + /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None)?; + /// let amount = Amount::from(100); + /// + /// let quote = wallet.mint_quote(amount, None).await?; + /// Ok(()) + /// } + /// ``` + #[instrument(skip(self))] + pub async fn mint_quote( + &self, + amount: Amount, + description: Option, + ) -> Result { + let mint_url = self.mint_url.clone(); + let unit = self.unit; + + // If we have a description, we check that the mint supports it. + // If we have a description, we check that the mint supports it. + if description.is_some() { + let mint_method_settings = self + .localstore + .get_mint(mint_url.clone()) + .await? + .ok_or(Error::IncorrectMint)? + .nuts + .nut04 + .get_settings(&unit, &crate::nuts::PaymentMethod::Bolt11) + .ok_or(Error::UnsupportedUnit)?; + + if !mint_method_settings.description { + return Err(Error::InvoiceDescriptionUnsupported); + } + } + + let quote_res = self + .client + .post_mint_quote(mint_url.clone().try_into()?, amount, unit, description) + .await?; + + let quote = MintQuote { + mint_url, + id: quote_res.quote.clone(), + amount, + unit, + request: quote_res.request, + state: quote_res.state, + expiry: quote_res.expiry.unwrap_or(0), + }; + + self.localstore.add_mint_quote(quote.clone()).await?; + + Ok(quote) + } + + /// Check mint quote status + #[instrument(skip(self, quote_id))] + pub async fn mint_quote_state(&self, quote_id: &str) -> Result { + let response = self + .client + .get_mint_quote_status(self.mint_url.clone().try_into()?, quote_id) + .await?; + + match self.localstore.get_mint_quote(quote_id).await? { + Some(quote) => { + let mut quote = quote; + + quote.state = response.state; + self.localstore.add_mint_quote(quote).await?; + } + None => { + tracing::info!("Quote mint {} unknown", quote_id); + } + } + + Ok(response) + } + + /// Check status of pending mint quotes + #[instrument(skip(self))] + pub async fn check_all_mint_quotes(&self) -> Result { + let mint_quotes = self.localstore.get_mint_quotes().await?; + let mut total_amount = Amount::ZERO; + + for mint_quote in mint_quotes { + let mint_quote_response = self.mint_quote_state(&mint_quote.id).await?; + + if mint_quote_response.state == MintQuoteState::Paid { + let amount = self + .mint(&mint_quote.id, SplitTarget::default(), None) + .await?; + total_amount += amount; + } else if mint_quote.expiry.le(&unix_time()) { + self.localstore.remove_mint_quote(&mint_quote.id).await?; + } + } + Ok(total_amount) + } + + /// Mint + /// # Synopsis + /// ```rust + /// use std::sync::Arc; + /// + /// use anyhow::Result; + /// use cdk::amount::{Amount, SplitTarget}; + /// use cdk::cdk_database::WalletMemoryDatabase; + /// use cdk::nuts::CurrencyUnit; + /// use cdk::wallet::Wallet; + /// use rand::Rng; + /// + /// #[tokio::main] + /// async fn main() -> Result<()> { + /// let seed = rand::thread_rng().gen::<[u8; 32]>(); + /// let mint_url = "https://testnut.cashu.space"; + /// let unit = CurrencyUnit::Sat; + /// + /// let localstore = WalletMemoryDatabase::default(); + /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap(); + /// let amount = Amount::from(100); + /// + /// let quote = wallet.mint_quote(amount, None).await?; + /// let quote_id = quote.id; + /// // To be called after quote request is paid + /// let amount_minted = wallet.mint("e_id, SplitTarget::default(), None).await?; + /// + /// Ok(()) + /// } + /// ``` + #[instrument(skip(self))] + pub async fn mint( + &self, + quote_id: &str, + amount_split_target: SplitTarget, + spending_conditions: Option, + ) -> Result { + // Check that mint is in store of mints + if self + .localstore + .get_mint(self.mint_url.clone()) + .await? + .is_none() + { + self.get_mint_info().await?; + } + + let quote_info = self.localstore.get_mint_quote(quote_id).await?; + + let quote_info = if let Some(quote) = quote_info { + if quote.expiry.le(&unix_time()) && quote.expiry.ne(&0) { + return Err(Error::ExpiredQuote(quote.expiry, unix_time())); + } + + quote.clone() + } else { + return Err(Error::UnknownQuote); + }; + + let active_keyset_id = self.get_active_mint_keyset().await?.id; + + let count = self + .localstore + .get_keyset_counter(&active_keyset_id) + .await?; + + let count = count.map_or(0, |c| c + 1); + + let premint_secrets = match &spending_conditions { + Some(spending_conditions) => PreMintSecrets::with_conditions( + active_keyset_id, + quote_info.amount, + &amount_split_target, + spending_conditions, + )?, + None => PreMintSecrets::from_xpriv( + active_keyset_id, + count, + self.xpriv, + quote_info.amount, + &amount_split_target, + )?, + }; + + let mint_res = self + .client + .post_mint( + self.mint_url.clone().try_into()?, + quote_id, + premint_secrets.clone(), + ) + .await?; + + let keys = self.get_keyset_keys(active_keyset_id).await?; + + // Verify the signature DLEQ is valid + { + for (sig, premint) in mint_res.signatures.iter().zip(&premint_secrets.secrets) { + let keys = self.get_keyset_keys(sig.keyset_id).await?; + let key = keys.amount_key(sig.amount).ok_or(Error::AmountKey)?; + match sig.verify_dleq(key, premint.blinded_message.blinded_secret) { + Ok(_) | Err(nut12::Error::MissingDleqProof) => (), + Err(_) => return Err(Error::CouldNotVerifyDleq), + } + } + } + + let proofs = construct_proofs( + mint_res.signatures, + premint_secrets.rs(), + premint_secrets.secrets(), + &keys, + )?; + + let minted_amount = Amount::try_sum(proofs.iter().map(|p| p.amount))?; + + // Remove filled quote from store + self.localstore.remove_mint_quote("e_info.id).await?; + + if spending_conditions.is_none() { + // Update counter for keyset + self.localstore + .increment_keyset_counter(&active_keyset_id, proofs.len() as u32) + .await?; + } + + let proofs = proofs + .into_iter() + .map(|proof| { + ProofInfo::new( + proof, + self.mint_url.clone(), + State::Unspent, + quote_info.unit, + ) + }) + .collect::, _>>()?; + + // Add new proofs to store + self.localstore.update_proofs(proofs, vec![]).await?; + + Ok(minted_amount) + } +} diff --git a/crates/cdk/src/wallet/mod.rs b/crates/cdk/src/wallet/mod.rs index e89f13a83..93e1b76b5 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/crates/cdk/src/wallet/mod.rs @@ -1,35 +1,37 @@ #![doc = include_str!("./README.md")] -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::str::FromStr; use std::sync::Arc; use bitcoin::bip32::Xpriv; -use bitcoin::hashes::sha256::Hash as Sha256Hash; -use bitcoin::hashes::Hash; -use bitcoin::key::XOnlyPublicKey; use bitcoin::Network; use tracing::instrument; use crate::amount::SplitTarget; use crate::cdk_database::{self, WalletDatabase}; -use crate::dhke::{construct_proofs, hash_to_curve}; +use crate::dhke::construct_proofs; use crate::error::Error; use crate::fees::calculate_fee; use crate::mint_url::MintUrl; use crate::nuts::nut00::token::Token; use crate::nuts::{ - nut10, nut12, Conditions, CurrencyUnit, Id, KeySetInfo, Keys, Kind, MeltQuoteBolt11Response, - MeltQuoteState, MintInfo, MintQuoteBolt11Response, MintQuoteState, PaymentMethod, - PreMintSecrets, PreSwap, Proof, ProofState, Proofs, PublicKey, RestoreRequest, SecretKey, - SigFlag, SpendingConditions, State, SwapRequest, + nut10, CurrencyUnit, Id, Keys, MintInfo, MintQuoteState, PreMintSecrets, Proof, Proofs, + RestoreRequest, SpendingConditions, State, }; -use crate::types::{Melted, ProofInfo}; -use crate::util::{hex, unix_time}; -use crate::{Amount, Bolt11Invoice, HttpClient, SECP256K1}; +use crate::types::ProofInfo; +use crate::{Amount, HttpClient}; +mod balance; pub mod client; +mod keysets; +mod melt; +mod mint; pub mod multi_mint_wallet; +mod proofs; +mod receive; +mod send; +mod swap; pub mod types; pub mod util; @@ -141,65 +143,6 @@ impl Wallet { Ok(Amount::from(fee)) } - /// Total unspent balance of wallet - #[instrument(skip(self))] - pub async fn total_balance(&self) -> Result { - let proofs = self - .localstore - .get_proofs( - Some(self.mint_url.clone()), - Some(self.unit), - Some(vec![State::Unspent]), - None, - ) - .await?; - let balance = Amount::try_sum(proofs.iter().map(|p| p.proof.amount))?; - - Ok(balance) - } - - /// Total pending balance - #[instrument(skip(self))] - pub async fn total_pending_balance(&self) -> Result, Error> { - let proofs = self - .localstore - .get_proofs( - Some(self.mint_url.clone()), - Some(self.unit), - Some(vec![State::Pending]), - None, - ) - .await?; - - let balances = proofs.iter().fold(HashMap::new(), |mut acc, proof| { - *acc.entry(proof.unit).or_insert(Amount::ZERO) += proof.proof.amount; - acc - }); - - Ok(balances) - } - - /// Total reserved balance - #[instrument(skip(self))] - pub async fn total_reserved_balance(&self) -> Result, Error> { - let proofs = self - .localstore - .get_proofs( - Some(self.mint_url.clone()), - Some(self.unit), - Some(vec![State::Reserved]), - None, - ) - .await?; - - let balances = proofs.iter().fold(HashMap::new(), |mut acc, proof| { - *acc.entry(proof.unit).or_insert(Amount::ZERO) += proof.proof.amount; - acc - }); - - Ok(balances) - } - /// Update Mint information and related entries in the event a mint changes /// its URL #[instrument(skip(self))] @@ -214,63 +157,6 @@ impl Wallet { Ok(()) } - /// Get unspent proofs for mint - #[instrument(skip(self))] - pub async fn get_proofs(&self) -> Result { - Ok(self - .localstore - .get_proofs( - Some(self.mint_url.clone()), - Some(self.unit), - Some(vec![State::Unspent]), - None, - ) - .await? - .into_iter() - .map(|p| p.proof) - .collect()) - } - - /// Get pending [`Proofs`] - #[instrument(skip(self))] - pub async fn get_pending_proofs(&self) -> Result { - Ok(self - .localstore - .get_proofs( - Some(self.mint_url.clone()), - Some(self.unit), - Some(vec![State::Pending]), - None, - ) - .await? - .into_iter() - .map(|p| p.proof) - .collect()) - } - - /// Get reserved [`Proofs`] - #[instrument(skip(self))] - pub async fn get_reserved_proofs(&self) -> Result { - Ok(self - .localstore - .get_proofs( - Some(self.mint_url.clone()), - Some(self.unit), - Some(vec![State::Reserved]), - None, - ) - .await? - .into_iter() - .map(|p| p.proof) - .collect()) - } - - /// Return proofs to unspent allowing them to be selected and spent - #[instrument(skip(self))] - pub async fn unreserve_proofs(&self, ys: Vec) -> Result<(), Error> { - Ok(self.localstore.set_unspent_proofs(ys).await?) - } - /// Qeury mint for current mint information #[instrument(skip(self))] pub async fn get_mint_info(&self) -> Result, Error> { @@ -295,450 +181,6 @@ impl Wallet { Ok(mint_info) } - /// Get keys for mint keyset - /// - /// Selected keys from localstore if they are already known - /// If they are not known queries mint for keyset id and stores the [`Keys`] - #[instrument(skip(self))] - pub async fn get_keyset_keys(&self, keyset_id: Id) -> Result { - let keys = if let Some(keys) = self.localstore.get_keys(&keyset_id).await? { - keys - } else { - let keys = self - .client - .get_mint_keyset(self.mint_url.clone().try_into()?, keyset_id) - .await?; - - self.localstore.add_keys(keys.keys.clone()).await?; - - keys.keys - }; - - Ok(keys) - } - - /// Get keysets for mint - /// - /// Queries mint for all keysets - #[instrument(skip(self))] - pub async fn get_mint_keysets(&self) -> Result, Error> { - let keysets = self - .client - .get_mint_keysets(self.mint_url.clone().try_into()?) - .await?; - - self.localstore - .add_mint_keysets(self.mint_url.clone(), keysets.keysets.clone()) - .await?; - - Ok(keysets.keysets) - } - - /// Get active keyset for mint - /// - /// Queries mint for current keysets then gets [`Keys`] for any unknown - /// keysets - #[instrument(skip(self))] - pub async fn get_active_mint_keyset(&self) -> Result { - let keysets = self - .client - .get_mint_keysets(self.mint_url.clone().try_into()?) - .await?; - let keysets = keysets.keysets; - - self.localstore - .add_mint_keysets(self.mint_url.clone(), keysets.clone()) - .await?; - - let active_keysets = keysets - .clone() - .into_iter() - .filter(|k| k.active && k.unit == self.unit) - .collect::>(); - - match self - .localstore - .get_mint_keysets(self.mint_url.clone()) - .await? - { - Some(known_keysets) => { - let unknown_keysets: Vec<&KeySetInfo> = keysets - .iter() - .filter(|k| known_keysets.contains(k)) - .collect(); - - for keyset in unknown_keysets { - self.get_keyset_keys(keyset.id).await?; - } - } - None => { - for keyset in keysets { - self.get_keyset_keys(keyset.id).await?; - } - } - } - - active_keysets.first().ok_or(Error::NoActiveKeyset).cloned() - } - - /// Reclaim unspent proofs - /// - /// Checks the stats of [`Proofs`] swapping for a new [`Proof`] if unspent - #[instrument(skip(self, proofs))] - pub async fn reclaim_unspent(&self, proofs: Proofs) -> Result<(), Error> { - let proof_ys = proofs - .iter() - // Find Y for the secret - .map(|p| hash_to_curve(p.secret.as_bytes())) - .collect::, _>>()?; - - let spendable = self - .client - .post_check_state(self.mint_url.clone().try_into()?, proof_ys) - .await? - .states; - - let unspent: Proofs = proofs - .into_iter() - .zip(spendable) - .filter_map(|(p, s)| (s.state == State::Unspent).then_some(p)) - .collect(); - - self.swap(None, SplitTarget::default(), unspent, None, false) - .await?; - - Ok(()) - } - - /// NUT-07 Check the state of a [`Proof`] with the mint - #[instrument(skip(self, proofs))] - pub async fn check_proofs_spent(&self, proofs: Proofs) -> Result, Error> { - let spendable = self - .client - .post_check_state( - self.mint_url.clone().try_into()?, - proofs - .iter() - // Find Y for the secret - .map(|p| hash_to_curve(p.secret.as_bytes())) - .collect::, _>>()?, - ) - .await?; - - Ok(spendable.states) - } - - /// Checks pending proofs for spent status - #[instrument(skip(self))] - pub async fn check_all_pending_proofs(&self) -> Result { - let mut balance = Amount::ZERO; - - let proofs = self - .localstore - .get_proofs( - Some(self.mint_url.clone()), - Some(self.unit), - Some(vec![State::Pending, State::Reserved]), - None, - ) - .await?; - - if proofs.is_empty() { - return Ok(Amount::ZERO); - } - - let states = self - .check_proofs_spent(proofs.clone().into_iter().map(|p| p.proof).collect()) - .await?; - - // Both `State::Pending` and `State::Unspent` should be included in the pending - // table. This is because a proof that has been crated to send will be - // stored in the pending table in order to avoid accidentally double - // spending but to allow it to be explicitly reclaimed - let pending_states: HashSet = states - .into_iter() - .filter(|s| s.state.ne(&State::Spent)) - .map(|s| s.y) - .collect(); - - let (pending_proofs, non_pending_proofs): (Vec, Vec) = proofs - .into_iter() - .partition(|p| pending_states.contains(&p.y)); - - let amount = Amount::try_sum(pending_proofs.iter().map(|p| p.proof.amount))?; - - self.localstore - .update_proofs( - vec![], - non_pending_proofs.into_iter().map(|p| p.y).collect(), - ) - .await?; - - balance += amount; - - Ok(balance) - } - - /// Mint Quote - /// # Synopsis - /// ```rust - /// use std::sync::Arc; - /// - /// use cdk::amount::Amount; - /// use cdk::cdk_database::WalletMemoryDatabase; - /// use cdk::nuts::CurrencyUnit; - /// use cdk::wallet::Wallet; - /// use rand::Rng; - /// - /// #[tokio::main] - /// async fn main() -> anyhow::Result<()> { - /// let seed = rand::thread_rng().gen::<[u8; 32]>(); - /// let mint_url = "https://testnut.cashu.space"; - /// let unit = CurrencyUnit::Sat; - /// - /// let localstore = WalletMemoryDatabase::default(); - /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None)?; - /// let amount = Amount::from(100); - /// - /// let quote = wallet.mint_quote(amount, None).await?; - /// Ok(()) - /// } - /// ``` - #[instrument(skip(self))] - pub async fn mint_quote( - &self, - amount: Amount, - description: Option, - ) -> Result { - let mint_url = self.mint_url.clone(); - let unit = self.unit; - - // If we have a description, we check that the mint supports it. - // If we have a description, we check that the mint supports it. - if description.is_some() { - let mint_method_settings = self - .localstore - .get_mint(mint_url.clone()) - .await? - .ok_or(Error::IncorrectMint)? - .nuts - .nut04 - .get_settings(&unit, &PaymentMethod::Bolt11) - .ok_or(Error::UnsupportedUnit)?; - - if !mint_method_settings.description { - return Err(Error::InvoiceDescriptionUnsupported); - } - } - - let quote_res = self - .client - .post_mint_quote(mint_url.clone().try_into()?, amount, unit, description) - .await?; - - let quote = MintQuote { - mint_url, - id: quote_res.quote.clone(), - amount, - unit, - request: quote_res.request, - state: quote_res.state, - expiry: quote_res.expiry.unwrap_or(0), - }; - - self.localstore.add_mint_quote(quote.clone()).await?; - - Ok(quote) - } - - /// Check mint quote status - #[instrument(skip(self, quote_id))] - pub async fn mint_quote_state(&self, quote_id: &str) -> Result { - let response = self - .client - .get_mint_quote_status(self.mint_url.clone().try_into()?, quote_id) - .await?; - - match self.localstore.get_mint_quote(quote_id).await? { - Some(quote) => { - let mut quote = quote; - - quote.state = response.state; - self.localstore.add_mint_quote(quote).await?; - } - None => { - tracing::info!("Quote mint {} unknown", quote_id); - } - } - - Ok(response) - } - - /// Check status of pending mint quotes - #[instrument(skip(self))] - pub async fn check_all_mint_quotes(&self) -> Result { - let mint_quotes = self.localstore.get_mint_quotes().await?; - let mut total_amount = Amount::ZERO; - - for mint_quote in mint_quotes { - let mint_quote_response = self.mint_quote_state(&mint_quote.id).await?; - - if mint_quote_response.state == MintQuoteState::Paid { - let amount = self - .mint(&mint_quote.id, SplitTarget::default(), None) - .await?; - total_amount += amount; - } else if mint_quote.expiry.le(&unix_time()) { - self.localstore.remove_mint_quote(&mint_quote.id).await?; - } - } - Ok(total_amount) - } - - /// Mint - /// # Synopsis - /// ```rust - /// use std::sync::Arc; - /// - /// use anyhow::Result; - /// use cdk::amount::{Amount, SplitTarget}; - /// use cdk::cdk_database::WalletMemoryDatabase; - /// use cdk::nuts::CurrencyUnit; - /// use cdk::wallet::Wallet; - /// use rand::Rng; - /// - /// #[tokio::main] - /// async fn main() -> Result<()> { - /// let seed = rand::thread_rng().gen::<[u8; 32]>(); - /// let mint_url = "https://testnut.cashu.space"; - /// let unit = CurrencyUnit::Sat; - /// - /// let localstore = WalletMemoryDatabase::default(); - /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap(); - /// let amount = Amount::from(100); - /// - /// let quote = wallet.mint_quote(amount, None).await?; - /// let quote_id = quote.id; - /// // To be called after quote request is paid - /// let amount_minted = wallet.mint("e_id, SplitTarget::default(), None).await?; - /// - /// Ok(()) - /// } - /// ``` - #[instrument(skip(self))] - pub async fn mint( - &self, - quote_id: &str, - amount_split_target: SplitTarget, - spending_conditions: Option, - ) -> Result { - // Check that mint is in store of mints - if self - .localstore - .get_mint(self.mint_url.clone()) - .await? - .is_none() - { - self.get_mint_info().await?; - } - - let quote_info = self.localstore.get_mint_quote(quote_id).await?; - - let quote_info = if let Some(quote) = quote_info { - if quote.expiry.le(&unix_time()) && quote.expiry.ne(&0) { - return Err(Error::ExpiredQuote(quote.expiry, unix_time())); - } - - quote.clone() - } else { - return Err(Error::UnknownQuote); - }; - - let active_keyset_id = self.get_active_mint_keyset().await?.id; - - let count = self - .localstore - .get_keyset_counter(&active_keyset_id) - .await?; - - let count = count.map_or(0, |c| c + 1); - - let premint_secrets = match &spending_conditions { - Some(spending_conditions) => PreMintSecrets::with_conditions( - active_keyset_id, - quote_info.amount, - &amount_split_target, - spending_conditions, - )?, - None => PreMintSecrets::from_xpriv( - active_keyset_id, - count, - self.xpriv, - quote_info.amount, - &amount_split_target, - )?, - }; - - let mint_res = self - .client - .post_mint( - self.mint_url.clone().try_into()?, - quote_id, - premint_secrets.clone(), - ) - .await?; - - let keys = self.get_keyset_keys(active_keyset_id).await?; - - // Verify the signature DLEQ is valid - { - for (sig, premint) in mint_res.signatures.iter().zip(&premint_secrets.secrets) { - let keys = self.get_keyset_keys(sig.keyset_id).await?; - let key = keys.amount_key(sig.amount).ok_or(Error::AmountKey)?; - match sig.verify_dleq(key, premint.blinded_message.blinded_secret) { - Ok(_) | Err(nut12::Error::MissingDleqProof) => (), - Err(_) => return Err(Error::CouldNotVerifyDleq), - } - } - } - - let proofs = construct_proofs( - mint_res.signatures, - premint_secrets.rs(), - premint_secrets.secrets(), - &keys, - )?; - - let minted_amount = Amount::try_sum(proofs.iter().map(|p| p.amount))?; - - // Remove filled quote from store - self.localstore.remove_mint_quote("e_info.id).await?; - - if spending_conditions.is_none() { - // Update counter for keyset - self.localstore - .increment_keyset_counter(&active_keyset_id, proofs.len() as u32) - .await?; - } - - let proofs = proofs - .into_iter() - .map(|proof| { - ProofInfo::new( - proof, - self.mint_url.clone(), - State::Unspent, - quote_info.unit, - ) - }) - .collect::, _>>()?; - - // Add new proofs to store - self.localstore.update_proofs(proofs, vec![]).await?; - - Ok(minted_amount) - } - /// Get amounts needed to refill proof state #[instrument(skip(self))] pub async fn amounts_needed_for_state_target(&self) -> Result, Error> { @@ -794,1101 +236,6 @@ impl Wallet { Ok(SplitTarget::Values(values)) } - /// Create Swap Payload - #[instrument(skip(self, proofs))] - pub async fn create_swap( - &self, - amount: Option, - amount_split_target: SplitTarget, - proofs: Proofs, - spending_conditions: Option, - include_fees: bool, - ) -> Result { - let active_keyset_id = self.get_active_mint_keyset().await?.id; - - // Desired amount is either amount passed or value of all proof - let proofs_total = Amount::try_sum(proofs.iter().map(|p| p.amount))?; - - let ys: Vec = proofs.iter().map(|p| p.y()).collect::>()?; - self.localstore.set_pending_proofs(ys).await?; - - let fee = self.get_proofs_fee(&proofs).await?; - - let change_amount: Amount = proofs_total - amount.unwrap_or(Amount::ZERO) - fee; - - let (send_amount, change_amount) = match include_fees { - true => { - let split_count = amount - .unwrap_or(Amount::ZERO) - .split_targeted(&SplitTarget::default()) - .unwrap() - .len(); - - let fee_to_redeem = self - .get_keyset_count_fee(&active_keyset_id, split_count as u64) - .await?; - - ( - amount.map(|a| a + fee_to_redeem), - change_amount - fee_to_redeem, - ) - } - false => (amount, change_amount), - }; - - // If a non None split target is passed use that - // else use state refill - let change_split_target = match amount_split_target { - SplitTarget::None => self.determine_split_target_values(change_amount).await?, - s => s, - }; - - let derived_secret_count; - - let count = self - .localstore - .get_keyset_counter(&active_keyset_id) - .await?; - - let mut count = count.map_or(0, |c| c + 1); - - let (mut desired_messages, change_messages) = match spending_conditions { - Some(conditions) => { - let change_premint_secrets = PreMintSecrets::from_xpriv( - active_keyset_id, - count, - self.xpriv, - change_amount, - &change_split_target, - )?; - - derived_secret_count = change_premint_secrets.len(); - - ( - PreMintSecrets::with_conditions( - active_keyset_id, - send_amount.unwrap_or(Amount::ZERO), - &SplitTarget::default(), - &conditions, - )?, - change_premint_secrets, - ) - } - None => { - let premint_secrets = PreMintSecrets::from_xpriv( - active_keyset_id, - count, - self.xpriv, - send_amount.unwrap_or(Amount::ZERO), - &SplitTarget::default(), - )?; - - count += premint_secrets.len() as u32; - - let change_premint_secrets = PreMintSecrets::from_xpriv( - active_keyset_id, - count, - self.xpriv, - change_amount, - &change_split_target, - )?; - - derived_secret_count = change_premint_secrets.len() + premint_secrets.len(); - - (premint_secrets, change_premint_secrets) - } - }; - - // Combine the BlindedMessages totaling the desired amount with change - desired_messages.combine(change_messages); - // Sort the premint secrets to avoid finger printing - desired_messages.sort_secrets(); - - let swap_request = SwapRequest::new(proofs, desired_messages.blinded_messages()); - - Ok(PreSwap { - pre_mint_secrets: desired_messages, - swap_request, - derived_secret_count: derived_secret_count as u32, - fee, - }) - } - - /// Swap - #[instrument(skip(self, input_proofs))] - pub async fn swap( - &self, - amount: Option, - amount_split_target: SplitTarget, - input_proofs: Proofs, - spending_conditions: Option, - include_fees: bool, - ) -> Result, Error> { - let mint_url = &self.mint_url; - let unit = &self.unit; - - let pre_swap = self - .create_swap( - amount, - amount_split_target, - input_proofs.clone(), - spending_conditions.clone(), - include_fees, - ) - .await?; - - let swap_response = self - .client - .post_swap(mint_url.clone().try_into()?, pre_swap.swap_request) - .await?; - - let active_keyset_id = pre_swap.pre_mint_secrets.keyset_id; - - let active_keys = self - .localstore - .get_keys(&active_keyset_id) - .await? - .ok_or(Error::NoActiveKeyset)?; - - let post_swap_proofs = construct_proofs( - swap_response.signatures, - pre_swap.pre_mint_secrets.rs(), - pre_swap.pre_mint_secrets.secrets(), - &active_keys, - )?; - - self.localstore - .increment_keyset_counter(&active_keyset_id, pre_swap.derived_secret_count) - .await?; - - let mut added_proofs = Vec::new(); - let change_proofs; - let send_proofs; - match amount { - Some(amount) => { - let (proofs_with_condition, proofs_without_condition): (Proofs, Proofs) = - post_swap_proofs.into_iter().partition(|p| { - let nut10_secret: Result = p.secret.clone().try_into(); - - nut10_secret.is_ok() - }); - - let (proofs_to_send, proofs_to_keep) = match spending_conditions { - Some(_) => (proofs_with_condition, proofs_without_condition), - None => { - let mut all_proofs = proofs_without_condition; - all_proofs.reverse(); - - let mut proofs_to_send: Proofs = Vec::new(); - let mut proofs_to_keep = Vec::new(); - - for proof in all_proofs { - let proofs_to_send_amount = - Amount::try_sum(proofs_to_send.iter().map(|p| p.amount))?; - if proof.amount + proofs_to_send_amount <= amount + pre_swap.fee { - proofs_to_send.push(proof); - } else { - proofs_to_keep.push(proof); - } - } - - (proofs_to_send, proofs_to_keep) - } - }; - - let send_amount = Amount::try_sum(proofs_to_send.iter().map(|p| p.amount))?; - - if send_amount.ne(&(amount + pre_swap.fee)) { - tracing::warn!( - "Send amount proofs is {:?} expected {:?}", - send_amount, - amount - ); - } - - let send_proofs_info = proofs_to_send - .clone() - .into_iter() - .map(|proof| ProofInfo::new(proof, mint_url.clone(), State::Reserved, *unit)) - .collect::, _>>()?; - added_proofs = send_proofs_info; - - change_proofs = proofs_to_keep; - send_proofs = Some(proofs_to_send); - } - None => { - change_proofs = post_swap_proofs; - send_proofs = None; - } - } - - let keep_proofs = change_proofs - .into_iter() - .map(|proof| ProofInfo::new(proof, mint_url.clone(), State::Unspent, *unit)) - .collect::, _>>()?; - added_proofs.extend(keep_proofs); - - // Remove spent proofs used as inputs - let deleted_ys = input_proofs - .into_iter() - .map(|proof| proof.y()) - .collect::, _>>()?; - - self.localstore - .update_proofs(added_proofs, deleted_ys) - .await?; - Ok(send_proofs) - } - - #[instrument(skip(self))] - async fn swap_from_unspent( - &self, - amount: Amount, - conditions: Option, - include_fees: bool, - ) -> Result { - let available_proofs = self - .localstore - .get_proofs( - Some(self.mint_url.clone()), - Some(self.unit), - Some(vec![State::Unspent]), - None, - ) - .await?; - - let (available_proofs, proofs_sum) = available_proofs.into_iter().map(|p| p.proof).fold( - (Vec::new(), Amount::ZERO), - |(mut acc1, mut acc2), p| { - acc2 += p.amount; - acc1.push(p); - (acc1, acc2) - }, - ); - - if proofs_sum < amount { - return Err(Error::InsufficientFunds); - } - - let proofs = self.select_proofs_to_swap(amount, available_proofs).await?; - - self.swap( - Some(amount), - SplitTarget::default(), - proofs, - conditions, - include_fees, - ) - .await? - .ok_or(Error::InsufficientFunds) - } - - /// Send specific proofs - #[instrument(skip(self))] - pub async fn send_proofs(&self, memo: Option, proofs: Proofs) -> Result { - let ys = proofs - .iter() - .map(|p| p.y()) - .collect::, _>>()?; - self.localstore.reserve_proofs(ys).await?; - - Ok(Token::new( - self.mint_url.clone(), - proofs, - memo, - Some(self.unit), - )) - } - - /// Send - #[instrument(skip(self))] - pub async fn send( - &self, - amount: Amount, - memo: Option, - conditions: Option, - amount_split_target: &SplitTarget, - send_kind: &SendKind, - include_fees: bool, - ) -> Result { - // If online send check mint for current keysets fees - if matches!( - send_kind, - SendKind::OnlineExact | SendKind::OnlineTolerance(_) - ) { - if let Err(e) = self.get_active_mint_keyset().await { - tracing::error!( - "Error fetching active mint keyset: {:?}. Using stored keysets", - e - ); - } - } - - let mint_url = &self.mint_url; - let unit = &self.unit; - let available_proofs = self - .localstore - .get_proofs( - Some(mint_url.clone()), - Some(*unit), - Some(vec![State::Unspent]), - conditions.clone().map(|c| vec![c]), - ) - .await?; - - let (available_proofs, proofs_sum) = available_proofs.into_iter().map(|p| p.proof).fold( - (Vec::new(), Amount::ZERO), - |(mut acc1, mut acc2), p| { - acc2 += p.amount; - acc1.push(p); - (acc1, acc2) - }, - ); - let available_proofs = if proofs_sum < amount { - match &conditions { - Some(conditions) => { - let available_proofs = self - .localstore - .get_proofs( - Some(mint_url.clone()), - Some(*unit), - Some(vec![State::Unspent]), - None, - ) - .await?; - - let available_proofs = available_proofs.into_iter().map(|p| p.proof).collect(); - - let proofs_to_swap = - self.select_proofs_to_swap(amount, available_proofs).await?; - - let proofs_with_conditions = self - .swap( - Some(amount), - SplitTarget::default(), - proofs_to_swap, - Some(conditions.clone()), - include_fees, - ) - .await?; - proofs_with_conditions.ok_or(Error::InsufficientFunds)? - } - None => { - return Err(Error::InsufficientFunds); - } - } - } else { - available_proofs - }; - - let selected = self - .select_proofs_to_send(amount, available_proofs, include_fees) - .await; - - let send_proofs: Proofs = match (send_kind, selected, conditions.clone()) { - // Handle exact matches offline - (SendKind::OfflineExact, Ok(selected_proofs), _) => { - let selected_proofs_amount = - Amount::try_sum(selected_proofs.iter().map(|p| p.amount))?; - - let amount_to_send = match include_fees { - true => amount + self.get_proofs_fee(&selected_proofs).await?, - false => amount, - }; - - if selected_proofs_amount == amount_to_send { - selected_proofs - } else { - return Err(Error::InsufficientFunds); - } - } - - // Handle exact matches - (SendKind::OnlineExact, Ok(selected_proofs), _) => { - let selected_proofs_amount = - Amount::try_sum(selected_proofs.iter().map(|p| p.amount))?; - - let amount_to_send = match include_fees { - true => amount + self.get_proofs_fee(&selected_proofs).await?, - false => amount, - }; - - if selected_proofs_amount == amount_to_send { - selected_proofs - } else { - tracing::info!("Could not select proofs exact while offline."); - tracing::info!("Attempting to select proofs and swapping"); - - self.swap_from_unspent(amount, conditions, include_fees) - .await? - } - } - - // Handle offline tolerance - (SendKind::OfflineTolerance(tolerance), Ok(selected_proofs), _) => { - let selected_proofs_amount = - Amount::try_sum(selected_proofs.iter().map(|p| p.amount))?; - - let amount_to_send = match include_fees { - true => amount + self.get_proofs_fee(&selected_proofs).await?, - false => amount, - }; - if selected_proofs_amount - amount_to_send <= *tolerance { - selected_proofs - } else { - tracing::info!("Selected proofs greater than tolerance. Must swap online"); - return Err(Error::InsufficientFunds); - } - } - - // Handle online tolerance when selection fails and conditions are present - (SendKind::OnlineTolerance(_), Err(_), Some(_)) => { - tracing::info!("Could not select proofs with conditions while offline."); - tracing::info!("Attempting to select proofs without conditions and swapping"); - - self.swap_from_unspent(amount, conditions, include_fees) - .await? - } - - // Handle online tolerance with successful selection - (SendKind::OnlineTolerance(tolerance), Ok(selected_proofs), _) => { - let selected_proofs_amount = - Amount::try_sum(selected_proofs.iter().map(|p| p.amount))?; - let amount_to_send = match include_fees { - true => amount + self.get_proofs_fee(&selected_proofs).await?, - false => amount, - }; - if selected_proofs_amount - amount_to_send <= *tolerance { - selected_proofs - } else { - tracing::info!("Could not select proofs while offline. Attempting swap"); - self.swap_from_unspent(amount, conditions, include_fees) - .await? - } - } - - // Handle all other cases where selection fails - ( - SendKind::OfflineExact - | SendKind::OnlineExact - | SendKind::OfflineTolerance(_) - | SendKind::OnlineTolerance(_), - Err(_), - _, - ) => { - tracing::debug!("Could not select proofs"); - return Err(Error::InsufficientFunds); - } - }; - - self.send_proofs(memo, send_proofs).await - } - - /// Melt Quote - /// # Synopsis - /// ```rust - /// use std::sync::Arc; - /// - /// use cdk::cdk_database::WalletMemoryDatabase; - /// use cdk::nuts::CurrencyUnit; - /// use cdk::wallet::Wallet; - /// use rand::Rng; - /// - /// #[tokio::main] - /// async fn main() -> anyhow::Result<()> { - /// let seed = rand::thread_rng().gen::<[u8; 32]>(); - /// let mint_url = "https://testnut.cashu.space"; - /// let unit = CurrencyUnit::Sat; - /// - /// let localstore = WalletMemoryDatabase::default(); - /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap(); - /// let bolt11 = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq".to_string(); - /// let quote = wallet.melt_quote(bolt11, None).await?; - /// - /// Ok(()) - /// } - /// ``` - #[instrument(skip(self, request))] - pub async fn melt_quote( - &self, - request: String, - mpp: Option, - ) -> Result { - let invoice = Bolt11Invoice::from_str(&request)?; - - let request_amount = invoice - .amount_milli_satoshis() - .ok_or(Error::InvoiceAmountUndefined)?; - - let amount = match self.unit { - CurrencyUnit::Sat => Amount::from(request_amount / 1000), - CurrencyUnit::Msat => Amount::from(request_amount), - _ => return Err(Error::UnitUnsupported), - }; - - let quote_res = self - .client - .post_melt_quote(self.mint_url.clone().try_into()?, self.unit, invoice, mpp) - .await?; - - if quote_res.amount != amount { - return Err(Error::IncorrectQuoteAmount); - } - - let quote = MeltQuote { - id: quote_res.quote, - amount, - request, - unit: self.unit, - fee_reserve: quote_res.fee_reserve, - state: quote_res.state, - expiry: quote_res.expiry, - payment_preimage: quote_res.payment_preimage, - }; - - self.localstore.add_melt_quote(quote.clone()).await?; - - Ok(quote) - } - - /// Melt quote status - #[instrument(skip(self, quote_id))] - pub async fn melt_quote_status( - &self, - quote_id: &str, - ) -> Result { - let response = self - .client - .get_melt_quote_status(self.mint_url.clone().try_into()?, quote_id) - .await?; - - match self.localstore.get_melt_quote(quote_id).await? { - Some(quote) => { - let mut quote = quote; - - quote.state = response.state; - self.localstore.add_melt_quote(quote).await?; - } - None => { - tracing::info!("Quote melt {} unknown", quote_id); - } - } - - Ok(response) - } - - /// Melt specific proofs - #[instrument(skip(self, proofs))] - pub async fn melt_proofs(&self, quote_id: &str, proofs: Proofs) -> Result { - let quote_info = self.localstore.get_melt_quote(quote_id).await?; - let quote_info = if let Some(quote) = quote_info { - if quote.expiry.le(&unix_time()) { - return Err(Error::ExpiredQuote(quote.expiry, unix_time())); - } - - quote.clone() - } else { - return Err(Error::UnknownQuote); - }; - - let proofs_total = Amount::try_sum(proofs.iter().map(|p| p.amount))?; - if proofs_total < quote_info.amount + quote_info.fee_reserve { - return Err(Error::InsufficientFunds); - } - - let ys = proofs - .iter() - .map(|p| p.y()) - .collect::, _>>()?; - self.localstore.set_pending_proofs(ys).await?; - - let active_keyset_id = self.get_active_mint_keyset().await?.id; - - let count = self - .localstore - .get_keyset_counter(&active_keyset_id) - .await?; - - let count = count.map_or(0, |c| c + 1); - - let premint_secrets = PreMintSecrets::from_xpriv_blank( - active_keyset_id, - count, - self.xpriv, - proofs_total - quote_info.amount, - )?; - - let melt_response = self - .client - .post_melt( - self.mint_url.clone().try_into()?, - quote_id.to_string(), - proofs.clone(), - Some(premint_secrets.blinded_messages()), - ) - .await; - - let melt_response = match melt_response { - Ok(melt_response) => melt_response, - Err(err) => { - tracing::error!("Could not melt: {}", err); - tracing::info!("Checking status of input proofs."); - - self.reclaim_unspent(proofs).await?; - - return Err(err); - } - }; - - let active_keys = self - .localstore - .get_keys(&active_keyset_id) - .await? - .ok_or(Error::NoActiveKeyset)?; - - let change_proofs = match melt_response.change { - Some(change) => { - let num_change_proof = change.len(); - - let num_change_proof = match ( - premint_secrets.len() < num_change_proof, - premint_secrets.secrets().len() < num_change_proof, - ) { - (true, _) | (_, true) => { - tracing::error!("Mismatch in change promises to change"); - premint_secrets.len() - } - _ => num_change_proof, - }; - - Some(construct_proofs( - change, - premint_secrets.rs()[..num_change_proof].to_vec(), - premint_secrets.secrets()[..num_change_proof].to_vec(), - &active_keys, - )?) - } - None => None, - }; - - let state = match melt_response.paid { - true => MeltQuoteState::Paid, - false => MeltQuoteState::Unpaid, - }; - - let melted = Melted::from_proofs( - state, - melt_response.payment_preimage, - quote_info.amount, - proofs.clone(), - change_proofs.clone(), - )?; - - let change_proof_infos = match change_proofs { - Some(change_proofs) => { - tracing::debug!( - "Change amount returned from melt: {}", - Amount::try_sum(change_proofs.iter().map(|p| p.amount))? - ); - - // Update counter for keyset - self.localstore - .increment_keyset_counter(&active_keyset_id, change_proofs.len() as u32) - .await?; - - change_proofs - .into_iter() - .map(|proof| { - ProofInfo::new( - proof, - self.mint_url.clone(), - State::Unspent, - quote_info.unit, - ) - }) - .collect::, _>>()? - } - None => Vec::new(), - }; - - self.localstore.remove_melt_quote("e_info.id).await?; - - let deleted_ys = proofs - .iter() - .map(|p| p.y()) - .collect::, _>>()?; - self.localstore - .update_proofs(change_proof_infos, deleted_ys) - .await?; - - Ok(melted) - } - - /// Melt - /// # Synopsis - /// ```rust, no_run - /// use std::sync::Arc; - /// - /// use cdk::cdk_database::WalletMemoryDatabase; - /// use cdk::nuts::CurrencyUnit; - /// use cdk::wallet::Wallet; - /// use rand::Rng; - /// - /// #[tokio::main] - /// async fn main() -> anyhow::Result<()> { - /// let seed = rand::thread_rng().gen::<[u8; 32]>(); - /// let mint_url = "https://testnut.cashu.space"; - /// let unit = CurrencyUnit::Sat; - /// - /// let localstore = WalletMemoryDatabase::default(); - /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap(); - /// let bolt11 = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq".to_string(); - /// let quote = wallet.melt_quote(bolt11, None).await?; - /// let quote_id = quote.id; - /// - /// let _ = wallet.melt("e_id).await?; - /// - /// Ok(()) - /// } - #[instrument(skip(self))] - pub async fn melt(&self, quote_id: &str) -> Result { - let quote_info = self.localstore.get_melt_quote(quote_id).await?; - - let quote_info = if let Some(quote) = quote_info { - if quote.expiry.le(&unix_time()) { - return Err(Error::ExpiredQuote(quote.expiry, unix_time())); - } - - quote.clone() - } else { - return Err(Error::UnknownQuote); - }; - - let inputs_needed_amount = quote_info.amount + quote_info.fee_reserve; - - let available_proofs = self.get_proofs().await?; - - let input_proofs = self - .select_proofs_to_swap(inputs_needed_amount, available_proofs) - .await?; - - self.melt_proofs(quote_id, input_proofs).await - } - - /// Select proofs to send - #[instrument(skip_all)] - pub async fn select_proofs_to_send( - &self, - amount: Amount, - proofs: Proofs, - include_fees: bool, - ) -> Result { - // TODO: Check all proofs are same unit - - if Amount::try_sum(proofs.iter().map(|p| p.amount))? < amount { - return Err(Error::InsufficientFunds); - } - - let (mut proofs_larger, mut proofs_smaller): (Proofs, Proofs) = - proofs.into_iter().partition(|p| p.amount > amount); - - let next_bigger_proof = proofs_larger.first().cloned(); - - let mut selected_proofs: Vec = Vec::new(); - let mut remaining_amount = amount; - - while remaining_amount > Amount::ZERO { - proofs_larger.sort(); - // Sort smaller proofs in descending order - proofs_smaller.sort_by(|a: &Proof, b: &Proof| b.cmp(a)); - - let selected_proof = if let Some(next_small) = proofs_smaller.clone().first() { - next_small.clone() - } else if let Some(next_bigger) = proofs_larger.first() { - next_bigger.clone() - } else { - break; - }; - - let proof_amount = selected_proof.amount; - - selected_proofs.push(selected_proof); - - let fees = match include_fees { - true => self.get_proofs_fee(&selected_proofs).await?, - false => Amount::ZERO, - }; - - if proof_amount >= remaining_amount + fees { - remaining_amount = Amount::ZERO; - break; - } - - remaining_amount = amount.checked_add(fees).ok_or(Error::AmountOverflow)? - - Amount::try_sum(selected_proofs.iter().map(|p| p.amount))?; - (proofs_larger, proofs_smaller) = proofs_smaller - .into_iter() - .skip(1) - .partition(|p| p.amount > remaining_amount); - } - - if remaining_amount > Amount::ZERO { - if let Some(next_bigger) = next_bigger_proof { - return Ok(vec![next_bigger.clone()]); - } - - return Err(Error::InsufficientFunds); - } - - Ok(selected_proofs) - } - - /// Select proofs to send - #[instrument(skip_all)] - pub async fn select_proofs_to_swap( - &self, - amount: Amount, - proofs: Proofs, - ) -> Result { - let active_keyset_id = self.get_active_mint_keyset().await?.id; - - let (mut active_proofs, mut inactive_proofs): (Proofs, Proofs) = proofs - .into_iter() - .partition(|p| p.keyset_id == active_keyset_id); - - let mut selected_proofs: Proofs = Vec::new(); - inactive_proofs.sort_by(|a: &Proof, b: &Proof| b.cmp(a)); - - for inactive_proof in inactive_proofs { - selected_proofs.push(inactive_proof); - let selected_total = Amount::try_sum(selected_proofs.iter().map(|p| p.amount))?; - let fees = self.get_proofs_fee(&selected_proofs).await?; - - if selected_total >= amount + fees { - return Ok(selected_proofs); - } - } - - active_proofs.sort_by(|a: &Proof, b: &Proof| b.cmp(a)); - - for active_proof in active_proofs { - selected_proofs.push(active_proof); - let selected_total = Amount::try_sum(selected_proofs.iter().map(|p| p.amount))?; - let fees = self.get_proofs_fee(&selected_proofs).await?; - - if selected_total >= amount + fees { - return Ok(selected_proofs); - } - } - - Err(Error::InsufficientFunds) - } - - /// Receive proofs - #[instrument(skip_all)] - pub async fn receive_proofs( - &self, - proofs: Proofs, - amount_split_target: SplitTarget, - p2pk_signing_keys: &[SecretKey], - preimages: &[String], - ) -> Result { - let mut received_proofs: HashMap = HashMap::new(); - let mint_url = &self.mint_url; - // Add mint if it does not exist in the store - if self - .localstore - .get_mint(self.mint_url.clone()) - .await? - .is_none() - { - tracing::debug!( - "Mint not in localstore fetching info for: {}", - self.mint_url - ); - self.get_mint_info().await?; - } - - let _ = self.get_active_mint_keyset().await?; - - let active_keyset_id = self.get_active_mint_keyset().await?.id; - - let keys = self.get_keyset_keys(active_keyset_id).await?; - - let mut proofs = proofs; - - let mut sig_flag = SigFlag::SigInputs; - - // Map hash of preimage to preimage - let hashed_to_preimage: HashMap = preimages - .iter() - .map(|p| { - let hex_bytes = hex::decode(p)?; - Ok::<(String, &String), Error>((Sha256Hash::hash(&hex_bytes).to_string(), p)) - }) - .collect::, _>>()?; - - let p2pk_signing_keys: HashMap = p2pk_signing_keys - .iter() - .map(|s| (s.x_only_public_key(&SECP256K1).0, s)) - .collect(); - - for proof in &mut proofs { - // Verify that proof DLEQ is valid - if proof.dleq.is_some() { - let keys = self.get_keyset_keys(proof.keyset_id).await?; - let key = keys.amount_key(proof.amount).ok_or(Error::AmountKey)?; - proof.verify_dleq(key)?; - } - - if let Ok(secret) = - >::try_into( - proof.secret.clone(), - ) - { - let conditions: Result = - secret.secret_data.tags.unwrap_or_default().try_into(); - if let Ok(conditions) = conditions { - let mut pubkeys = conditions.pubkeys.unwrap_or_default(); - - match secret.kind { - Kind::P2PK => { - let data_key = PublicKey::from_str(&secret.secret_data.data)?; - - pubkeys.push(data_key); - } - Kind::HTLC => { - let hashed_preimage = &secret.secret_data.data; - let preimage = hashed_to_preimage - .get(hashed_preimage) - .ok_or(Error::PreimageNotProvided)?; - proof.add_preimage(preimage.to_string()); - } - } - for pubkey in pubkeys { - if let Some(signing) = p2pk_signing_keys.get(&pubkey.x_only_public_key()) { - proof.sign_p2pk(signing.to_owned().clone())?; - } - } - - if conditions.sig_flag.eq(&SigFlag::SigAll) { - sig_flag = SigFlag::SigAll; - } - } - } - } - - // Since the proofs are unknown they need to be added to the database - let proofs_info = proofs - .clone() - .into_iter() - .map(|p| ProofInfo::new(p, self.mint_url.clone(), State::Pending, self.unit)) - .collect::, _>>()?; - self.localstore.update_proofs(proofs_info, vec![]).await?; - - let mut pre_swap = self - .create_swap(None, amount_split_target, proofs, None, false) - .await?; - - if sig_flag.eq(&SigFlag::SigAll) { - for blinded_message in &mut pre_swap.swap_request.outputs { - for signing_key in p2pk_signing_keys.values() { - blinded_message.sign_p2pk(signing_key.to_owned().clone())? - } - } - } - - let swap_response = self - .client - .post_swap(mint_url.clone().try_into()?, pre_swap.swap_request) - .await?; - - // Proof to keep - let p = construct_proofs( - swap_response.signatures, - pre_swap.pre_mint_secrets.rs(), - pre_swap.pre_mint_secrets.secrets(), - &keys, - )?; - let mint_proofs = received_proofs.entry(mint_url.clone()).or_default(); - - self.localstore - .increment_keyset_counter(&active_keyset_id, p.len() as u32) - .await?; - - mint_proofs.extend(p); - - let mut total_amount = Amount::ZERO; - for (mint, proofs) in received_proofs { - total_amount += Amount::try_sum(proofs.iter().map(|p| p.amount))?; - let proofs = proofs - .into_iter() - .map(|proof| ProofInfo::new(proof, mint.clone(), State::Unspent, self.unit)) - .collect::, _>>()?; - self.localstore.update_proofs(proofs, vec![]).await?; - } - - Ok(total_amount) - } - - /// Receive - /// # Synopsis - /// ```rust, no_run - /// use std::sync::Arc; - /// - /// use cdk::amount::SplitTarget; - /// use cdk::cdk_database::WalletMemoryDatabase; - /// use cdk::nuts::CurrencyUnit; - /// use cdk::wallet::Wallet; - /// use rand::Rng; - /// - /// #[tokio::main] - /// async fn main() -> anyhow::Result<()> { - /// let seed = rand::thread_rng().gen::<[u8; 32]>(); - /// let mint_url = "https://testnut.cashu.space"; - /// let unit = CurrencyUnit::Sat; - /// - /// let localstore = WalletMemoryDatabase::default(); - /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap(); - /// let token = "cashuAeyJ0b2tlbiI6W3sicHJvb2ZzIjpbeyJhbW91bnQiOjEsInNlY3JldCI6ImI0ZjVlNDAxMDJhMzhiYjg3NDNiOTkwMzU5MTU1MGYyZGEzZTQxNWEzMzU0OTUyN2M2MmM5ZDc5MGVmYjM3MDUiLCJDIjoiMDIzYmU1M2U4YzYwNTMwZWVhOWIzOTQzZmRhMWEyY2U3MWM3YjNmMGNmMGRjNmQ4NDZmYTc2NWFhZjc3OWZhODFkIiwiaWQiOiIwMDlhMWYyOTMyNTNlNDFlIn1dLCJtaW50IjoiaHR0cHM6Ly90ZXN0bnV0LmNhc2h1LnNwYWNlIn1dLCJ1bml0Ijoic2F0In0="; - /// let amount_receive = wallet.receive(token, SplitTarget::default(), &[], &[]).await?; - /// Ok(()) - /// } - /// ``` - #[instrument(skip_all)] - pub async fn receive( - &self, - encoded_token: &str, - amount_split_target: SplitTarget, - p2pk_signing_keys: &[SecretKey], - preimages: &[String], - ) -> Result { - let token_data = Token::from_str(encoded_token)?; - - let unit = token_data.unit().unwrap_or_default(); - - if unit != self.unit { - return Err(Error::UnitUnsupported); - } - - let proofs = token_data.proofs(); - if proofs.len() != 1 { - return Err(Error::MultiMintTokenNotSupported); - } - - let (mint_url, proofs) = proofs.into_iter().next().expect("Token has proofs"); - - if self.mint_url != mint_url { - return Err(Error::IncorrectMint); - } - - let amount = self - .receive_proofs(proofs, amount_split_target, p2pk_signing_keys, preimages) - .await?; - - Ok(amount) - } - /// Restore #[instrument(skip(self))] pub async fn restore(&self) -> Result { diff --git a/crates/cdk/src/wallet/proofs.rs b/crates/cdk/src/wallet/proofs.rs new file mode 100644 index 000000000..3eff5cf8c --- /dev/null +++ b/crates/cdk/src/wallet/proofs.rs @@ -0,0 +1,277 @@ +use std::collections::HashSet; + +use tracing::instrument; + +use crate::{ + amount::SplitTarget, + dhke::hash_to_curve, + nuts::{Proof, ProofState, Proofs, PublicKey, State}, + types::ProofInfo, + Amount, Error, Wallet, +}; + +impl Wallet { + /// Get unspent proofs for mint + #[instrument(skip(self))] + pub async fn get_proofs(&self) -> Result { + Ok(self + .localstore + .get_proofs( + Some(self.mint_url.clone()), + Some(self.unit), + Some(vec![State::Unspent]), + None, + ) + .await? + .into_iter() + .map(|p| p.proof) + .collect()) + } + + /// Get pending [`Proofs`] + #[instrument(skip(self))] + pub async fn get_pending_proofs(&self) -> Result { + Ok(self + .localstore + .get_proofs( + Some(self.mint_url.clone()), + Some(self.unit), + Some(vec![State::Pending]), + None, + ) + .await? + .into_iter() + .map(|p| p.proof) + .collect()) + } + + /// Get reserved [`Proofs`] + #[instrument(skip(self))] + pub async fn get_reserved_proofs(&self) -> Result { + Ok(self + .localstore + .get_proofs( + Some(self.mint_url.clone()), + Some(self.unit), + Some(vec![State::Reserved]), + None, + ) + .await? + .into_iter() + .map(|p| p.proof) + .collect()) + } + + /// Return proofs to unspent allowing them to be selected and spent + #[instrument(skip(self))] + pub async fn unreserve_proofs(&self, ys: Vec) -> Result<(), Error> { + Ok(self.localstore.set_unspent_proofs(ys).await?) + } + + /// Reclaim unspent proofs + /// + /// Checks the stats of [`Proofs`] swapping for a new [`Proof`] if unspent + #[instrument(skip(self, proofs))] + pub async fn reclaim_unspent(&self, proofs: Proofs) -> Result<(), Error> { + let proof_ys = proofs + .iter() + // Find Y for the secret + .map(|p| hash_to_curve(p.secret.as_bytes())) + .collect::, _>>()?; + + let spendable = self + .client + .post_check_state(self.mint_url.clone().try_into()?, proof_ys) + .await? + .states; + + let unspent: Proofs = proofs + .into_iter() + .zip(spendable) + .filter_map(|(p, s)| (s.state == State::Unspent).then_some(p)) + .collect(); + + self.swap(None, SplitTarget::default(), unspent, None, false) + .await?; + + Ok(()) + } + + /// NUT-07 Check the state of a [`Proof`] with the mint + #[instrument(skip(self, proofs))] + pub async fn check_proofs_spent(&self, proofs: Proofs) -> Result, Error> { + let spendable = self + .client + .post_check_state( + self.mint_url.clone().try_into()?, + proofs + .iter() + // Find Y for the secret + .map(|p| hash_to_curve(p.secret.as_bytes())) + .collect::, _>>()?, + ) + .await?; + + Ok(spendable.states) + } + + /// Checks pending proofs for spent status + #[instrument(skip(self))] + pub async fn check_all_pending_proofs(&self) -> Result { + let mut balance = Amount::ZERO; + + let proofs = self + .localstore + .get_proofs( + Some(self.mint_url.clone()), + Some(self.unit), + Some(vec![State::Pending, State::Reserved]), + None, + ) + .await?; + + if proofs.is_empty() { + return Ok(Amount::ZERO); + } + + let states = self + .check_proofs_spent(proofs.clone().into_iter().map(|p| p.proof).collect()) + .await?; + + // Both `State::Pending` and `State::Unspent` should be included in the pending + // table. This is because a proof that has been crated to send will be + // stored in the pending table in order to avoid accidentally double + // spending but to allow it to be explicitly reclaimed + let pending_states: HashSet = states + .into_iter() + .filter(|s| s.state.ne(&State::Spent)) + .map(|s| s.y) + .collect(); + + let (pending_proofs, non_pending_proofs): (Vec, Vec) = proofs + .into_iter() + .partition(|p| pending_states.contains(&p.y)); + + let amount = Amount::try_sum(pending_proofs.iter().map(|p| p.proof.amount))?; + + self.localstore + .update_proofs( + vec![], + non_pending_proofs.into_iter().map(|p| p.y).collect(), + ) + .await?; + + balance += amount; + + Ok(balance) + } + + /// Select proofs to send + #[instrument(skip_all)] + pub async fn select_proofs_to_send( + &self, + amount: Amount, + proofs: Proofs, + include_fees: bool, + ) -> Result { + // TODO: Check all proofs are same unit + + if Amount::try_sum(proofs.iter().map(|p| p.amount))? < amount { + return Err(Error::InsufficientFunds); + } + + let (mut proofs_larger, mut proofs_smaller): (Proofs, Proofs) = + proofs.into_iter().partition(|p| p.amount > amount); + + let next_bigger_proof = proofs_larger.first().cloned(); + + let mut selected_proofs: Proofs = Vec::new(); + let mut remaining_amount = amount; + + while remaining_amount > Amount::ZERO { + proofs_larger.sort(); + // Sort smaller proofs in descending order + proofs_smaller.sort_by(|a: &Proof, b: &Proof| b.cmp(a)); + + let selected_proof = if let Some(next_small) = proofs_smaller.clone().first() { + next_small.clone() + } else if let Some(next_bigger) = proofs_larger.first() { + next_bigger.clone() + } else { + break; + }; + + let proof_amount = selected_proof.amount; + + selected_proofs.push(selected_proof); + + let fees = match include_fees { + true => self.get_proofs_fee(&selected_proofs).await?, + false => Amount::ZERO, + }; + + if proof_amount >= remaining_amount + fees { + remaining_amount = Amount::ZERO; + break; + } + + remaining_amount = amount.checked_add(fees).ok_or(Error::AmountOverflow)? + - Amount::try_sum(selected_proofs.iter().map(|p| p.amount))?; + (proofs_larger, proofs_smaller) = proofs_smaller + .into_iter() + .skip(1) + .partition(|p| p.amount > remaining_amount); + } + + if remaining_amount > Amount::ZERO { + if let Some(next_bigger) = next_bigger_proof { + return Ok(vec![next_bigger.clone()]); + } + + return Err(Error::InsufficientFunds); + } + + Ok(selected_proofs) + } + + /// Select proofs to send + #[instrument(skip_all)] + pub async fn select_proofs_to_swap( + &self, + amount: Amount, + proofs: Proofs, + ) -> Result { + let active_keyset_id = self.get_active_mint_keyset().await?.id; + + let (mut active_proofs, mut inactive_proofs): (Proofs, Proofs) = proofs + .into_iter() + .partition(|p| p.keyset_id == active_keyset_id); + + let mut selected_proofs: Proofs = Vec::new(); + inactive_proofs.sort_by(|a: &Proof, b: &Proof| b.cmp(a)); + + for inactive_proof in inactive_proofs { + selected_proofs.push(inactive_proof); + let selected_total = Amount::try_sum(selected_proofs.iter().map(|p| p.amount))?; + let fees = self.get_proofs_fee(&selected_proofs).await?; + + if selected_total >= amount + fees { + return Ok(selected_proofs); + } + } + + active_proofs.sort_by(|a: &Proof, b: &Proof| b.cmp(a)); + + for active_proof in active_proofs { + selected_proofs.push(active_proof); + let selected_total = Amount::try_sum(selected_proofs.iter().map(|p| p.amount))?; + let fees = self.get_proofs_fee(&selected_proofs).await?; + + if selected_total >= amount + fees { + return Ok(selected_proofs); + } + } + + Err(Error::InsufficientFunds) + } +} diff --git a/crates/cdk/src/wallet/receive.rs b/crates/cdk/src/wallet/receive.rs new file mode 100644 index 000000000..cc28cd0af --- /dev/null +++ b/crates/cdk/src/wallet/receive.rs @@ -0,0 +1,224 @@ +use std::{collections::HashMap, str::FromStr}; + +use bitcoin::hashes::Hash; +use bitcoin::{hashes::sha256::Hash as Sha256Hash, XOnlyPublicKey}; +use tracing::instrument; + +use crate::nuts::nut10::Kind; +use crate::nuts::{Conditions, Token}; +use crate::{ + amount::SplitTarget, + dhke::construct_proofs, + mint_url::MintUrl, + nuts::{Proofs, PublicKey, SecretKey, SigFlag, State}, + types::ProofInfo, + util::hex, + Amount, Error, Wallet, SECP256K1, +}; + +impl Wallet { + /// Receive proofs + #[instrument(skip_all)] + pub async fn receive_proofs( + &self, + proofs: Proofs, + amount_split_target: SplitTarget, + p2pk_signing_keys: &[SecretKey], + preimages: &[String], + ) -> Result { + let mut received_proofs: HashMap = HashMap::new(); + let mint_url = &self.mint_url; + // Add mint if it does not exist in the store + if self + .localstore + .get_mint(self.mint_url.clone()) + .await? + .is_none() + { + tracing::debug!( + "Mint not in localstore fetching info for: {}", + self.mint_url + ); + self.get_mint_info().await?; + } + + let _ = self.get_active_mint_keyset().await?; + + let active_keyset_id = self.get_active_mint_keyset().await?.id; + + let keys = self.get_keyset_keys(active_keyset_id).await?; + + let mut proofs = proofs; + + let mut sig_flag = SigFlag::SigInputs; + + // Map hash of preimage to preimage + let hashed_to_preimage: HashMap = preimages + .iter() + .map(|p| { + let hex_bytes = hex::decode(p)?; + Ok::<(String, &String), Error>((Sha256Hash::hash(&hex_bytes).to_string(), p)) + }) + .collect::, _>>()?; + + let p2pk_signing_keys: HashMap = p2pk_signing_keys + .iter() + .map(|s| (s.x_only_public_key(&SECP256K1).0, s)) + .collect(); + + for proof in &mut proofs { + // Verify that proof DLEQ is valid + if proof.dleq.is_some() { + let keys = self.get_keyset_keys(proof.keyset_id).await?; + let key = keys.amount_key(proof.amount).ok_or(Error::AmountKey)?; + proof.verify_dleq(key)?; + } + + if let Ok(secret) = + >::try_into( + proof.secret.clone(), + ) + { + let conditions: Result = + secret.secret_data.tags.unwrap_or_default().try_into(); + if let Ok(conditions) = conditions { + let mut pubkeys = conditions.pubkeys.unwrap_or_default(); + + match secret.kind { + Kind::P2PK => { + let data_key = PublicKey::from_str(&secret.secret_data.data)?; + + pubkeys.push(data_key); + } + Kind::HTLC => { + let hashed_preimage = &secret.secret_data.data; + let preimage = hashed_to_preimage + .get(hashed_preimage) + .ok_or(Error::PreimageNotProvided)?; + proof.add_preimage(preimage.to_string()); + } + } + for pubkey in pubkeys { + if let Some(signing) = p2pk_signing_keys.get(&pubkey.x_only_public_key()) { + proof.sign_p2pk(signing.to_owned().clone())?; + } + } + + if conditions.sig_flag.eq(&SigFlag::SigAll) { + sig_flag = SigFlag::SigAll; + } + } + } + } + + // Since the proofs are unknown they need to be added to the database + let proofs_info = proofs + .clone() + .into_iter() + .map(|p| ProofInfo::new(p, self.mint_url.clone(), State::Pending, self.unit)) + .collect::, _>>()?; + self.localstore.update_proofs(proofs_info, vec![]).await?; + + let mut pre_swap = self + .create_swap(None, amount_split_target, proofs, None, false) + .await?; + + if sig_flag.eq(&SigFlag::SigAll) { + for blinded_message in &mut pre_swap.swap_request.outputs { + for signing_key in p2pk_signing_keys.values() { + blinded_message.sign_p2pk(signing_key.to_owned().clone())? + } + } + } + + let swap_response = self + .client + .post_swap(mint_url.clone().try_into()?, pre_swap.swap_request) + .await?; + + // Proof to keep + let p = construct_proofs( + swap_response.signatures, + pre_swap.pre_mint_secrets.rs(), + pre_swap.pre_mint_secrets.secrets(), + &keys, + )?; + let mint_proofs = received_proofs.entry(mint_url.clone()).or_default(); + + self.localstore + .increment_keyset_counter(&active_keyset_id, p.len() as u32) + .await?; + + mint_proofs.extend(p); + + let mut total_amount = Amount::ZERO; + for (mint, proofs) in received_proofs { + total_amount += Amount::try_sum(proofs.iter().map(|p| p.amount))?; + let proofs = proofs + .into_iter() + .map(|proof| ProofInfo::new(proof, mint.clone(), State::Unspent, self.unit)) + .collect::, _>>()?; + self.localstore.update_proofs(proofs, vec![]).await?; + } + + Ok(total_amount) + } + + /// Receive + /// # Synopsis + /// ```rust, no_run + /// use std::sync::Arc; + /// + /// use cdk::amount::SplitTarget; + /// use cdk::cdk_database::WalletMemoryDatabase; + /// use cdk::nuts::CurrencyUnit; + /// use cdk::wallet::Wallet; + /// use rand::Rng; + /// + /// #[tokio::main] + /// async fn main() -> anyhow::Result<()> { + /// let seed = rand::thread_rng().gen::<[u8; 32]>(); + /// let mint_url = "https://testnut.cashu.space"; + /// let unit = CurrencyUnit::Sat; + /// + /// let localstore = WalletMemoryDatabase::default(); + /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap(); + /// let token = "cashuAeyJ0b2tlbiI6W3sicHJvb2ZzIjpbeyJhbW91bnQiOjEsInNlY3JldCI6ImI0ZjVlNDAxMDJhMzhiYjg3NDNiOTkwMzU5MTU1MGYyZGEzZTQxNWEzMzU0OTUyN2M2MmM5ZDc5MGVmYjM3MDUiLCJDIjoiMDIzYmU1M2U4YzYwNTMwZWVhOWIzOTQzZmRhMWEyY2U3MWM3YjNmMGNmMGRjNmQ4NDZmYTc2NWFhZjc3OWZhODFkIiwiaWQiOiIwMDlhMWYyOTMyNTNlNDFlIn1dLCJtaW50IjoiaHR0cHM6Ly90ZXN0bnV0LmNhc2h1LnNwYWNlIn1dLCJ1bml0Ijoic2F0In0="; + /// let amount_receive = wallet.receive(token, SplitTarget::default(), &[], &[]).await?; + /// Ok(()) + /// } + /// ``` + #[instrument(skip_all)] + pub async fn receive( + &self, + encoded_token: &str, + amount_split_target: SplitTarget, + p2pk_signing_keys: &[SecretKey], + preimages: &[String], + ) -> Result { + let token_data = Token::from_str(encoded_token)?; + + let unit = token_data.unit().unwrap_or_default(); + + if unit != self.unit { + return Err(Error::UnitUnsupported); + } + + let proofs = token_data.proofs(); + if proofs.len() != 1 { + return Err(Error::MultiMintTokenNotSupported); + } + + let (mint_url, proofs) = proofs.into_iter().next().expect("Token has proofs"); + + if self.mint_url != mint_url { + return Err(Error::IncorrectMint); + } + + let amount = self + .receive_proofs(proofs, amount_split_target, p2pk_signing_keys, preimages) + .await?; + + Ok(amount) + } +} diff --git a/crates/cdk/src/wallet/send.rs b/crates/cdk/src/wallet/send.rs new file mode 100644 index 000000000..a3024bde7 --- /dev/null +++ b/crates/cdk/src/wallet/send.rs @@ -0,0 +1,212 @@ +use tracing::instrument; + +use crate::{ + amount::SplitTarget, + nuts::{Proofs, PublicKey, SpendingConditions, State, Token}, + Amount, Error, Wallet, +}; + +use super::SendKind; + +impl Wallet { + /// Send specific proofs + #[instrument(skip(self))] + pub async fn send_proofs(&self, memo: Option, proofs: Proofs) -> Result { + let ys = proofs + .iter() + .map(|p| p.y()) + .collect::, _>>()?; + self.localstore.reserve_proofs(ys).await?; + + Ok(Token::new( + self.mint_url.clone(), + proofs, + memo, + Some(self.unit), + )) + } + + /// Send + #[instrument(skip(self))] + pub async fn send( + &self, + amount: Amount, + memo: Option, + conditions: Option, + amount_split_target: &SplitTarget, + send_kind: &SendKind, + include_fees: bool, + ) -> Result { + // If online send check mint for current keysets fees + if matches!( + send_kind, + SendKind::OnlineExact | SendKind::OnlineTolerance(_) + ) { + if let Err(e) = self.get_active_mint_keyset().await { + tracing::error!( + "Error fetching active mint keyset: {:?}. Using stored keysets", + e + ); + } + } + + let mint_url = &self.mint_url; + let unit = &self.unit; + let available_proofs = self + .localstore + .get_proofs( + Some(mint_url.clone()), + Some(*unit), + Some(vec![State::Unspent]), + conditions.clone().map(|c| vec![c]), + ) + .await?; + + let (available_proofs, proofs_sum) = available_proofs.into_iter().map(|p| p.proof).fold( + (Vec::new(), Amount::ZERO), + |(mut acc1, mut acc2), p| { + acc2 += p.amount; + acc1.push(p); + (acc1, acc2) + }, + ); + let available_proofs = if proofs_sum < amount { + match &conditions { + Some(conditions) => { + let available_proofs = self + .localstore + .get_proofs( + Some(mint_url.clone()), + Some(*unit), + Some(vec![State::Unspent]), + None, + ) + .await?; + + let available_proofs = available_proofs.into_iter().map(|p| p.proof).collect(); + + let proofs_to_swap = + self.select_proofs_to_swap(amount, available_proofs).await?; + + let proofs_with_conditions = self + .swap( + Some(amount), + SplitTarget::default(), + proofs_to_swap, + Some(conditions.clone()), + include_fees, + ) + .await?; + proofs_with_conditions.ok_or(Error::InsufficientFunds)? + } + None => { + return Err(Error::InsufficientFunds); + } + } + } else { + available_proofs + }; + + let selected = self + .select_proofs_to_send(amount, available_proofs, include_fees) + .await; + + let send_proofs: Proofs = match (send_kind, selected, conditions.clone()) { + // Handle exact matches offline + (SendKind::OfflineExact, Ok(selected_proofs), _) => { + let selected_proofs_amount = + Amount::try_sum(selected_proofs.iter().map(|p| p.amount))?; + + let amount_to_send = match include_fees { + true => amount + self.get_proofs_fee(&selected_proofs).await?, + false => amount, + }; + + if selected_proofs_amount == amount_to_send { + selected_proofs + } else { + return Err(Error::InsufficientFunds); + } + } + + // Handle exact matches + (SendKind::OnlineExact, Ok(selected_proofs), _) => { + let selected_proofs_amount = + Amount::try_sum(selected_proofs.iter().map(|p| p.amount))?; + + let amount_to_send = match include_fees { + true => amount + self.get_proofs_fee(&selected_proofs).await?, + false => amount, + }; + + if selected_proofs_amount == amount_to_send { + selected_proofs + } else { + tracing::info!("Could not select proofs exact while offline."); + tracing::info!("Attempting to select proofs and swapping"); + + self.swap_from_unspent(amount, conditions, include_fees) + .await? + } + } + + // Handle offline tolerance + (SendKind::OfflineTolerance(tolerance), Ok(selected_proofs), _) => { + let selected_proofs_amount = + Amount::try_sum(selected_proofs.iter().map(|p| p.amount))?; + + let amount_to_send = match include_fees { + true => amount + self.get_proofs_fee(&selected_proofs).await?, + false => amount, + }; + if selected_proofs_amount - amount_to_send <= *tolerance { + selected_proofs + } else { + tracing::info!("Selected proofs greater than tolerance. Must swap online"); + return Err(Error::InsufficientFunds); + } + } + + // Handle online tolerance when selection fails and conditions are present + (SendKind::OnlineTolerance(_), Err(_), Some(_)) => { + tracing::info!("Could not select proofs with conditions while offline."); + tracing::info!("Attempting to select proofs without conditions and swapping"); + + self.swap_from_unspent(amount, conditions, include_fees) + .await? + } + + // Handle online tolerance with successful selection + (SendKind::OnlineTolerance(tolerance), Ok(selected_proofs), _) => { + let selected_proofs_amount = + Amount::try_sum(selected_proofs.iter().map(|p| p.amount))?; + let amount_to_send = match include_fees { + true => amount + self.get_proofs_fee(&selected_proofs).await?, + false => amount, + }; + if selected_proofs_amount - amount_to_send <= *tolerance { + selected_proofs + } else { + tracing::info!("Could not select proofs while offline. Attempting swap"); + self.swap_from_unspent(amount, conditions, include_fees) + .await? + } + } + + // Handle all other cases where selection fails + ( + SendKind::OfflineExact + | SendKind::OnlineExact + | SendKind::OfflineTolerance(_) + | SendKind::OnlineTolerance(_), + Err(_), + _, + ) => { + tracing::debug!("Could not select proofs"); + return Err(Error::InsufficientFunds); + } + }; + + self.send_proofs(memo, send_proofs).await + } +} diff --git a/crates/cdk/src/wallet/swap.rs b/crates/cdk/src/wallet/swap.rs new file mode 100644 index 000000000..7f59866ae --- /dev/null +++ b/crates/cdk/src/wallet/swap.rs @@ -0,0 +1,308 @@ +use tracing::instrument; + +use crate::amount::SplitTarget; +use crate::dhke::construct_proofs; +use crate::nuts::nut10; +use crate::nuts::PreMintSecrets; +use crate::nuts::PreSwap; +use crate::nuts::Proofs; +use crate::nuts::PublicKey; +use crate::nuts::SpendingConditions; +use crate::nuts::State; +use crate::nuts::SwapRequest; +use crate::types::ProofInfo; +use crate::Amount; +use crate::Error; +use crate::Wallet; + +impl Wallet { + /// Swap + #[instrument(skip(self, input_proofs))] + pub async fn swap( + &self, + amount: Option, + amount_split_target: SplitTarget, + input_proofs: Proofs, + spending_conditions: Option, + include_fees: bool, + ) -> Result, Error> { + let mint_url = &self.mint_url; + let unit = &self.unit; + + let pre_swap = self + .create_swap( + amount, + amount_split_target, + input_proofs.clone(), + spending_conditions.clone(), + include_fees, + ) + .await?; + + let swap_response = self + .client + .post_swap(mint_url.clone().try_into()?, pre_swap.swap_request) + .await?; + + let active_keyset_id = pre_swap.pre_mint_secrets.keyset_id; + + let active_keys = self + .localstore + .get_keys(&active_keyset_id) + .await? + .ok_or(Error::NoActiveKeyset)?; + + let post_swap_proofs = construct_proofs( + swap_response.signatures, + pre_swap.pre_mint_secrets.rs(), + pre_swap.pre_mint_secrets.secrets(), + &active_keys, + )?; + + self.localstore + .increment_keyset_counter(&active_keyset_id, pre_swap.derived_secret_count) + .await?; + + let mut added_proofs = Vec::new(); + let change_proofs; + let send_proofs; + match amount { + Some(amount) => { + let (proofs_with_condition, proofs_without_condition): (Proofs, Proofs) = + post_swap_proofs.into_iter().partition(|p| { + let nut10_secret: Result = p.secret.clone().try_into(); + + nut10_secret.is_ok() + }); + + let (proofs_to_send, proofs_to_keep) = match spending_conditions { + Some(_) => (proofs_with_condition, proofs_without_condition), + None => { + let mut all_proofs = proofs_without_condition; + all_proofs.reverse(); + + let mut proofs_to_send: Proofs = Vec::new(); + let mut proofs_to_keep = Vec::new(); + + for proof in all_proofs { + let proofs_to_send_amount = + Amount::try_sum(proofs_to_send.iter().map(|p| p.amount))?; + if proof.amount + proofs_to_send_amount <= amount + pre_swap.fee { + proofs_to_send.push(proof); + } else { + proofs_to_keep.push(proof); + } + } + + (proofs_to_send, proofs_to_keep) + } + }; + + let send_amount = Amount::try_sum(proofs_to_send.iter().map(|p| p.amount))?; + + if send_amount.ne(&(amount + pre_swap.fee)) { + tracing::warn!( + "Send amount proofs is {:?} expected {:?}", + send_amount, + amount + ); + } + + let send_proofs_info = proofs_to_send + .clone() + .into_iter() + .map(|proof| ProofInfo::new(proof, mint_url.clone(), State::Reserved, *unit)) + .collect::, _>>()?; + added_proofs = send_proofs_info; + + change_proofs = proofs_to_keep; + send_proofs = Some(proofs_to_send); + } + None => { + change_proofs = post_swap_proofs; + send_proofs = None; + } + } + + let keep_proofs = change_proofs + .into_iter() + .map(|proof| ProofInfo::new(proof, mint_url.clone(), State::Unspent, *unit)) + .collect::, _>>()?; + added_proofs.extend(keep_proofs); + + // Remove spent proofs used as inputs + let deleted_ys = input_proofs + .into_iter() + .map(|proof| proof.y()) + .collect::, _>>()?; + + self.localstore + .update_proofs(added_proofs, deleted_ys) + .await?; + Ok(send_proofs) + } + + /// Swap from unspent proofs in db + #[instrument(skip(self))] + pub async fn swap_from_unspent( + &self, + amount: Amount, + conditions: Option, + include_fees: bool, + ) -> Result { + let available_proofs = self + .localstore + .get_proofs( + Some(self.mint_url.clone()), + Some(self.unit), + Some(vec![State::Unspent]), + None, + ) + .await?; + + let (available_proofs, proofs_sum) = available_proofs.into_iter().map(|p| p.proof).fold( + (Vec::new(), Amount::ZERO), + |(mut acc1, mut acc2), p| { + acc2 += p.amount; + acc1.push(p); + (acc1, acc2) + }, + ); + + if proofs_sum < amount { + return Err(Error::InsufficientFunds); + } + + let proofs = self.select_proofs_to_swap(amount, available_proofs).await?; + + self.swap( + Some(amount), + SplitTarget::default(), + proofs, + conditions, + include_fees, + ) + .await? + .ok_or(Error::InsufficientFunds) + } + + /// Create Swap Payload + #[instrument(skip(self, proofs))] + pub async fn create_swap( + &self, + amount: Option, + amount_split_target: SplitTarget, + proofs: Proofs, + spending_conditions: Option, + include_fees: bool, + ) -> Result { + let active_keyset_id = self.get_active_mint_keyset().await?.id; + + // Desired amount is either amount passed or value of all proof + let proofs_total = Amount::try_sum(proofs.iter().map(|p| p.amount))?; + + let ys: Vec = proofs.iter().map(|p| p.y()).collect::>()?; + self.localstore.set_pending_proofs(ys).await?; + + let fee = self.get_proofs_fee(&proofs).await?; + + let change_amount: Amount = proofs_total - amount.unwrap_or(Amount::ZERO) - fee; + + let (send_amount, change_amount) = match include_fees { + true => { + let split_count = amount + .unwrap_or(Amount::ZERO) + .split_targeted(&SplitTarget::default()) + .unwrap() + .len(); + + let fee_to_redeem = self + .get_keyset_count_fee(&active_keyset_id, split_count as u64) + .await?; + + ( + amount.map(|a| a + fee_to_redeem), + change_amount - fee_to_redeem, + ) + } + false => (amount, change_amount), + }; + + // If a non None split target is passed use that + // else use state refill + let change_split_target = match amount_split_target { + SplitTarget::None => self.determine_split_target_values(change_amount).await?, + s => s, + }; + + let derived_secret_count; + + let count = self + .localstore + .get_keyset_counter(&active_keyset_id) + .await?; + + let mut count = count.map_or(0, |c| c + 1); + + let (mut desired_messages, change_messages) = match spending_conditions { + Some(conditions) => { + let change_premint_secrets = PreMintSecrets::from_xpriv( + active_keyset_id, + count, + self.xpriv, + change_amount, + &change_split_target, + )?; + + derived_secret_count = change_premint_secrets.len(); + + ( + PreMintSecrets::with_conditions( + active_keyset_id, + send_amount.unwrap_or(Amount::ZERO), + &SplitTarget::default(), + &conditions, + )?, + change_premint_secrets, + ) + } + None => { + let premint_secrets = PreMintSecrets::from_xpriv( + active_keyset_id, + count, + self.xpriv, + send_amount.unwrap_or(Amount::ZERO), + &SplitTarget::default(), + )?; + + count += premint_secrets.len() as u32; + + let change_premint_secrets = PreMintSecrets::from_xpriv( + active_keyset_id, + count, + self.xpriv, + change_amount, + &change_split_target, + )?; + + derived_secret_count = change_premint_secrets.len() + premint_secrets.len(); + + (premint_secrets, change_premint_secrets) + } + }; + + // Combine the BlindedMessages totaling the desired amount with change + desired_messages.combine(change_messages); + // Sort the premint secrets to avoid finger printing + desired_messages.sort_secrets(); + + let swap_request = SwapRequest::new(proofs, desired_messages.blinded_messages()); + + Ok(PreSwap { + pre_mint_secrets: desired_messages, + swap_request, + derived_secret_count: derived_secret_count as u32, + fee, + }) + } +}