diff --git a/crates/cashu/src/amount.rs b/crates/cashu/src/amount.rs index 5c29a870..a5e59170 100644 --- a/crates/cashu/src/amount.rs +++ b/crates/cashu/src/amount.rs @@ -49,6 +49,9 @@ impl Amount { /// Amount zero pub const ZERO: Amount = Amount(0); + // Amount one + pub const ONE: Amount = Amount(1); + /// Split into parts that are powers of two pub fn split(&self) -> Vec { let sats = self.0; @@ -119,6 +122,27 @@ impl Amount { Ok(parts) } + /// Splits amount into powers of two while accounting for the swap fee + pub fn split_with_fee(&self, fee_ppk: u64) -> Result, Error> { + let without_fee_amounts = self.split(); + let fee_ppk = fee_ppk * without_fee_amounts.len() as u64; + let fee = Amount::from((fee_ppk + 999) / 1000); + let new_amount = self.checked_add(fee).ok_or(Error::AmountOverflow)?; + + let split = new_amount.split(); + let split_fee_ppk = split.len() as u64 * fee_ppk; + let split_fee = Amount::from((split_fee_ppk + 999) / 1000); + + if let Some(net_amount) = new_amount.checked_sub(split_fee) { + if net_amount >= *self { + return Ok(split); + } + } + self.checked_add(Amount::ONE) + .ok_or(Error::AmountOverflow)? + .split_with_fee(fee_ppk) + } + /// Checked addition for Amount. Returns None if overflow occurs. pub fn checked_add(self, other: Amount) -> Option { self.0.checked_add(other.0).map(Amount) @@ -129,6 +153,16 @@ impl Amount { self.0.checked_sub(other.0).map(Amount) } + /// Checked multiplication for Amount. Returns None if overflow occurs. + pub fn checked_mul(self, other: Amount) -> Option { + self.0.checked_mul(other.0).map(Amount) + } + + /// Checked division for Amount. Returns None if overflow occurs. + pub fn checked_div(self, other: Amount) -> Option { + self.0.checked_div(other.0).map(Amount) + } + /// Try sum to check for overflow pub fn try_sum(iter: I) -> Result where @@ -334,6 +368,27 @@ mod tests { ); } + #[test] + fn test_split_with_fee() { + let amount = Amount(2); + let fee_ppk = 1; + + let split = amount.split_with_fee(fee_ppk).unwrap(); + assert_eq!(split, vec![Amount(2), Amount(1)]); + + let amount = Amount(3); + let fee_ppk = 1; + + let split = amount.split_with_fee(fee_ppk).unwrap(); + assert_eq!(split, vec![Amount(4)]); + + let amount = Amount(3); + let fee_ppk = 1000; + + let split = amount.split_with_fee(fee_ppk).unwrap(); + assert_eq!(split, vec![Amount(32)]); + } + #[test] fn test_split_values() { let amount = Amount(10); diff --git a/crates/cashu/src/nuts/nut00/mod.rs b/crates/cashu/src/nuts/nut00/mod.rs index 6490f908..ddd2b9fb 100644 --- a/crates/cashu/src/nuts/nut00/mod.rs +++ b/crates/cashu/src/nuts/nut00/mod.rs @@ -3,6 +3,7 @@ //! use std::cmp::Ordering; +use std::collections::{HashMap, HashSet}; use std::fmt; use std::hash::{Hash, Hasher}; use std::str::FromStr; @@ -38,6 +39,12 @@ pub type Proofs = Vec; /// Utility methods for [Proofs] pub trait ProofsMethods { + /// Count proofs by keyset + fn count_by_keyset(&self) -> HashMap; + + /// Sum proofs by keyset + fn sum_by_keyset(&self) -> HashMap; + /// Try to sum up the amounts of all [Proof]s fn total_amount(&self) -> Result; @@ -46,17 +53,65 @@ pub trait ProofsMethods { } impl ProofsMethods for Proofs { + fn count_by_keyset(&self) -> HashMap { + count_by_keyset(self.iter()) + } + + fn sum_by_keyset(&self) -> HashMap { + sum_by_keyset(self.iter()) + } + fn total_amount(&self) -> Result { - Amount::try_sum(self.iter().map(|p| p.amount)).map_err(Into::into) + total_amount(self.iter()) } fn ys(&self) -> Result, Error> { - self.iter() - .map(|p| p.y()) - .collect::, _>>() + ys(self.iter()) } } +impl ProofsMethods for HashSet { + fn count_by_keyset(&self) -> HashMap { + count_by_keyset(self.iter()) + } + + fn sum_by_keyset(&self) -> HashMap { + sum_by_keyset(self.iter()) + } + + fn total_amount(&self) -> Result { + total_amount(self.iter()) + } + + fn ys(&self) -> Result, Error> { + ys(self.iter()) + } +} + +fn count_by_keyset<'a, I: Iterator>(proofs: I) -> HashMap { + let mut counts = HashMap::new(); + for proof in proofs { + *counts.entry(proof.keyset_id).or_insert(0) += 1; + } + counts +} + +fn sum_by_keyset<'a, I: Iterator>(proofs: I) -> HashMap { + let mut sums = HashMap::new(); + for proof in proofs { + *sums.entry(proof.keyset_id).or_insert(Amount::ZERO) += proof.amount; + } + sums +} + +fn total_amount<'a, I: Iterator>(proofs: I) -> Result { + Amount::try_sum(proofs.map(|p| p.amount)).map_err(Into::into) +} + +fn ys<'a, I: Iterator>(proofs: I) -> Result, Error> { + proofs.map(|p| p.y()).collect::, _>>() +} + /// NUT00 Error #[derive(Debug, Error)] pub enum Error { @@ -272,6 +327,11 @@ impl Proof { } } + /// Check if proof is in active keyset `Id`s + pub fn is_active(&self, active_keyset_ids: &[Id]) -> bool { + active_keyset_ids.contains(&self.keyset_id) + } + /// Get y from proof /// /// Where y is `hash_to_curve(secret)` diff --git a/crates/cashu/src/nuts/nut01/public_key.rs b/crates/cashu/src/nuts/nut01/public_key.rs index 23e2f1de..f8a010ca 100644 --- a/crates/cashu/src/nuts/nut01/public_key.rs +++ b/crates/cashu/src/nuts/nut01/public_key.rs @@ -12,13 +12,19 @@ use super::Error; use crate::SECP256K1; /// PublicKey -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] pub struct PublicKey { #[cfg_attr(feature = "swagger", schema(value_type = String))] inner: secp256k1::PublicKey, } +impl fmt::Debug for PublicKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "PublicKey({})", self.to_hex()) + } +} + impl Deref for PublicKey { type Target = secp256k1::PublicKey; diff --git a/crates/cashu/src/nuts/nut07.rs b/crates/cashu/src/nuts/nut07.rs index 11dbf6ec..da891f70 100644 --- a/crates/cashu/src/nuts/nut07.rs +++ b/crates/cashu/src/nuts/nut07.rs @@ -31,10 +31,12 @@ pub enum State { /// /// Currently being used in a transaction i.e. melt in progress Pending, - /// Proof is reserved + /// Reserved /// - /// i.e. used to create a token + /// Proof is reserved for future token creation Reserved, + /// Pending spent (i.e., spent but not yet swapped by receiver) + PendingSpent, } impl fmt::Display for State { @@ -44,6 +46,7 @@ impl fmt::Display for State { Self::Unspent => "UNSPENT", Self::Pending => "PENDING", Self::Reserved => "RESERVED", + Self::PendingSpent => "PENDING_SPENT", }; write!(f, "{}", s) @@ -59,6 +62,7 @@ impl FromStr for State { "UNSPENT" => Ok(Self::Unspent), "PENDING" => Ok(Self::Pending), "RESERVED" => Ok(Self::Reserved), + "PENDING_SPENT" => Ok(Self::PendingSpent), _ => Err(Error::UnknownState), } } diff --git a/crates/cashu/src/wallet.rs b/crates/cashu/src/wallet.rs index 781bc9f7..ea70045f 100644 --- a/crates/cashu/src/wallet.rs +++ b/crates/cashu/src/wallet.rs @@ -85,3 +85,25 @@ pub enum SendKind { /// Wallet must remain offline but can over pay if below tolerance OfflineTolerance(Amount), } + +impl SendKind { + /// Check if send kind is online + pub fn is_online(&self) -> bool { + matches!(self, Self::OnlineExact | Self::OnlineTolerance(_)) + } + + /// Check if send kind is offline + pub fn is_offline(&self) -> bool { + matches!(self, Self::OfflineExact | Self::OfflineTolerance(_)) + } + + /// Check if send kind is exact + pub fn is_exact(&self) -> bool { + matches!(self, Self::OnlineExact | Self::OfflineExact) + } + + /// Check if send kind has tolerance + pub fn has_tolerance(&self) -> bool { + matches!(self, Self::OnlineTolerance(_) | Self::OfflineTolerance(_)) + } +} diff --git a/crates/cdk-cli/src/sub_commands/pay_request.rs b/crates/cdk-cli/src/sub_commands/pay_request.rs index a003072c..e563dc97 100644 --- a/crates/cdk-cli/src/sub_commands/pay_request.rs +++ b/crates/cdk-cli/src/sub_commands/pay_request.rs @@ -1,10 +1,9 @@ use std::io::{self, Write}; use anyhow::{anyhow, Result}; -use cdk::amount::SplitTarget; use cdk::nuts::nut18::TransportType; use cdk::nuts::{PaymentRequest, PaymentRequestPayload}; -use cdk::wallet::{MultiMintWallet, SendKind}; +use cdk::wallet::{MultiMintWallet, SendOptions}; use clap::Args; use nostr_sdk::nips::nip19::Nip19Profile; use nostr_sdk::{Client as NostrClient, EventBuilder, FromBech32, Keys}; @@ -81,17 +80,16 @@ pub async fn pay_request( }) .ok_or(anyhow!("No supported transport method found"))?; - let proofs = matching_wallet - .send( + let prepared_send = matching_wallet + .prepare_send( amount, - None, - None, - &SplitTarget::default(), - &SendKind::default(), - true, + SendOptions { + include_fee: true, + ..Default::default() + }, ) - .await? - .proofs(); + .await?; + let proofs = matching_wallet.send(prepared_send, None).await?.proofs(); let payload = PaymentRequestPayload { id: payment_request.payment_id.clone(), diff --git a/crates/cdk-cli/src/sub_commands/send.rs b/crates/cdk-cli/src/sub_commands/send.rs index 0a642539..643f8d32 100644 --- a/crates/cdk-cli/src/sub_commands/send.rs +++ b/crates/cdk-cli/src/sub_commands/send.rs @@ -3,10 +3,9 @@ use std::io::Write; use std::str::FromStr; use anyhow::{bail, Result}; -use cdk::amount::SplitTarget; use cdk::nuts::{Conditions, CurrencyUnit, PublicKey, SpendingConditions}; use cdk::wallet::types::{SendKind, WalletKey}; -use cdk::wallet::MultiMintWallet; +use cdk::wallet::{MultiMintWallet, SendMemo, SendOptions}; use cdk::Amount; use clap::Args; @@ -170,16 +169,22 @@ pub async fn send( (false, None) => SendKind::OnlineExact, }; - let token = wallet - .send( + let prepared_send = wallet + .prepare_send( token_amount, - sub_command_args.memo.clone(), - conditions, - &SplitTarget::default(), - &send_kind, - sub_command_args.include_fee, + SendOptions { + memo: sub_command_args.memo.clone().map(|memo| SendMemo { + memo, + include_memo: true, + }), + send_kind, + include_fee: sub_command_args.include_fee, + conditions, + ..Default::default() + }, ) .await?; + let token = wallet.send(prepared_send, None).await?; match sub_command_args.v3 { true => { diff --git a/crates/cdk-common/src/database/wallet.rs b/crates/cdk-common/src/database/wallet.rs index f3662542..d8166415 100644 --- a/crates/cdk-common/src/database/wallet.rs +++ b/crates/cdk-common/src/database/wallet.rs @@ -84,14 +84,6 @@ pub trait Database: Debug { added: Vec, removed_ys: Vec, ) -> Result<(), Self::Err>; - /// Set proofs as pending in storage. Proofs are identified by their Y - /// value. - async fn set_pending_proofs(&self, ys: Vec) -> Result<(), Self::Err>; - /// Reserve proofs in storage. Proofs are identified by their Y value. - async fn reserve_proofs(&self, ys: Vec) -> Result<(), Self::Err>; - /// Set proofs as unspent in storage. Proofs are identified by their Y - /// value. - async fn set_unspent_proofs(&self, ys: Vec) -> Result<(), Self::Err>; /// Get proofs from storage async fn get_proofs( &self, @@ -100,6 +92,8 @@ pub trait Database: Debug { state: Option>, spending_conditions: Option>, ) -> Result, Self::Err>; + /// Update proofs state in storage + async fn update_proofs_state(&self, ys: Vec, state: State) -> Result<(), Self::Err>; /// Increment Keyset counter async fn increment_keyset_counter(&self, keyset_id: &Id, count: u32) -> Result<(), Self::Err>; diff --git a/crates/cdk-common/src/error.rs b/crates/cdk-common/src/error.rs index 97b00b54..1364e2bf 100644 --- a/crates/cdk-common/src/error.rs +++ b/crates/cdk-common/src/error.rs @@ -169,6 +169,9 @@ pub enum Error { /// Insufficient Funds #[error("Insufficient funds")] InsufficientFunds, + /// Unexpected proof state + #[error("Unexpected proof state")] + UnexpectedProofState, /// No active keyset #[error("No active keyset")] NoActiveKeyset, diff --git a/crates/cdk-integration-tests/tests/integration_tests_pure.rs b/crates/cdk-integration-tests/tests/integration_tests_pure.rs index 46ed99eb..d5563079 100644 --- a/crates/cdk-integration-tests/tests/integration_tests_pure.rs +++ b/crates/cdk-integration-tests/tests/integration_tests_pure.rs @@ -1,8 +1,10 @@ use std::assert_eq; +use std::collections::HashSet; +use std::hash::RandomState; use cdk::amount::SplitTarget; use cdk::nuts::nut00::ProofsMethods; -use cdk::wallet::SendKind; +use cdk::wallet::SendOptions; use cdk::Amount; use cdk_integration_tests::init_pure_tests::*; @@ -18,18 +20,20 @@ async fn test_swap_to_send() -> anyhow::Result<()> { assert_eq!(Amount::from(64), balance_alice); // Alice wants to send 40 sats, which internally swaps - let token = wallet_alice - .send( - Amount::from(40), - None, - None, - &SplitTarget::None, - &SendKind::OnlineExact, - false, - ) + let prepared_send = wallet_alice + .prepare_send(Amount::from(40), SendOptions::default()) .await?; + assert_eq!( + HashSet::<_, RandomState>::from_iter(prepared_send.proofs().ys()?), + HashSet::from_iter(wallet_alice.get_reserved_proofs().await?.ys()?) + ); + let token = wallet_alice.send(prepared_send, None).await?; assert_eq!(Amount::from(40), token.proofs().total_amount()?); assert_eq!(Amount::from(24), wallet_alice.total_balance().await?); + assert_eq!( + HashSet::<_, RandomState>::from_iter(token.proofs().ys()?), + HashSet::from_iter(wallet_alice.get_pending_spent_proofs().await?.ys()?) + ); // Alice sends cashu, Carol receives let wallet_carol = create_test_wallet_arc_for_mint(mint_bob.clone()).await?; diff --git a/crates/cdk-redb/src/wallet/mod.rs b/crates/cdk-redb/src/wallet/mod.rs index ff00d878..c5a0b913 100644 --- a/crates/cdk-redb/src/wallet/mod.rs +++ b/crates/cdk-redb/src/wallet/mod.rs @@ -145,46 +145,6 @@ impl WalletRedbDatabase { Ok(Self { db: Arc::new(db) }) } - - async fn update_proof_states( - &self, - ys: Vec, - state: State, - ) -> Result<(), database::Error> { - let read_txn = self.db.begin_read().map_err(Error::from)?; - let table = read_txn.open_table(PROOFS_TABLE).map_err(Error::from)?; - - let write_txn = self.db.begin_write().map_err(Error::from)?; - - for y in ys { - let y_slice = y.to_bytes(); - let proof = table - .get(y_slice.as_slice()) - .map_err(Error::from)? - .ok_or(Error::UnknownY)?; - - let mut proof_info = - serde_json::from_str::(proof.value()).map_err(Error::from)?; - - proof_info.state = state; - - { - let mut table = write_txn.open_table(PROOFS_TABLE).map_err(Error::from)?; - table - .insert( - y_slice.as_slice(), - serde_json::to_string(&proof_info) - .map_err(Error::from)? - .as_str(), - ) - .map_err(Error::from)?; - } - } - - write_txn.commit().map_err(Error::from)?; - - Ok(()) - } } #[async_trait] @@ -611,21 +571,6 @@ impl WalletDatabase for WalletRedbDatabase { Ok(()) } - #[instrument(skip(self, ys))] - async fn set_pending_proofs(&self, ys: Vec) -> Result<(), Self::Err> { - self.update_proof_states(ys, State::Pending).await - } - - #[instrument(skip(self, ys))] - async fn reserve_proofs(&self, ys: Vec) -> Result<(), Self::Err> { - self.update_proof_states(ys, State::Reserved).await - } - - #[instrument(skip(self, ys))] - async fn set_unspent_proofs(&self, ys: Vec) -> Result<(), Self::Err> { - self.update_proof_states(ys, State::Unspent).await - } - #[instrument(skip_all)] async fn get_proofs( &self, @@ -659,6 +604,46 @@ impl WalletDatabase for WalletRedbDatabase { Ok(proofs) } + async fn update_proofs_state( + &self, + ys: Vec, + state: State, + ) -> Result<(), database::Error> { + let read_txn = self.db.begin_read().map_err(Error::from)?; + let table = read_txn.open_table(PROOFS_TABLE).map_err(Error::from)?; + + let write_txn = self.db.begin_write().map_err(Error::from)?; + + for y in ys { + let y_slice = y.to_bytes(); + let proof = table + .get(y_slice.as_slice()) + .map_err(Error::from)? + .ok_or(Error::UnknownY)?; + + let mut proof_info = + serde_json::from_str::(proof.value()).map_err(Error::from)?; + + proof_info.state = state; + + { + let mut table = write_txn.open_table(PROOFS_TABLE).map_err(Error::from)?; + table + .insert( + y_slice.as_slice(), + serde_json::to_string(&proof_info) + .map_err(Error::from)? + .as_str(), + ) + .map_err(Error::from)?; + } + } + + write_txn.commit().map_err(Error::from)?; + + Ok(()) + } + #[instrument(skip(self), fields(keyset_id = %keyset_id))] async fn increment_keyset_counter(&self, keyset_id: &Id, count: u32) -> Result<(), Self::Err> { let write_txn = self.db.begin_write().map_err(Error::from)?; diff --git a/crates/cdk-sqlite/src/wallet/migrations/20250314082116_allow_pending_spent.sql b/crates/cdk-sqlite/src/wallet/migrations/20250314082116_allow_pending_spent.sql new file mode 100644 index 00000000..56d47ba4 --- /dev/null +++ b/crates/cdk-sqlite/src/wallet/migrations/20250314082116_allow_pending_spent.sql @@ -0,0 +1,31 @@ +-- Create a new table with the updated CHECK constraint +CREATE TABLE IF NOT EXISTS proof_new ( +y BLOB PRIMARY KEY, +mint_url TEXT NOT NULL, +state TEXT CHECK ( state IN ('SPENT', 'UNSPENT', 'PENDING', 'RESERVED', 'PENDING_SPENT' ) ) NOT NULL, +spending_condition TEXT, +unit TEXT NOT NULL, +amount INTEGER NOT NULL, +keyset_id TEXT NOT NULL, +secret TEXT NOT NULL, +c BLOB NOT NULL, +witness TEXT +); + +CREATE INDEX IF NOT EXISTS secret_index ON proof_new(secret); +CREATE INDEX IF NOT EXISTS state_index ON proof_new(state); +CREATE INDEX IF NOT EXISTS spending_condition_index ON proof_new(spending_condition); +CREATE INDEX IF NOT EXISTS unit_index ON proof_new(unit); +CREATE INDEX IF NOT EXISTS amount_index ON proof_new(amount); +CREATE INDEX IF NOT EXISTS mint_url_index ON proof_new(mint_url); + +-- Copy data from old proof table to new proof table +INSERT INTO proof_new (y, mint_url, state, spending_condition, unit, amount, keyset_id, secret, c, witness) +SELECT y, mint_url, state, spending_condition, unit, amount, keyset_id, secret, c, witness +FROM proof; + +-- Drop the old proof table +DROP TABLE proof; + +-- Rename the new proof table to proof +ALTER TABLE proof_new RENAME TO proof; diff --git a/crates/cdk-sqlite/src/wallet/mod.rs b/crates/cdk-sqlite/src/wallet/mod.rs index dc8fb8a8..5ba37081 100644 --- a/crates/cdk-sqlite/src/wallet/mod.rs +++ b/crates/cdk-sqlite/src/wallet/mod.rs @@ -59,23 +59,6 @@ impl WalletSqliteDatabase { .await .expect("Could not run migrations"); } - - async fn set_proof_state(&self, y: PublicKey, state: State) -> Result<(), database::Error> { - sqlx::query( - r#" - UPDATE proof - SET state=? - WHERE y IS ?; - "#, - ) - .bind(state.to_string()) - .bind(y.to_bytes().to_vec()) - .execute(&self.pool) - .await - .map_err(Error::from)?; - - Ok(()) - } } #[async_trait] @@ -658,30 +641,6 @@ WHERE id=? Ok(()) } - async fn set_pending_proofs(&self, ys: Vec) -> Result<(), Self::Err> { - for y in ys { - self.set_proof_state(y, State::Pending).await?; - } - - Ok(()) - } - - async fn reserve_proofs(&self, ys: Vec) -> Result<(), Self::Err> { - for y in ys { - self.set_proof_state(y, State::Reserved).await?; - } - - Ok(()) - } - - async fn set_unspent_proofs(&self, ys: Vec) -> Result<(), Self::Err> { - for y in ys { - self.set_proof_state(y, State::Unspent).await?; - } - - Ok(()) - } - #[instrument(skip(self, state, spending_conditions))] async fn get_proofs( &self, @@ -734,6 +693,31 @@ FROM proof; } } + async fn update_proofs_state(&self, ys: Vec, state: State) -> Result<(), Self::Err> { + let mut transaction = self.pool.begin().await.map_err(Error::from)?; + + let update_sql = format!( + "UPDATE proof SET state = ? WHERE y IN ({})", + "?,".repeat(ys.len()).trim_end_matches(',') + ); + + ys.iter() + .fold( + sqlx::query(&update_sql).bind(state.to_string()), + |query, y| query.bind(y.to_bytes().to_vec()), + ) + .execute(&mut *transaction) + .await + .map_err(|err| { + tracing::error!("SQLite could not update proof state: {err:?}"); + Error::SQLX(err) + })?; + + transaction.commit().await.map_err(Error::from)?; + + Ok(()) + } + #[instrument(skip(self), fields(keyset_id = %keyset_id))] async fn increment_keyset_counter(&self, keyset_id: &Id, count: u32) -> Result<(), Self::Err> { let mut transaction = self.pool.begin().await.map_err(Error::from)?; diff --git a/crates/cdk/examples/mint-token.rs b/crates/cdk/examples/mint-token.rs index 544152fe..c327b5b4 100644 --- a/crates/cdk/examples/mint-token.rs +++ b/crates/cdk/examples/mint-token.rs @@ -4,14 +4,23 @@ use cdk::amount::SplitTarget; use cdk::error::Error; use cdk::nuts::nut00::ProofsMethods; use cdk::nuts::{CurrencyUnit, MintQuoteState, NotificationPayload}; -use cdk::wallet::types::SendKind; -use cdk::wallet::{Wallet, WalletSubscription}; +use cdk::wallet::{SendOptions, Wallet, WalletSubscription}; use cdk::Amount; use cdk_sqlite::wallet::memory; use rand::Rng; +use tracing_subscriber::EnvFilter; #[tokio::main] async fn main() -> Result<(), Error> { + let default_filter = "debug"; + + let sqlx_filter = "sqlx=warn,hyper_util=warn,reqwest=warn,rustls=warn"; + + let env_filter = EnvFilter::new(format!("{},{}", default_filter, sqlx_filter)); + + // Parse input + tracing_subscriber::fmt().with_env_filter(env_filter).init(); + // Initialize the memory store for the wallet let localstore = memory::empty().await?; @@ -52,16 +61,8 @@ async fn main() -> Result<(), Error> { println!("Received {} from mint {}", receive_amount, mint_url); // Send a token with the specified amount - let token = wallet - .send( - amount, - None, - None, - &SplitTarget::default(), - &SendKind::OnlineExact, - false, - ) - .await?; + let prepared_send = wallet.prepare_send(amount, SendOptions::default()).await?; + let token = wallet.send(prepared_send, None).await?; println!("Token:"); println!("{}", token); diff --git a/crates/cdk/examples/p2pk.rs b/crates/cdk/examples/p2pk.rs index 05e7181c..e476e0a9 100644 --- a/crates/cdk/examples/p2pk.rs +++ b/crates/cdk/examples/p2pk.rs @@ -3,8 +3,7 @@ use std::sync::Arc; use cdk::amount::SplitTarget; use cdk::error::Error; use cdk::nuts::{CurrencyUnit, MintQuoteState, NotificationPayload, SecretKey, SpendingConditions}; -use cdk::wallet::types::SendKind; -use cdk::wallet::{Wallet, WalletSubscription}; +use cdk::wallet::{SendOptions, Wallet, WalletSubscription}; use cdk::Amount; use cdk_sqlite::wallet::memory; use rand::Rng; @@ -30,10 +29,10 @@ async fn main() -> Result<(), Error> { // Define the mint URL and currency unit let mint_url = "https://testnut.cashu.space"; let unit = CurrencyUnit::Sat; - let amount = Amount::from(50); + let amount = Amount::from(100); // Create a new wallet - let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None)?; + let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, Some(1))?; // Request a mint quote from the wallet let quote = wallet.mint_quote(amount, None).await?; @@ -57,7 +56,14 @@ async fn main() -> Result<(), Error> { } // Mint the received amount - let _receive_amount = wallet.mint("e.id, SplitTarget::default(), None).await?; + let received_proofs = wallet.mint("e.id, SplitTarget::default(), None).await?; + println!( + "Minted nuts: {:?}", + received_proofs + .into_iter() + .map(|p| p.amount) + .collect::>() + ); // Generate a secret key for spending conditions let secret = SecretKey::generate(); @@ -67,19 +73,21 @@ async fn main() -> Result<(), Error> { // Get the total balance of the wallet let bal = wallet.total_balance().await?; - println!("{}", bal); + println!("Total balance: {}", bal); // Send a token with the specified amount and spending conditions - let token = wallet - .send( + let prepared_send = wallet + .prepare_send( 10.into(), - None, - Some(spending_conditions), - &SplitTarget::default(), - &SendKind::default(), - false, + SendOptions { + conditions: Some(spending_conditions), + include_fee: true, + ..Default::default() + }, ) .await?; + println!("Fee: {}", prepared_send.fee()); + let token = wallet.send(prepared_send, None).await?; println!("Created token locked to pubkey: {}", secret.public_key()); println!("{}", token); diff --git a/crates/cdk/examples/proof-selection.rs b/crates/cdk/examples/proof-selection.rs index ee77b21f..29ae6aab 100644 --- a/crates/cdk/examples/proof-selection.rs +++ b/crates/cdk/examples/proof-selection.rs @@ -1,5 +1,6 @@ //! Wallet example with memory store +use std::collections::HashMap; use std::sync::Arc; use cdk::amount::SplitTarget; @@ -59,9 +60,15 @@ async fn main() -> Result<(), Box> { let proofs = wallet.get_unspent_proofs().await?; // Select proofs to send - let selected = wallet - .select_proofs_to_send(Amount::from(64), proofs, false) - .await?; + let amount = Amount::from(64); + let active_keyset_ids = wallet + .get_active_mint_keysets() + .await? + .into_iter() + .map(|keyset| keyset.id) + .collect(); + let selected = + Wallet::select_proofs(amount, proofs, &active_keyset_ids, &HashMap::new(), false)?; for (i, proof) in selected.iter().enumerate() { println!("{}: {}", i, proof.amount); } diff --git a/crates/cdk/examples/wallet.rs b/crates/cdk/examples/wallet.rs index 28f564af..c45fd528 100644 --- a/crates/cdk/examples/wallet.rs +++ b/crates/cdk/examples/wallet.rs @@ -4,8 +4,7 @@ use std::time::Duration; use cdk::amount::SplitTarget; use cdk::nuts::nut00::ProofsMethods; use cdk::nuts::{CurrencyUnit, MintQuoteState}; -use cdk::wallet::types::SendKind; -use cdk::wallet::Wallet; +use cdk::wallet::{SendOptions, Wallet}; use cdk::Amount; use cdk_sqlite::wallet::memory; use rand::Rng; @@ -59,16 +58,8 @@ async fn main() -> Result<(), Box> { println!("Minted {}", receive_amount); // Send the token - let token = wallet - .send( - amount, - None, - None, - &SplitTarget::None, - &SendKind::default(), - false, - ) - .await?; + let prepared_send = wallet.prepare_send(amount, SendOptions::default()).await?; + let token = wallet.send(prepared_send, None).await?; println!("{}", token); diff --git a/crates/cdk/src/wallet/keysets.rs b/crates/cdk/src/wallet/keysets.rs index b19d3ec3..c47a6d54 100644 --- a/crates/cdk/src/wallet/keysets.rs +++ b/crates/cdk/src/wallet/keysets.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use tracing::instrument; use crate::nuts::{Id, KeySetInfo, Keys}; @@ -99,4 +101,29 @@ impl Wallet { .ok_or(Error::NoActiveKeyset)?; Ok(keyset_with_lowest_fee) } + + /// Get keyset fees for mint + pub async fn get_keyset_fees(&self) -> Result, Error> { + let keysets = self + .localstore + .get_mint_keysets(self.mint_url.clone()) + .await? + .ok_or(Error::UnknownKeySet)?; + + let mut fees = HashMap::new(); + for keyset in keysets { + fees.insert(keyset.id, keyset.input_fee_ppk); + } + + Ok(fees) + } + + /// Get keyset fees for mint by keyset id + pub async fn get_keyset_fees_by_id(&self, keyset_id: Id) -> Result { + self.get_keyset_fees() + .await? + .get(&keyset_id) + .cloned() + .ok_or(Error::UnknownKeySet) + } } diff --git a/crates/cdk/src/wallet/melt.rs b/crates/cdk/src/wallet/melt.rs index 4507d71e..ada8686a 100644 --- a/crates/cdk/src/wallet/melt.rs +++ b/crates/cdk/src/wallet/melt.rs @@ -130,7 +130,9 @@ impl Wallet { } let ys = proofs.ys()?; - self.localstore.set_pending_proofs(ys).await?; + self.localstore + .update_proofs_state(ys, State::Pending) + .await?; let active_keyset_id = self.get_active_mint_keyset().await?.id; @@ -287,9 +289,20 @@ impl Wallet { let available_proofs = self.get_unspent_proofs().await?; - let input_proofs = self - .select_proofs_to_swap(inputs_needed_amount, available_proofs) - .await?; + let active_keyset_ids = self + .get_active_mint_keysets() + .await? + .into_iter() + .map(|k| k.id) + .collect(); + let keyset_fees = self.get_keyset_fees().await?; + let input_proofs = Wallet::select_proofs( + inputs_needed_amount, + available_proofs, + &active_keyset_ids, + &keyset_fees, + true, + )?; self.melt_proofs(quote_id, input_proofs).await } diff --git a/crates/cdk/src/wallet/mod.rs b/crates/cdk/src/wallet/mod.rs index fdf88cd0..fe0019be 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/crates/cdk/src/wallet/mod.rs @@ -43,6 +43,7 @@ mod swap; pub mod util; pub use cdk_common::wallet as types; +pub use send::{PreparedSend, SendMemo, SendOptions}; use crate::nuts::nut00::ProofsMethods; @@ -173,25 +174,24 @@ impl Wallet { /// Fee required for proof set #[instrument(skip_all)] pub async fn get_proofs_fee(&self, proofs: &Proofs) -> Result { - let mut proofs_per_keyset = HashMap::new(); + let proofs_per_keyset = proofs.count_by_keyset(); + self.get_proofs_fee_by_count(proofs_per_keyset).await + } + + /// Fee required for proof set by count + pub async fn get_proofs_fee_by_count( + &self, + proofs_per_keyset: HashMap, + ) -> Result { let mut fee_per_keyset = HashMap::new(); - for proof in proofs { - if let std::collections::hash_map::Entry::Vacant(e) = - fee_per_keyset.entry(proof.keyset_id) - { - let mint_keyset_info = self - .localstore - .get_keyset_by_id(&proof.keyset_id) - .await? - .ok_or(Error::UnknownKeySet)?; - e.insert(mint_keyset_info.input_fee_ppk); - } - - proofs_per_keyset - .entry(proof.keyset_id) - .and_modify(|count| *count += 1) - .or_insert(1); + for keyset_id in proofs_per_keyset.keys() { + let mint_keyset_info = self + .localstore + .get_keyset_by_id(keyset_id) + .await? + .ok_or(Error::UnknownKeySet)?; + fee_per_keyset.insert(*keyset_id, mint_keyset_info.input_fee_ppk); } let fee = calculate_fee(&proofs_per_keyset, &fee_per_keyset)?; diff --git a/crates/cdk/src/wallet/multi_mint_wallet.rs b/crates/cdk/src/wallet/multi_mint_wallet.rs index 3ab59d48..5fd43dcc 100644 --- a/crates/cdk/src/wallet/multi_mint_wallet.rs +++ b/crates/cdk/src/wallet/multi_mint_wallet.rs @@ -11,7 +11,7 @@ use cdk_common::wallet::WalletKey; use tokio::sync::Mutex; use tracing::instrument; -use super::types::SendKind; +use super::send::{PreparedSend, SendMemo, SendOptions}; use super::Error; use crate::amount::SplitTarget; use crate::mint_url::MintUrl; @@ -110,32 +110,36 @@ impl MultiMintWallet { Ok(mint_proofs) } + /// Prepare to send + #[instrument(skip(self))] + pub async fn prepare_send( + &self, + wallet_key: &WalletKey, + amount: Amount, + opts: SendOptions, + ) -> Result { + let wallet = self + .get_wallet(wallet_key) + .await + .ok_or(Error::UnknownWallet(wallet_key.clone()))?; + + wallet.prepare_send(amount, opts).await + } + /// Create cashu token #[instrument(skip(self))] pub async fn send( &self, wallet_key: &WalletKey, - amount: Amount, - memo: Option, - conditions: Option, - send_kind: SendKind, - include_fees: bool, + send: PreparedSend, + memo: Option, ) -> Result { let wallet = self .get_wallet(wallet_key) .await .ok_or(Error::UnknownWallet(wallet_key.clone()))?; - wallet - .send( - amount, - memo, - conditions, - &SplitTarget::default(), - &send_kind, - include_fees, - ) - .await + wallet.send(send, memo).await } /// Mint quote for wallet diff --git a/crates/cdk/src/wallet/proofs.rs b/crates/cdk/src/wallet/proofs.rs index bbd59fe1..f0cd6411 100644 --- a/crates/cdk/src/wallet/proofs.rs +++ b/crates/cdk/src/wallet/proofs.rs @@ -1,8 +1,10 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use cdk_common::Id; use tracing::instrument; use crate::amount::SplitTarget; +use crate::fees::calculate_fee; use crate::nuts::nut00::ProofsMethods; use crate::nuts::{ CheckStateRequest, Proof, ProofState, Proofs, PublicKey, SpendingConditions, State, @@ -30,6 +32,13 @@ impl Wallet { .await } + /// Get pending spent [`Proofs`] + #[instrument(skip(self))] + pub async fn get_pending_spent_proofs(&self) -> Result { + self.get_proofs_with(Some(vec![State::PendingSpent]), None) + .await + } + /// Get this wallet's [Proofs] that match the args pub async fn get_proofs_with( &self, @@ -53,7 +62,10 @@ impl Wallet { /// 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?) + Ok(self + .localstore + .update_proofs_state(ys, State::Unspent) + .await?) } /// Reclaim unspent proofs @@ -112,7 +124,7 @@ impl Wallet { .get_proofs( Some(self.mint_url.clone()), Some(self.unit.clone()), - Some(vec![State::Pending, State::Reserved]), + Some(vec![State::Pending, State::Reserved, State::PendingSpent]), None, ) .await?; @@ -153,123 +165,375 @@ impl Wallet { Ok(balance) } - /// Select proofs to send + /// Select proofs #[instrument(skip_all)] - pub async fn select_proofs_to_send( - &self, + pub fn select_proofs( amount: Amount, proofs: Proofs, + active_keyset_ids: &Vec, + keyset_fees: &HashMap, include_fees: bool, ) -> Result { tracing::debug!( - "Selecting proofs to send {} from {}", + "amount={}, proofs={:?}", amount, - proofs.total_amount()? + proofs.iter().map(|p| p.amount.into()).collect::>() ); + if amount == Amount::ZERO { + return Ok(vec![]); + } ensure_cdk!(proofs.total_amount()? >= amount, Error::InsufficientFunds); - let (mut proofs_larger, mut proofs_smaller): (Proofs, Proofs) = - proofs.into_iter().partition(|p| p.amount > amount); + // Sort proofs in descending order + let mut proofs = proofs; + proofs.sort_by(|a, b| a.cmp(b).reverse()); - let next_bigger_proof = proofs_larger.first().cloned(); + // Split the amount into optimal amounts + let optimal_amounts = amount.split(); - let mut selected_proofs: Proofs = Vec::new(); - let mut remaining_amount = amount; + // Track selected proofs and remaining amounts (include all inactive proofs first) + let mut selected_proofs: HashSet = proofs + .iter() + .filter(|p| !p.is_active(active_keyset_ids)) + .cloned() + .collect(); + if selected_proofs.total_amount()? >= amount { + tracing::debug!("All inactive proofs are sufficient"); + return Ok(selected_proofs.into_iter().collect()); + } + let mut remaining_amounts: Vec = Vec::new(); - 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; + // Select proof with the exact amount and not already selected + let mut select_proof = |proofs: &Proofs, amount: Amount, exact: bool| -> bool { + let mut last_proof = None; + for proof in proofs.iter() { + if !selected_proofs.contains(proof) { + if proof.amount == amount { + selected_proofs.insert(proof.clone()); + return true; + } else if !exact && proof.amount > amount { + last_proof = Some(proof.clone()); + } else if proof.amount < amount { + break; + } + } } + if let Some(proof) = last_proof { + selected_proofs.insert(proof); + true + } else { + false + } + }; - remaining_amount = amount.checked_add(fees).ok_or(Error::AmountOverflow)? - - selected_proofs.total_amount()?; - (proofs_larger, proofs_smaller) = proofs_smaller - .into_iter() - .skip(1) - .partition(|p| p.amount > remaining_amount); + // Select proofs with the optimal amounts + for optimal_amount in optimal_amounts { + if !select_proof(&proofs, optimal_amount, true) { + // Add the remaining amount to the remaining amounts because proof with the optimal amount was not found + remaining_amounts.push(optimal_amount); + } } - if remaining_amount > Amount::ZERO { - if let Some(next_bigger) = next_bigger_proof { - return Ok(vec![next_bigger.clone()]); + // If all the optimal amounts are selected, return the selected proofs + if remaining_amounts.is_empty() { + tracing::debug!("All optimal amounts are selected"); + if include_fees { + return Self::include_fees( + amount, + proofs, + selected_proofs.into_iter().collect(), + active_keyset_ids, + keyset_fees, + ); + } else { + return Ok(selected_proofs.into_iter().collect()); + } + } + + // Select proofs with the remaining amounts by checking for 2 of the half amount, 4 of the quarter amount, etc. + tracing::debug!("Selecting proofs with the remaining amounts"); + for remaining_amount in remaining_amounts { + // Number of proofs to select + let mut n = 2; + + let mut target_amount = remaining_amount; + let mut found = false; + while let Some(curr_amount) = target_amount.checked_div(Amount::from(2)) { + if curr_amount == Amount::ZERO { + break; + } + + // Select proofs with the current amount + let mut count = 0; + for _ in 0..n { + if select_proof(&proofs, curr_amount, true) { + count += 1; + } else { + break; + } + } + n -= count; + + // All proofs with the current amount are selected + if n == 0 { + found = true; + break; + } + + // Try to find double the number of the next amount + n *= 2; + target_amount = curr_amount; } - return Err(Error::InsufficientFunds); + // Find closest amount over the remaining amount + if !found { + select_proof(&proofs, remaining_amount, false); + } + } + + // Check if the selected proofs total amount is equal to the amount else filter out unnecessary proofs + let mut selected_proofs = selected_proofs.into_iter().collect::>(); + let total_amount = selected_proofs.total_amount()?; + if total_amount != amount && selected_proofs.len() > 1 { + selected_proofs.sort_by(|a, b| a.cmp(b).reverse()); + selected_proofs = Self::select_least_amount_over(selected_proofs, amount)?; + } + + if include_fees { + return Self::include_fees( + amount, + proofs, + selected_proofs, + active_keyset_ids, + keyset_fees, + ); } Ok(selected_proofs) } - /// Select proofs to send - #[instrument(skip_all)] - pub async fn select_proofs_to_swap( - &self, + fn select_least_amount_over(proofs: Proofs, amount: Amount) -> Result, Error> { + let total_amount = proofs.total_amount()?; + if total_amount < amount { + return Err(Error::InsufficientFunds); + } + if proofs.len() == 1 { + return Ok(proofs); + } + + for i in 1..proofs.len() { + let (left, right) = proofs.split_at(i); + let left = left.to_vec(); + let right = right.to_vec(); + let left_amount = left.total_amount()?; + let right_amount = right.total_amount()?; + + if left_amount >= amount && right_amount >= amount { + match ( + Self::select_least_amount_over(left, amount), + Self::select_least_amount_over(right, amount), + ) { + (Ok(left_proofs), Ok(right_proofs)) => { + let left_total_amount = left_proofs.total_amount()?; + let right_total_amount = right_proofs.total_amount()?; + if left_total_amount < right_total_amount { + return Ok(left_proofs); + } else { + return Ok(right_proofs); + } + } + (Ok(left_proofs), Err(_)) => return Ok(left_proofs), + (Err(_), Ok(right_proofs)) => return Ok(right_proofs), + (Err(_), Err(_)) => return Err(Error::InsufficientFunds), + } + } else if left_amount >= amount { + return Self::select_least_amount_over(left, amount); + } else if right_amount >= amount { + return Self::select_least_amount_over(right, amount); + } + } + + Ok(proofs) + } + + fn include_fees( amount: Amount, proofs: Proofs, + mut selected_proofs: Proofs, + active_keyset_ids: &Vec, + keyset_fees: &HashMap, ) -> Result { + tracing::debug!("Including fees"); + let fee = + calculate_fee(&selected_proofs.count_by_keyset(), keyset_fees).unwrap_or_default(); + let net_amount = selected_proofs.total_amount()? - fee; tracing::debug!( - "Selecting proofs to swap {} from {}", - amount, - proofs.total_amount()? - ); - 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 = selected_proofs.total_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 = selected_proofs.total_amount()?; - let fees = self.get_proofs_fee(&selected_proofs).await?; - - if selected_total >= amount + fees { - return Ok(selected_proofs); - } - } - - tracing::debug!( - "Could not select proofs to swap: total selected: {}", + "Net amount={}, fee={}, total amount={}", + net_amount, + fee, selected_proofs.total_amount()? ); + if net_amount >= amount { + tracing::debug!( + "Selected proofs: {:?}", + selected_proofs + .iter() + .map(|p| p.amount.into()) + .collect::>(), + ); + return Ok(selected_proofs); + } - Err(Error::InsufficientFunds) + tracing::debug!("Net amount is less than the required amount"); + let remaining_amount = amount - net_amount; + let remaining_proofs = proofs + .into_iter() + .filter(|p| !selected_proofs.contains(p)) + .collect::(); + selected_proofs.extend(Wallet::select_proofs( + remaining_amount, + remaining_proofs, + active_keyset_ids, + &HashMap::new(), // Fees are already calculated + false, + )?); + tracing::debug!( + "Selected proofs: {:?}", + selected_proofs + .iter() + .map(|p| p.amount.into()) + .collect::>(), + ); + Ok(selected_proofs) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use cdk_common::secret::Secret; + use cdk_common::{Amount, Id, Proof, PublicKey}; + + use crate::Wallet; + + fn id() -> Id { + Id::from_bytes(&[0; 8]).unwrap() + } + + fn proof(amount: u64) -> Proof { + Proof::new( + Amount::from(amount), + id(), + Secret::generate(), + PublicKey::from_hex( + "03deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + ) + .unwrap(), + ) + } + + #[test] + fn test_select_proofs_empty() { + let proofs = vec![]; + let selected_proofs = + Wallet::select_proofs(0.into(), proofs, &vec![id()], &HashMap::new(), false).unwrap(); + assert_eq!(selected_proofs.len(), 0); + } + + #[test] + fn test_select_proofs_insufficient() { + let proofs = vec![proof(1), proof(2), proof(4)]; + let selected_proofs = + Wallet::select_proofs(8.into(), proofs, &vec![id()], &HashMap::new(), false); + assert!(selected_proofs.is_err()); + } + + #[test] + fn test_select_proofs_exact() { + let proofs = vec![ + proof(1), + proof(2), + proof(4), + proof(8), + proof(16), + proof(32), + proof(64), + ]; + let mut selected_proofs = + Wallet::select_proofs(77.into(), proofs, &vec![id()], &HashMap::new(), false).unwrap(); + selected_proofs.sort(); + assert_eq!(selected_proofs.len(), 4); + assert_eq!(selected_proofs[0].amount, 1.into()); + assert_eq!(selected_proofs[1].amount, 4.into()); + assert_eq!(selected_proofs[2].amount, 8.into()); + assert_eq!(selected_proofs[3].amount, 64.into()); + } + + #[test] + fn test_select_proofs_over() { + let proofs = vec![proof(1), proof(2), proof(4), proof(8), proof(32), proof(64)]; + let selected_proofs = + Wallet::select_proofs(31.into(), proofs, &vec![id()], &HashMap::new(), false).unwrap(); + assert_eq!(selected_proofs.len(), 1); + assert_eq!(selected_proofs[0].amount, 32.into()); + } + + #[test] + fn test_select_proofs_smaller_over() { + let proofs = vec![proof(8), proof(16), proof(32)]; + let selected_proofs = + Wallet::select_proofs(23.into(), proofs, &vec![id()], &HashMap::new(), false).unwrap(); + assert_eq!(selected_proofs.len(), 2); + assert_eq!(selected_proofs[0].amount, 16.into()); + assert_eq!(selected_proofs[1].amount, 8.into()); + } + + #[test] + fn test_select_proofs_many_ones() { + let proofs = (0..1024).into_iter().map(|_| proof(1)).collect::>(); + let selected_proofs = + Wallet::select_proofs(1024.into(), proofs, &vec![id()], &HashMap::new(), false) + .unwrap(); + assert_eq!(selected_proofs.len(), 1024); + for i in 0..1024 { + assert_eq!(selected_proofs[i].amount, 1.into()); + } + } + + #[test] + fn test_select_proofs_huge_proofs() { + let proofs = (0..32) + .flat_map(|i| { + (0..5) + .into_iter() + .map(|_| proof(1 << i)) + .collect::>() + }) + .collect::>(); + let mut selected_proofs = Wallet::select_proofs( + ((1u64 << 32) - 1).into(), + proofs, + &vec![id()], + &HashMap::new(), + false, + ) + .unwrap(); + selected_proofs.sort(); + assert_eq!(selected_proofs.len(), 32); + for i in 0..32 { + assert_eq!(selected_proofs[i].amount, (1 << i).into()); + } + } + + #[test] + fn test_select_proofs_with_fees() { + let proofs = vec![proof(64), proof(4), proof(32)]; + let mut keyset_fees = HashMap::new(); + keyset_fees.insert(id(), 100); + let selected_proofs = + Wallet::select_proofs(10.into(), proofs, &vec![id()], &keyset_fees, false).unwrap(); + assert_eq!(selected_proofs.len(), 1); + assert_eq!(selected_proofs[0].amount, 32.into()); } } diff --git a/crates/cdk/src/wallet/send.rs b/crates/cdk/src/wallet/send.rs index 1d2b739a..5f98ae00 100644 --- a/crates/cdk/src/wallet/send.rs +++ b/crates/cdk/src/wallet/send.rs @@ -1,3 +1,5 @@ +use std::fmt::Debug; + use tracing::instrument; use super::SendKind; @@ -7,36 +9,26 @@ use crate::nuts::{Proofs, SpendingConditions, State, Token}; use crate::{Amount, Error, Wallet}; impl Wallet { - /// Send specific proofs - #[instrument(skip(self))] - pub async fn send_proofs(&self, memo: Option, proofs: Proofs) -> Result { - let ys = proofs.ys()?; - self.localstore.reserve_proofs(ys).await?; - - Ok(Token::new( - self.mint_url.clone(), - proofs, - memo, - self.unit.clone(), - )) - } - - /// Send - #[instrument(skip(self))] - pub async fn send( + /// Prepare A Send Transaction + /// + /// This function prepares a send transaction by selecting proofs to send and proofs to swap. + /// By doing so, it ensures that the wallet user is able to view the fees associated with the send transaction. + /// + /// ```no_compile + /// let send = wallet.prepare_send(Amount::from(10), SendOptions::default()).await?; + /// assert!(send.fee() <= Amount::from(1)); + /// let token = wallet.send(send, None).await?; + /// ``` + #[instrument(skip(self), err)] + pub async fn prepare_send( &self, amount: Amount, - memo: Option, - conditions: Option, - amount_split_target: &SplitTarget, - send_kind: &SendKind, - include_fees: bool, - ) -> Result { + opts: SendOptions, + ) -> Result { + tracing::info!("Preparing send"); + // If online send check mint for current keysets fees - if matches!( - send_kind, - SendKind::OnlineExact | SendKind::OnlineTolerance(_) - ) { + if opts.send_kind.is_online() { if let Err(e) = self.get_active_mint_keyset().await { tracing::error!( "Error fetching active mint keyset: {:?}. Using stored keysets", @@ -45,135 +37,377 @@ impl Wallet { } } - let available_proofs = self + // Get keyset fees from localstore + let keyset_fees = self.get_keyset_fees().await?; + + // Get available proofs matching conditions + let mut available_proofs = self .get_proofs_with( Some(vec![State::Unspent]), - conditions.clone().map(|c| vec![c]), + opts.conditions.clone().map(|c| vec![c]), ) .await?; - let proofs_sum = available_proofs.total_amount()?; - - let available_proofs = if proofs_sum < amount { - match &conditions { - Some(conditions) => { - tracing::debug!("Insufficient prrofs matching conditions attempting swap"); - let unspent_proofs = self.get_unspent_proofs().await?; - let proofs_to_swap = self.select_proofs_to_swap(amount, unspent_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 => 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 = selected_proofs.total_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 = selected_proofs.total_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 = selected_proofs.total_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) + // Check if sufficient proofs are available + let mut force_swap = false; + let available_sum = available_proofs.total_amount()?; + if available_sum < amount { + if opts.conditions.is_none() || opts.send_kind.is_offline() { + return Err(Error::InsufficientFunds); + } else { + // Swap is required for send + tracing::debug!("Insufficient proofs matching conditions"); + force_swap = true; + available_proofs = self + .localstore + .get_proofs( + Some(self.mint_url.clone()), + Some(self.unit.clone()), + Some(vec![State::Unspent]), + Some(vec![]), + ) .await? + .into_iter() + .map(|p| p.proof) + .collect(); } + } - // Handle online tolerance with successful selection - (SendKind::OnlineTolerance(tolerance), Ok(selected_proofs), _) => { - let selected_proofs_amount = selected_proofs.total_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? - } - } + // Select proofs + let active_keyset_ids = self + .get_active_mint_keysets() + .await? + .into_iter() + .map(|k| k.id) + .collect(); + let selected_proofs = Wallet::select_proofs( + amount, + available_proofs, + &active_keyset_ids, + &keyset_fees, + opts.include_fee, + )?; + let selected_total = selected_proofs.total_amount()?; - // Handle all other cases where selection fails - ( - SendKind::OfflineExact - | SendKind::OnlineExact - | SendKind::OfflineTolerance(_) - | SendKind::OnlineTolerance(_), - Err(_), - _, - ) => { - tracing::debug!("Could not select proofs"); + // Check if selected proofs are exact + let send_fee = if opts.include_fee { + self.get_proofs_fee(&selected_proofs).await? + } else { + Amount::ZERO + }; + if selected_total == amount + send_fee { + return self + .internal_prepare_send(amount, opts, selected_proofs, force_swap) + .await; + } else if opts.send_kind == SendKind::OfflineExact { + return Err(Error::InsufficientFunds); + } + + // Check if selected proofs are sufficient for tolerance + let tolerance = match opts.send_kind { + SendKind::OfflineTolerance(tolerance) => Some(tolerance), + SendKind::OnlineTolerance(tolerance) => Some(tolerance), + _ => None, + }; + if let Some(tolerance) = tolerance { + if selected_total - amount > tolerance && opts.send_kind.is_offline() { return Err(Error::InsufficientFunds); } - }; + } - self.send_proofs(memo, send_proofs).await + self.internal_prepare_send(amount, opts, selected_proofs, force_swap) + .await + } + + async fn internal_prepare_send( + &self, + amount: Amount, + opts: SendOptions, + proofs: Proofs, + force_swap: bool, + ) -> Result { + // Split amount with fee if necessary + let (send_amounts, send_fee) = if opts.include_fee { + let active_keyset_id = self.get_active_mint_keyset().await?.id; + let keyset_fee_ppk = self.get_keyset_fees_by_id(active_keyset_id).await?; + tracing::debug!("Keyset fee per proof: {:?}", keyset_fee_ppk); + let send_split = amount.split_with_fee(keyset_fee_ppk)?; + let send_fee = self + .get_proofs_fee_by_count( + vec![(active_keyset_id, send_split.len() as u64)] + .into_iter() + .collect(), + ) + .await?; + (send_split, send_fee) + } else { + let send_split = amount.split(); + let send_fee = Amount::ZERO; + (send_split, send_fee) + }; + tracing::debug!("Send amounts: {:?}", send_amounts); + tracing::debug!("Send fee: {:?}", send_fee); + + // Reserve proofs + self.localstore + .update_proofs_state(proofs.ys()?, State::Reserved) + .await?; + + // Check if proofs are exact send amount + let proofs_exact_amount = proofs.total_amount()? == amount + send_fee; + + // Split proofs to swap and send + let mut proofs_to_swap = Proofs::new(); + let mut proofs_to_send = Proofs::new(); + if force_swap { + proofs_to_swap = proofs; + } else if proofs_exact_amount + || opts.send_kind.is_offline() + || opts.send_kind.has_tolerance() + { + proofs_to_send = proofs; + } else { + let mut remaining_send_amounts = send_amounts.clone(); + for proof in proofs { + if let Some(idx) = remaining_send_amounts + .iter() + .position(|a| a == &proof.amount) + { + proofs_to_send.push(proof); + remaining_send_amounts.remove(idx); + } else { + proofs_to_swap.push(proof); + } + } + } + + // Calculate swap fee + let swap_fee = self.get_proofs_fee(&proofs_to_swap).await?; + + // Return prepared send + Ok(PreparedSend { + amount, + options: opts, + proofs_to_swap, + swap_fee, + proofs_to_send, + send_fee, + }) + } + + /// Finalize A Send Transaction + /// + /// This function finalizes a send transaction by constructing a token the [`PreparedSend`]. + /// See [`Wallet::prepare_send`] for more information. + #[instrument(skip(self), err)] + pub async fn send(&self, send: PreparedSend, memo: Option) -> Result { + tracing::info!("Sending prepared send"); + let mut proofs_to_send = send.proofs_to_send; + + // Get active keyset ID + let active_keyset_id = self.get_active_mint_keyset().await?.id; + tracing::debug!("Active keyset ID: {:?}", active_keyset_id); + + // Get keyset fees + let keyset_fee_ppk = self.get_keyset_fees_by_id(active_keyset_id).await?; + tracing::debug!("Keyset fees: {:?}", keyset_fee_ppk); + + // Calculate total send amount + let total_send_amount = send.amount + send.send_fee; + tracing::debug!("Total send amount: {}", total_send_amount); + + // Swap proofs if necessary + if !send.proofs_to_swap.is_empty() { + let swap_amount = total_send_amount - proofs_to_send.total_amount()?; + tracing::debug!("Swapping proofs; swap_amount={:?}", swap_amount); + if let Some(proofs) = self + .swap( + Some(swap_amount), + SplitTarget::None, + send.proofs_to_swap, + send.options.conditions.clone(), + false, // already included in swap_amount + ) + .await? + { + proofs_to_send.extend(proofs); + } + } + tracing::debug!( + "Proofs to send: {:?}", + proofs_to_send.iter().map(|p| p.amount).collect::>() + ); + + // Check if sufficient proofs are available + if send.amount > proofs_to_send.total_amount()? { + return Err(Error::InsufficientFunds); + } + + // Check if proofs are reserved or unspent + let sendable_proof_ys = self + .get_proofs_with( + Some(vec![State::Reserved, State::Unspent]), + send.options.conditions.clone().map(|c| vec![c]), + ) + .await? + .ys()?; + if proofs_to_send + .ys()? + .iter() + .any(|y| !sendable_proof_ys.contains(y)) + { + tracing::warn!("Proofs to send are not reserved or unspent"); + return Err(Error::UnexpectedProofState); + } + + // Update proofs state to pending spent + tracing::debug!( + "Updating proofs state to pending spent: {:?}", + proofs_to_send.ys()? + ); + self.localstore + .update_proofs_state(proofs_to_send.ys()?, State::PendingSpent) + .await?; + + // Include token memo + let send_memo = send.options.memo.or(memo); + let memo = send_memo.and_then(|m| if m.include_memo { Some(m.memo) } else { None }); + + // Create and return token + Ok(Token::new( + self.mint_url.clone(), + proofs_to_send, + memo, + self.unit.clone(), + )) + } + + /// Cancel prepared send + pub async fn cancel_send(&self, send: PreparedSend) -> Result<(), Error> { + tracing::info!("Cancelling prepared send"); + + // Double-check proofs state + let reserved_proofs = self.get_reserved_proofs().await?.ys()?; + if !send + .proofs() + .ys()? + .iter() + .all(|y| reserved_proofs.contains(y)) + { + return Err(Error::UnexpectedProofState); + } + + self.localstore + .update_proofs_state(send.proofs().ys()?, State::Unspent) + .await?; + + Ok(()) } } + +/// Prepared send +pub struct PreparedSend { + amount: Amount, + options: SendOptions, + proofs_to_swap: Proofs, + swap_fee: Amount, + proofs_to_send: Proofs, + send_fee: Amount, +} + +impl PreparedSend { + /// Amount + pub fn amount(&self) -> Amount { + self.amount + } + + /// Send options + pub fn options(&self) -> &SendOptions { + &self.options + } + + /// Proofs to swap (i.e., proofs that need to be swapped before constructing the token) + pub fn proofs_to_swap(&self) -> &Proofs { + &self.proofs_to_swap + } + + /// Swap fee + pub fn swap_fee(&self) -> Amount { + self.swap_fee + } + + /// Proofs to send (i.e., proofs that will be included in the token) + pub fn proofs_to_send(&self) -> &Proofs { + &self.proofs_to_send + } + + /// Send fee + pub fn send_fee(&self) -> Amount { + self.send_fee + } + + /// All proofs + pub fn proofs(&self) -> Proofs { + let mut proofs = self.proofs_to_swap.clone(); + proofs.extend(self.proofs_to_send.clone()); + proofs + } + + /// Total fee + pub fn fee(&self) -> Amount { + self.swap_fee + self.send_fee + } +} + +impl Debug for PreparedSend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PreparedSend") + .field("amount", &self.amount) + .field("options", &self.options) + .field( + "proofs_to_swap", + &self + .proofs_to_swap + .iter() + .map(|p| p.amount) + .collect::>(), + ) + .field("swap_fee", &self.swap_fee) + .field( + "proofs_to_send", + &self + .proofs_to_send + .iter() + .map(|p| p.amount) + .collect::>(), + ) + .field("send_fee", &self.send_fee) + .finish() + } +} + +/// Send options +#[derive(Debug, Clone, Default)] +pub struct SendOptions { + /// Memo + pub memo: Option, + /// Spending conditions + pub conditions: Option, + /// Amount split target + pub amount_split_target: SplitTarget, + /// Send kind + pub send_kind: SendKind, + /// Include fee + /// + /// When this is true the token created will include the amount of fees needed to redeem the token (amount + fee_to_redeem) + pub include_fee: bool, +} + +/// Send memo +#[derive(Debug, Clone)] +pub struct SendMemo { + /// Memo + pub memo: String, + /// Include memo in token + pub include_memo: bool, +} diff --git a/crates/cdk/src/wallet/swap.rs b/crates/cdk/src/wallet/swap.rs index 01104d37..5e9f15aa 100644 --- a/crates/cdk/src/wallet/swap.rs +++ b/crates/cdk/src/wallet/swap.rs @@ -20,13 +20,14 @@ impl Wallet { spending_conditions: Option, include_fees: bool, ) -> Result, Error> { + tracing::info!("Swapping"); let mint_url = &self.mint_url; let unit = &self.unit; let pre_swap = self .create_swap( amount, - amount_split_target, + amount_split_target.clone(), input_proofs.clone(), spending_conditions.clone(), include_fees, @@ -72,13 +73,15 @@ impl Wallet { 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(); + let mut proofs_to_send = Proofs::new(); + let mut proofs_to_keep = Proofs::new(); + let mut amount_split = amount.split_targeted(&amount_split_target)?; for proof in all_proofs { - let proofs_to_send_amount = proofs_to_send.total_amount()?; - if proof.amount + proofs_to_send_amount <= amount + pre_swap.fee { + if let Some(idx) = amount_split.iter().position(|&a| a == proof.amount) + { proofs_to_send.push(proof); + amount_split.remove(idx); } else { proofs_to_keep.push(proof); } @@ -163,7 +166,20 @@ impl Wallet { ensure_cdk!(proofs_sum >= amount, Error::InsufficientFunds); - let proofs = self.select_proofs_to_swap(amount, available_proofs).await?; + let active_keyset_ids = self + .get_active_mint_keysets() + .await? + .into_iter() + .map(|k| k.id) + .collect(); + let keyset_fees = self.get_keyset_fees().await?; + let proofs = Wallet::select_proofs( + amount, + available_proofs, + &active_keyset_ids, + &keyset_fees, + true, + )?; self.swap( Some(amount), @@ -186,13 +202,16 @@ impl Wallet { spending_conditions: Option, include_fees: bool, ) -> Result { + tracing::info!("Creating swap"); 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 = proofs.total_amount()?; let ys: Vec = proofs.ys()?; - self.localstore.set_pending_proofs(ys).await?; + self.localstore + .update_proofs_state(ys, State::Reserved) + .await?; let fee = self.get_proofs_fee(&proofs).await?;