//! Cashu Wallet use std::collections::{HashMap, HashSet}; use std::str::FromStr; use bip39::Mnemonic; use cashu::dhke::{construct_proofs, unblind_message}; #[cfg(feature = "nut07")] use cashu::nuts::nut00::mint; use cashu::nuts::{ BlindedSignature, CurrencyUnit, Id, KeySetInfo, Keys, PreMintSecrets, PreSwap, Proof, Proofs, SwapRequest, Token, }; #[cfg(feature = "nut07")] use cashu::types::ProofsStatus; use cashu::types::{MeltQuote, Melted, MintQuote, SendProofs}; use cashu::url::UncheckedUrl; use cashu::{Amount, Bolt11Invoice}; use thiserror::Error; use tracing::warn; use crate::client::Client; use crate::localstore::LocalStore; use crate::utils::unix_time; #[derive(Debug, Error)] pub enum Error { /// Insufficient Funds #[error("Insufficient Funds")] InsufficientFunds, #[error("`{0}`")] Cashu(#[from] cashu::error::wallet::Error), #[error("`{0}`")] Client(#[from] crate::client::Error), /// Cashu Url Error #[error("`{0}`")] CashuUrl(#[from] cashu::url::Error), #[error("Quote Expired")] QuoteExpired, #[error("Quote Unknown")] QuoteUnknown, #[error("`{0}`")] LocalStore(#[from] super::localstore::Error), #[error("`{0}`")] Custom(String), } #[derive(Clone, Debug)] pub struct BackupInfo { mnemonic: Mnemonic, counter: HashMap, } #[derive(Clone, Debug)] pub struct Wallet { pub client: C, localstore: L, backup_info: Option, } impl Wallet { pub async fn new( client: C, localstore: L, mint_quotes: Vec, melt_quotes: Vec, backup_info: Option, mint_keys: Vec, ) -> Self { for quote in mint_quotes { localstore.add_mint_quote(quote).await.ok(); } for quote in melt_quotes { localstore.add_melt_quote(quote).await.ok(); } for keys in mint_keys { localstore.add_keys(keys).await.ok(); } Self { backup_info, client, localstore, } } /// Back up seed pub fn mnemonic(&self) -> Option { self.backup_info.clone().map(|b| b.mnemonic) } /// Back up keyset counters pub fn keyset_counters(&self) -> Option> { self.backup_info.clone().map(|b| b.counter) } /// Check if a proof is spent #[cfg(feature = "nut07")] pub async fn check_proofs_spent( &self, mint_url: UncheckedUrl, proofs: Proofs, ) -> Result { let spendable = self .client .post_check_spendable( mint_url.try_into()?, proofs .clone() .into_iter() .map(|p| p.into()) .collect::() .clone(), ) .await?; // Separate proofs in spent and unspent based on mint response let (spendable, spent): (Vec<_>, Vec<_>) = proofs .iter() .zip(spendable.spendable.iter()) .partition(|(_, &b)| b); Ok(ProofsStatus { spendable: spendable.into_iter().map(|(s, _)| s).cloned().collect(), spent: spent.into_iter().map(|(s, _)| s).cloned().collect(), }) } /* // TODO: This should be create token // the requited proofs for the token amount may already be in the wallet and mint is not needed // Mint a token pub async fn mint_token( &mut self, mint_url: UncheckedUrl, amount: Amount, memo: Option, unit: Option, ) -> Result { let quote = self .mint_quote( mint_url.clone(), amount, unit.clone() .ok_or(Error::Custom("Unit required".to_string()))?, ) .await?; let proofs = self.mint(mint_url.clone(), "e.id).await?; let token = Token::new(mint_url.clone(), proofs, memo, unit); Ok(token?) } */ /// Mint Quote pub async fn mint_quote( &mut self, mint_url: UncheckedUrl, amount: Amount, unit: CurrencyUnit, ) -> Result { let quote_res = self .client .post_mint_quote(mint_url.try_into()?, amount, unit.clone()) .await?; let quote = MintQuote { id: quote_res.quote.clone(), amount, unit: unit.clone(), request: Bolt11Invoice::from_str("e_res.request).unwrap(), paid: quote_res.paid, expiry: quote_res.expiry, }; self.localstore.add_mint_quote(quote.clone()).await?; Ok(quote) } async fn active_mint_keyset( &mut self, mint_url: &UncheckedUrl, unit: &CurrencyUnit, ) -> Result, Error> { if let Some(keysets) = self.localstore.get_mint_keysets(mint_url.clone()).await? { for keyset in keysets { if keyset.unit.eq(unit) && keyset.active { return Ok(Some(keyset.id)); } } } else { let keysets = self.client.get_mint_keysets(mint_url.try_into()?).await?; self.localstore .add_mint_keysets(mint_url.clone(), keysets.keysets.into_iter().collect()) .await?; } Ok(None) } async fn active_keys( &mut self, mint_url: &UncheckedUrl, unit: &CurrencyUnit, ) -> Result, Error> { let active_keyset_id = self.active_mint_keyset(mint_url, unit).await?.unwrap(); let mut keys = None; if let Some(k) = self.localstore.get_keys(&active_keyset_id).await? { keys = Some(k.clone()) } else { let keysets = self.client.get_mint_keys(mint_url.try_into()?).await?; for keyset in keysets { if keyset.id.eq(&active_keyset_id) { keys = Some(keyset.keys.clone()) } self.localstore.add_keys(keyset.keys).await?; } } Ok(keys) } /// Mint pub async fn mint(&mut self, mint_url: UncheckedUrl, quote_id: &str) -> Result { 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()) { return Err(Error::QuoteExpired); } quote.clone() } else { return Err(Error::QuoteUnknown); }; let active_keyset_id = self .active_mint_keyset(&mint_url, "e_info.unit) .await? .unwrap(); let premint_secrets = match &self.backup_info { Some(backup_info) => PreMintSecrets::from_seed( active_keyset_id, *backup_info.counter.get(&active_keyset_id).unwrap_or(&0), &backup_info.mnemonic, quote_info.amount, )?, None => PreMintSecrets::random(active_keyset_id, quote_info.amount)?, }; let mint_res = self .client .post_mint( mint_url.clone().try_into()?, quote_id, premint_secrets.clone(), ) .await?; let keys = self.localstore.get_keys(&active_keyset_id).await?.unwrap(); let proofs = construct_proofs( mint_res.signatures, premint_secrets.rs(), premint_secrets.secrets(), &keys, )?; let minted_amount = proofs.iter().map(|p| p.amount).sum(); // Remove filled quote from store self.localstore.remove_mint_quote("e_info.id).await?; // Add new proofs to store self.localstore.add_proofs(mint_url, proofs).await?; Ok(minted_amount) } /// Receive pub async fn receive(&mut self, encoded_token: &str) -> Result<(), Error> { let token_data = Token::from_str(encoded_token)?; let unit = token_data.unit.unwrap_or_default(); let mut proofs: HashMap = HashMap::new(); for token in token_data.token { if token.proofs.is_empty() { continue; } let active_keyset_id = self.active_mint_keyset(&token.mint, &unit).await?; // TODO: if none fetch keyset for mint let keys = self.localstore.get_keys(&active_keyset_id.unwrap()).await?; // Sum amount of all proofs let amount: Amount = token.proofs.iter().map(|p| p.amount).sum(); let pre_swap = self .create_swap(&token.mint, &unit, Some(amount), token.proofs) .await?; let swap_response = self .client .post_split(token.mint.clone().try_into()?, pre_swap.split_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.unwrap(), )?; let mint_proofs = proofs.entry(token.mint).or_default(); mint_proofs.extend(p); } for (mint, proofs) in proofs { self.localstore.add_proofs(mint, proofs).await?; } Ok(()) } /// Create Split Payload async fn create_swap( &mut self, mint_url: &UncheckedUrl, unit: &CurrencyUnit, amount: Option, proofs: Proofs, ) -> Result { // Since split is used to get the needed combination of tokens for a specific // amount first blinded messages are created for the amount let active_keyset_id = self.active_mint_keyset(mint_url, unit).await?.unwrap(); let pre_mint_secrets = if let Some(amount) = amount { let mut desired_messages = PreMintSecrets::random(active_keyset_id, amount)?; let change_amount = proofs.iter().map(|p| p.amount).sum::() - amount; let change_messages = PreMintSecrets::random(active_keyset_id, change_amount)?; // Combine the BlindedMessages totoalling the desired amount with change desired_messages.combine(change_messages); // Sort the premint secrets to avoid finger printing desired_messages.sort_secrets(); desired_messages } else { let value = proofs.iter().map(|p| p.amount).sum(); PreMintSecrets::random(active_keyset_id, value)? }; let split_request = SwapRequest::new(proofs, pre_mint_secrets.blinded_messages()); Ok(PreSwap { pre_mint_secrets, split_request, }) } pub async fn process_swap_response( &self, blinded_messages: PreMintSecrets, promises: Vec, ) -> Result { let mut proofs = vec![]; for (promise, premint) in promises.iter().zip(blinded_messages) { let a = self .localstore .get_keys(&promise.keyset_id) .await? .unwrap() .amount_key(promise.amount) .unwrap() .to_owned(); let blinded_c = promise.c.clone(); let unblinded_sig = unblind_message(blinded_c, premint.r.into(), a).unwrap(); let proof = Proof { keyset_id: promise.keyset_id, amount: promise.amount, secret: premint.secret, c: unblinded_sig, }; proofs.push(proof); } Ok(proofs) } /// Send pub async fn send( &mut self, mint_url: &UncheckedUrl, unit: &CurrencyUnit, amount: Amount, proofs: Proofs, ) -> Result { let amount_available: Amount = proofs.iter().map(|p| p.amount).sum(); if amount_available.lt(&amount) { println!("Not enough funds"); return Err(Error::InsufficientFunds); } let pre_swap = self .create_swap(mint_url, unit, Some(amount), proofs) .await?; let swap_response = self .client .post_split(mint_url.clone().try_into()?, pre_swap.split_request) .await?; let mut keep_proofs = Proofs::new(); let mut send_proofs = Proofs::new(); let mut proofs = construct_proofs( swap_response.signatures, pre_swap.pre_mint_secrets.rs(), pre_swap.pre_mint_secrets.secrets(), &self.active_keys(mint_url, unit).await?.unwrap(), )?; proofs.reverse(); for proof in proofs { if (proof.amount + send_proofs.iter().map(|p| p.amount).sum()).gt(&amount) { keep_proofs.push(proof); } else { send_proofs.push(proof); } } // println!("Send Proofs: {:#?}", send_proofs); // println!("Keep Proofs: {:#?}", keep_proofs); let send_amount: Amount = send_proofs.iter().map(|p| p.amount).sum(); if send_amount.ne(&amount) { warn!( "Send amount proofs is {:?} expected {:?}", send_amount, amount ); } Ok(SendProofs { change_proofs: keep_proofs, send_proofs, }) } /// Melt Quote pub async fn melt_quote( &mut self, mint_url: UncheckedUrl, unit: CurrencyUnit, request: Bolt11Invoice, ) -> Result { let quote_res = self .client .post_melt_quote(mint_url.clone().try_into()?, unit.clone(), request.clone()) .await?; let quote = MeltQuote { id: quote_res.quote, amount: quote_res.amount.into(), request, unit, fee_reserve: quote_res.fee_reserve.into(), paid: quote_res.paid, expiry: quote_res.expiry, }; self.localstore.add_melt_quote(quote.clone()).await?; Ok(quote) } // Select proofs async fn select_proofs( &self, mint_url: UncheckedUrl, unit: &CurrencyUnit, amount: Amount, ) -> Result { let mint_proofs = self .localstore .get_proofs(mint_url.clone()) .await? .ok_or(Error::InsufficientFunds)?; let mint_keysets = self.localstore.get_mint_keysets(mint_url).await?.unwrap(); let (active, inactive): (HashSet, HashSet) = mint_keysets .into_iter() .filter(|p| p.unit.eq(unit)) .partition(|x| x.active); let active: HashSet = active.iter().map(|k| k.id).collect(); let inactive: HashSet = inactive.iter().map(|k| k.id).collect(); let mut active_proofs: Proofs = Vec::new(); let mut inactive_proofs: Proofs = Vec::new(); for proof in mint_proofs { if active.contains(&proof.keyset_id) { active_proofs.push(proof); } else if inactive.contains(&proof.keyset_id) { inactive_proofs.push(proof); } } active_proofs.reverse(); inactive_proofs.reverse(); inactive_proofs.append(&mut active_proofs); let proofs = inactive_proofs; let mut selected_proofs: Proofs = Vec::new(); for proof in proofs { if selected_proofs.iter().map(|p| p.amount).sum::() < amount { selected_proofs.push(proof); } } if selected_proofs.iter().map(|p| p.amount).sum::() < amount { return Err(Error::InsufficientFunds); } Ok(selected_proofs) } /// Melt pub async fn melt(&mut self, mint_url: &UncheckedUrl, 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::QuoteExpired); } quote.clone() } else { return Err(Error::QuoteUnknown); }; let blinded = PreMintSecrets::blank( self.active_mint_keyset(mint_url, "e_info.unit) .await? .unwrap(), quote_info.fee_reserve, )?; let proofs = self .select_proofs(mint_url.clone(), "e_info.unit, quote_info.amount) .await?; let melt_response = self .client .post_melt( mint_url.clone().try_into()?, quote_id.to_string(), proofs, Some(blinded.blinded_messages()), ) .await?; let change_proofs = match melt_response.change { Some(change) => Some(construct_proofs( change, blinded.rs(), blinded.secrets(), &self.active_keys(mint_url, "e_info.unit).await?.unwrap(), )?), None => None, }; let melted = Melted { paid: true, preimage: melt_response.payment_preimage, change: change_proofs, }; self.localstore.remove_melt_quote("e_info.id).await?; Ok(melted) } pub fn proofs_to_token( &self, mint_url: UncheckedUrl, proofs: Proofs, memo: Option, unit: Option, ) -> Result { Ok(Token::new(mint_url, proofs, memo, unit)?.to_string()) } } /* #[cfg(test)] mod tests { use std::collections::{HashMap, HashSet}; use super::*; use crate::client::Client; use crate::mint::Mint; use cashu::nuts::nut04; #[test] fn test_wallet() { let mut mint = Mint::new( "supersecretsecret", "0/0/0/0", HashMap::new(), HashSet::new(), 32, ); let keys = mint.active_keyset_pubkeys(); let client = Client::new("https://cashu-rs.thesimplekid.space/").unwrap(); let wallet = Wallet::new(client, keys.keys); let blinded_messages = BlindedMessages::random(Amount::from_sat(64)).unwrap(); let mint_request = nut04::MintRequest { outputs: blinded_messages.blinded_messages.clone(), }; let res = mint.process_mint_request(mint_request).unwrap(); let proofs = wallet .process_split_response(blinded_messages, res.promises) .unwrap(); for proof in &proofs { mint.verify_proof(proof).unwrap(); } let split = wallet.create_split(proofs.clone()).unwrap(); let split_request = split.split_payload; let split_response = mint.process_split_request(split_request).unwrap(); let p = split_response.promises; let snd_proofs = wallet .process_split_response(split.blinded_messages, p.unwrap()) .unwrap(); let mut error = false; for proof in &snd_proofs { if let Err(err) = mint.verify_proof(proof) { println!("{err}{:?}", serde_json::to_string(proof)); error = true; } } if error { panic!() } } } */