diff --git a/crates/cashu/src/amount.rs b/crates/cashu/src/amount.rs index db55528b..64c8f3f8 100644 --- a/crates/cashu/src/amount.rs +++ b/crates/cashu/src/amount.rs @@ -3,6 +3,7 @@ //! Is any unit and will be treated as the unit of the wallet use std::cmp::Ordering; +use std::collections::HashMap; use std::fmt; use std::str::FromStr; @@ -11,6 +12,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::nuts::CurrencyUnit; +use crate::Id; /// Amount Error #[derive(Debug, Error)] @@ -41,6 +43,40 @@ pub enum Error { #[serde(transparent)] pub struct Amount(u64); +/// Fees and and amount type, it can be casted just as a reference to the inner amounts, or a single +/// u64 which is the fee +#[derive(Debug, Clone)] +pub struct FeeAndAmounts { + fee: u64, + amounts: Vec, +} + +impl From<(u64, Vec)> for FeeAndAmounts { + fn from(value: (u64, Vec)) -> Self { + Self { + fee: value.0, + amounts: value.1, + } + } +} + +impl FeeAndAmounts { + /// Fees + #[inline(always)] + pub fn fee(&self) -> u64 { + self.fee + } + + /// Amounts + #[inline(always)] + pub fn amounts(&self) -> &[u64] { + &self.amounts + } +} + +/// Fees and Amounts for each Keyset +pub type KeysetFeeAndAmounts = HashMap; + impl FromStr for Amount { type Err = Error; @@ -60,31 +96,38 @@ impl Amount { pub const ONE: Amount = Amount(1); /// Split into parts that are powers of two - pub fn split(&self) -> Vec { - let sats = self.0; - (0_u64..64) + pub fn split(&self, fee_and_amounts: &FeeAndAmounts) -> Vec { + fee_and_amounts + .amounts + .iter() .rev() - .filter_map(|bit| { - let part = 1 << bit; - ((sats & part) == part).then_some(Self::from(part)) + .fold((Vec::new(), self.0), |(mut acc, total), &amount| { + if total >= amount { + acc.push(Self::from(amount)); + } + (acc, total % amount) }) - .collect() + .0 } /// Split into parts that are powers of two by target - pub fn split_targeted(&self, target: &SplitTarget) -> Result, Error> { + pub fn split_targeted( + &self, + target: &SplitTarget, + fee_and_amounts: &FeeAndAmounts, + ) -> Result, Error> { let mut parts = match target { - SplitTarget::None => self.split(), + SplitTarget::None => self.split(fee_and_amounts), SplitTarget::Value(amount) => { if self.le(amount) { - return Ok(self.split()); + return Ok(self.split(fee_and_amounts)); } let mut parts_total = Amount::ZERO; let mut parts = Vec::new(); // The powers of two that are need to create target value - let parts_of_value = amount.split(); + let parts_of_value = amount.split(fee_and_amounts); while parts_total.lt(self) { for part in parts_of_value.iter().copied() { @@ -92,7 +135,7 @@ impl Amount { parts.push(part); } else { let amount_left = *self - parts_total; - parts.extend(amount_left.split()); + parts.extend(amount_left.split(fee_and_amounts)); } parts_total = Amount::try_sum(parts.clone().iter().copied())?; @@ -115,7 +158,7 @@ impl Amount { } Ordering::Greater => { let extra = *self - values_total; - let mut extra_amount = extra.split(); + let mut extra_amount = extra.split(fee_and_amounts); let mut values = values.clone(); values.append(&mut extra_amount); @@ -130,17 +173,18 @@ impl Amount { } /// 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 total_fee_ppk = fee_ppk + pub fn split_with_fee(&self, fee_and_amounts: &FeeAndAmounts) -> Result, Error> { + let without_fee_amounts = self.split(fee_and_amounts); + let total_fee_ppk = fee_and_amounts + .fee .checked_mul(without_fee_amounts.len() as u64) .ok_or(Error::AmountOverflow)?; let fee = Amount::from(total_fee_ppk.div_ceil(1000)); let new_amount = self.checked_add(fee).ok_or(Error::AmountOverflow)?; - let split = new_amount.split(); + let split = new_amount.split(fee_and_amounts); let split_fee_ppk = (split.len() as u64) - .checked_mul(fee_ppk) + .checked_mul(fee_and_amounts.fee) .ok_or(Error::AmountOverflow)?; let split_fee = Amount::from(split_fee_ppk.div_ceil(1000)); @@ -151,7 +195,7 @@ impl Amount { } self.checked_add(Amount::ONE) .ok_or(Error::AmountOverflow)? - .split_with_fee(fee_ppk) + .split_with_fee(fee_and_amounts) } /// Checked addition for Amount. Returns None if overflow occurs. @@ -192,6 +236,11 @@ impl Amount { ) -> Result { to_unit(self.0, current_unit, target_unit) } + /// + /// Convert to u64 + pub fn to_u64(self) -> u64 { + self.0 + } /// Convert to i64 pub fn to_i64(self) -> Option { @@ -376,34 +425,43 @@ mod tests { #[test] fn test_split_amount() { - assert_eq!(Amount::from(1).split(), vec![Amount::from(1)]); - assert_eq!(Amount::from(2).split(), vec![Amount::from(2)]); + let fee_and_amounts = (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(); + assert_eq!( - Amount::from(3).split(), + Amount::from(1).split(&fee_and_amounts), + vec![Amount::from(1)] + ); + assert_eq!( + Amount::from(2).split(&fee_and_amounts), + vec![Amount::from(2)] + ); + assert_eq!( + Amount::from(3).split(&fee_and_amounts), vec![Amount::from(2), Amount::from(1)] ); let amounts: Vec = [8, 2, 1].iter().map(|a| Amount::from(*a)).collect(); - assert_eq!(Amount::from(11).split(), amounts); + assert_eq!(Amount::from(11).split(&fee_and_amounts), amounts); let amounts: Vec = [128, 64, 32, 16, 8, 4, 2, 1] .iter() .map(|a| Amount::from(*a)) .collect(); - assert_eq!(Amount::from(255).split(), amounts); + assert_eq!(Amount::from(255).split(&fee_and_amounts), amounts); } #[test] fn test_split_target_amount() { + let fee_and_amounts = (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(); let amount = Amount(65); let split = amount - .split_targeted(&SplitTarget::Value(Amount(32))) + .split_targeted(&SplitTarget::Value(Amount(32)), &fee_and_amounts) .unwrap(); assert_eq!(vec![Amount(1), Amount(32), Amount(32)], split); let amount = Amount(150); let split = amount - .split_targeted(&SplitTarget::Value(Amount::from(50))) + .split_targeted(&SplitTarget::Value(Amount::from(50)), &fee_and_amounts) .unwrap(); assert_eq!( vec![ @@ -423,7 +481,7 @@ mod tests { let amount = Amount::from(63); let split = amount - .split_targeted(&SplitTarget::Value(Amount::from(32))) + .split_targeted(&SplitTarget::Value(Amount::from(32)), &fee_and_amounts) .unwrap(); assert_eq!( vec![ @@ -440,22 +498,21 @@ mod tests { #[test] fn test_split_with_fee() { + let fee_and_amounts = (1, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(); let amount = Amount(2); - let fee_ppk = 1; - let split = amount.split_with_fee(fee_ppk).unwrap(); + let split = amount.split_with_fee(&fee_and_amounts).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(); + let split = amount.split_with_fee(&fee_and_amounts).unwrap(); assert_eq!(split, vec![Amount(4)]); let amount = Amount(3); - let fee_ppk = 1000; + let fee_and_amounts = (1000, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(); - let split = amount.split_with_fee(fee_ppk).unwrap(); + let split = amount.split_with_fee(&fee_and_amounts).unwrap(); // With fee_ppk=1000 (100%), amount 3 requires proofs totaling at least 5 // to cover both the amount (3) and fees (~2 for 2 proofs) assert_eq!(split, vec![Amount(4), Amount(1)]); @@ -463,14 +520,14 @@ mod tests { #[test] fn test_split_with_fee_reported_issue() { + let fee_and_amounts = (100, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(); // Test the reported issue: mint 600, send 300 with fee_ppk=100 let amount = Amount(300); - let fee_ppk = 100; - let split = amount.split_with_fee(fee_ppk).unwrap(); + let split = amount.split_with_fee(&fee_and_amounts).unwrap(); // Calculate the total fee for the split - let total_fee_ppk = (split.len() as u64) * fee_ppk; + let total_fee_ppk = (split.len() as u64) * fee_and_amounts.fee; let total_fee = Amount::from(total_fee_ppk.div_ceil(1000)); // The split should cover the amount plus fees @@ -502,7 +559,9 @@ mod tests { ]; for (amount, fee_ppk) in test_cases { - let result = amount.split_with_fee(fee_ppk); + let fee_and_amounts = + (fee_ppk, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(); + let result = amount.split_with_fee(&fee_and_amounts); assert!( result.is_ok(), "split_with_fee failed for amount {} with fee_ppk {}: {:?}", @@ -550,7 +609,9 @@ mod tests { ]; for (amount, fee_ppk) in test_cases { - let result = amount.split_with_fee(fee_ppk); + let fee_and_amounts = + (fee_ppk, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(); + let result = amount.split_with_fee(&fee_and_amounts); assert!( result.is_ok(), "split_with_fee failed for amount {} with fee_ppk {}: {:?}", @@ -578,9 +639,10 @@ mod tests { // Test that the recursion doesn't go infinite // This tests the edge case where the method keeps adding Amount::ONE let amount = Amount(1); - let fee_ppk = 10000; // Very high fee that might cause recursion + let fee_ppk = 10000; + let fee_and_amounts = (fee_ppk, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(); - let result = amount.split_with_fee(fee_ppk); + let result = amount.split_with_fee(&fee_and_amounts); assert!( result.is_ok(), "split_with_fee should handle extreme fees without infinite recursion" @@ -589,13 +651,16 @@ mod tests { #[test] fn test_split_values() { + let fee_and_amounts = (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(); let amount = Amount(10); let target = vec![Amount(2), Amount(4), Amount(4)]; let split_target = SplitTarget::Values(target.clone()); - let values = amount.split_targeted(&split_target).unwrap(); + let values = amount + .split_targeted(&split_target, &fee_and_amounts) + .unwrap(); assert_eq!(target, values); @@ -603,13 +668,15 @@ mod tests { let split_target = SplitTarget::Values(vec![Amount(2), Amount(4)]); - let values = amount.split_targeted(&split_target).unwrap(); + let values = amount + .split_targeted(&split_target, &fee_and_amounts) + .unwrap(); assert_eq!(target, values); let split_target = SplitTarget::Values(vec![Amount(2), Amount(10)]); - let values = amount.split_targeted(&split_target); + let values = amount.split_targeted(&split_target, &fee_and_amounts); assert!(values.is_err()) } diff --git a/crates/cashu/src/nuts/nut00/mod.rs b/crates/cashu/src/nuts/nut00/mod.rs index 48582934..bc30a8ec 100644 --- a/crates/cashu/src/nuts/nut00/mod.rs +++ b/crates/cashu/src/nuts/nut00/mod.rs @@ -18,6 +18,8 @@ use super::nut10; #[cfg(feature = "wallet")] use super::nut11::SpendingConditions; #[cfg(feature = "wallet")] +use crate::amount::FeeAndAmounts; +#[cfg(feature = "wallet")] use crate::amount::SplitTarget; #[cfg(feature = "wallet")] use crate::dhke::blind_message; @@ -746,8 +748,9 @@ impl PreMintSecrets { keyset_id: Id, amount: Amount, amount_split_target: &SplitTarget, + fee_and_amounts: &FeeAndAmounts, ) -> Result { - let amount_split = amount.split_targeted(amount_split_target)?; + let amount_split = amount.split_targeted(amount_split_target, fee_and_amounts)?; let mut output = Vec::with_capacity(amount_split.len()); @@ -830,8 +833,9 @@ impl PreMintSecrets { amount: Amount, amount_split_target: &SplitTarget, conditions: &SpendingConditions, + fee_and_amounts: &FeeAndAmounts, ) -> Result { - let amount_split = amount.split_targeted(amount_split_target)?; + let amount_split = amount.split_targeted(amount_split_target, fee_and_amounts)?; let mut output = Vec::with_capacity(amount_split.len()); diff --git a/crates/cashu/src/nuts/nut13.rs b/crates/cashu/src/nuts/nut13.rs index 66fcf808..c8841b04 100644 --- a/crates/cashu/src/nuts/nut13.rs +++ b/crates/cashu/src/nuts/nut13.rs @@ -11,7 +11,7 @@ use tracing::instrument; use super::nut00::{BlindedMessage, PreMint, PreMintSecrets}; use super::nut01::SecretKey; use super::nut02::Id; -use crate::amount::SplitTarget; +use crate::amount::{FeeAndAmounts, SplitTarget}; use crate::dhke::blind_message; use crate::secret::Secret; use crate::util::hex; @@ -127,12 +127,13 @@ impl PreMintSecrets { seed: &[u8; 64], amount: Amount, amount_split_target: &SplitTarget, + fee_and_amounts: &FeeAndAmounts, ) -> Result { let mut pre_mint_secrets = PreMintSecrets::new(keyset_id); let mut counter = counter; - for amount in amount.split_targeted(amount_split_target)? { + for amount in amount.split_targeted(amount_split_target, fee_and_amounts)? { let secret = Secret::from_seed(seed, keyset_id, counter)?; let blinding_factor = SecretKey::from_seed(seed, keyset_id, counter)?; @@ -486,10 +487,12 @@ mod tests { .unwrap(); let amount = Amount::from(1000u64); let split_target = SplitTarget::default(); + let fee_and_amounts = (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(); // Test PreMintSecrets generation with v2 keyset let pre_mint_secrets = - PreMintSecrets::from_seed(keyset_id, 0, &seed, amount, &split_target).unwrap(); + PreMintSecrets::from_seed(keyset_id, 0, &seed, amount, &split_target, &fee_and_amounts) + .unwrap(); // Verify all secrets in the pre_mint use the new v2 derivation for (i, pre_mint) in pre_mint_secrets.secrets.iter().enumerate() { diff --git a/crates/cdk-common/src/database/mint/mod.rs b/crates/cdk-common/src/database/mint/mod.rs index 01af134e..922c9d29 100644 --- a/crates/cdk-common/src/database/mint/mod.rs +++ b/crates/cdk-common/src/database/mint/mod.rs @@ -34,8 +34,7 @@ pub const KVSTORE_NAMESPACE_KEY_MAX_LEN: usize = 120; pub fn validate_kvstore_string(s: &str) -> Result<(), Error> { if s.len() > KVSTORE_NAMESPACE_KEY_MAX_LEN { return Err(Error::KVStoreInvalidKey(format!( - "{} exceeds maximum length of key characters", - KVSTORE_NAMESPACE_KEY_MAX_LEN + "{KVSTORE_NAMESPACE_KEY_MAX_LEN} exceeds maximum length of key characters" ))); } @@ -72,11 +71,10 @@ pub fn validate_kvstore_params( } // Check for potential collisions between keys and namespaces in the same namespace - let namespace_key = format!("{}/{}", primary_namespace, secondary_namespace); + let namespace_key = format!("{primary_namespace}/{secondary_namespace}"); if key == primary_namespace || key == secondary_namespace || key == namespace_key { return Err(Error::KVStoreInvalidKey(format!( - "Key '{}' conflicts with namespace names", - key + "Key '{key}' conflicts with namespace names" ))); } diff --git a/crates/cdk-ffi/src/wallet.rs b/crates/cdk-ffi/src/wallet.rs index 04d2035f..b342e142 100644 --- a/crates/cdk-ffi/src/wallet.rs +++ b/crates/cdk-ffi/src/wallet.rs @@ -372,8 +372,11 @@ impl Wallet { pub async fn get_keyset_fees_by_id(&self, keyset_id: String) -> Result { let id = cdk::nuts::Id::from_str(&keyset_id) .map_err(|e| FfiError::Generic { msg: e.to_string() })?; - let fees = self.inner.get_keyset_fees_by_id(id).await?; - Ok(fees) + Ok(self + .inner + .get_keyset_fees_and_amounts_by_id(id) + .await? + .fee()) } /// Reclaim unspent proofs (mark them as unspent in the database) @@ -397,8 +400,8 @@ impl Wallet { ) -> Result { let id = cdk::nuts::Id::from_str(&keyset_id) .map_err(|e| FfiError::Generic { msg: e.to_string() })?; - let fee_ppk = self.inner.get_keyset_fees_by_id(id).await?; - let total_fee = (proof_count as u64 * fee_ppk) / 1000; // fee is per thousand + let fee_and_amounts = self.inner.get_keyset_fees_and_amounts_by_id(id).await?; + let total_fee = (proof_count as u64 * fee_and_amounts.fee()) / 1000; // fee is per thousand Ok(Amount::new(total_fee)) } } diff --git a/crates/cdk-integration-tests/tests/bolt12.rs b/crates/cdk-integration-tests/tests/bolt12.rs index 45c7458d..76d7e8d1 100644 --- a/crates/cdk-integration-tests/tests/bolt12.rs +++ b/crates/cdk-integration-tests/tests/bolt12.rs @@ -352,7 +352,14 @@ async fn test_regtest_bolt12_mint_extra() -> Result<()> { assert_eq!(state.amount_paid, (pay_amount_msats / 1_000).into()); assert_eq!(state.amount_issued, Amount::ZERO); - let pre_mint = PreMintSecrets::random(active_keyset_id, 500.into(), &SplitTarget::None)?; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); + + let pre_mint = PreMintSecrets::random( + active_keyset_id, + 500.into(), + &SplitTarget::None, + &fee_and_amounts, + )?; let quote_info = wallet .localstore diff --git a/crates/cdk-integration-tests/tests/fake_wallet.rs b/crates/cdk-integration-tests/tests/fake_wallet.rs index 1e01eed0..7237c66f 100644 --- a/crates/cdk-integration-tests/tests/fake_wallet.rs +++ b/crates/cdk-integration-tests/tests/fake_wallet.rs @@ -392,9 +392,15 @@ async fn test_fake_melt_change_in_quote() { let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap(); let keyset = wallet.fetch_active_keyset().await.unwrap(); + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); - let premint_secrets = - PreMintSecrets::random(keyset.id, 100.into(), &SplitTarget::default()).unwrap(); + let premint_secrets = PreMintSecrets::random( + keyset.id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); let client = HttpClient::new(MINT_URL.parse().unwrap(), None); @@ -469,9 +475,15 @@ async fn test_fake_mint_without_witness() { let http_client = HttpClient::new(MINT_URL.parse().unwrap(), None); let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); - let premint_secrets = - PreMintSecrets::random(active_keyset_id, 100.into(), &SplitTarget::default()).unwrap(); + let premint_secrets = PreMintSecrets::random( + active_keyset_id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); let request = MintRequest { quote: mint_quote.id, @@ -513,9 +525,15 @@ async fn test_fake_mint_with_wrong_witness() { let http_client = HttpClient::new(MINT_URL.parse().unwrap(), None); let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); - let premint_secrets = - PreMintSecrets::random(active_keyset_id, 100.into(), &SplitTarget::default()).unwrap(); + let premint_secrets = PreMintSecrets::random( + active_keyset_id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); let mut request = MintRequest { quote: mint_quote.id, @@ -561,9 +579,15 @@ async fn test_fake_mint_inflated() { .expect("no error"); let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); - let pre_mint = - PreMintSecrets::random(active_keyset_id, 500.into(), &SplitTarget::None).unwrap(); + let pre_mint = PreMintSecrets::random( + active_keyset_id, + 500.into(), + &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); let quote_info = wallet .localstore @@ -623,8 +647,15 @@ async fn test_fake_mint_multiple_units() { .expect("no error"); let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); - let pre_mint = PreMintSecrets::random(active_keyset_id, 50.into(), &SplitTarget::None).unwrap(); + let pre_mint = PreMintSecrets::random( + active_keyset_id, + 50.into(), + &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); let wallet_usd = Wallet::new( MINT_URL, @@ -637,8 +668,13 @@ async fn test_fake_mint_multiple_units() { let active_keyset_id = wallet_usd.fetch_active_keyset().await.unwrap().id; - let usd_pre_mint = - PreMintSecrets::random(active_keyset_id, 50.into(), &SplitTarget::None).unwrap(); + let usd_pre_mint = PreMintSecrets::random( + active_keyset_id, + 50.into(), + &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); let quote_info = wallet .localstore @@ -727,6 +763,7 @@ async fn test_fake_mint_multiple_unit_swap() { .expect("no error"); let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); { let inputs: Proofs = vec![ @@ -738,6 +775,7 @@ async fn test_fake_mint_multiple_unit_swap() { active_keyset_id, inputs.total_amount().unwrap(), &SplitTarget::None, + &fee_and_amounts, ) .unwrap(); @@ -764,13 +802,23 @@ async fn test_fake_mint_multiple_unit_swap() { let inputs: Proofs = proofs.into_iter().take(2).collect(); let total_inputs = inputs.total_amount().unwrap(); + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); let half = total_inputs / 2.into(); - let usd_pre_mint = - PreMintSecrets::random(usd_active_keyset_id, half, &SplitTarget::None).unwrap(); - let pre_mint = - PreMintSecrets::random(active_keyset_id, total_inputs - half, &SplitTarget::None) - .unwrap(); + let usd_pre_mint = PreMintSecrets::random( + usd_active_keyset_id, + half, + &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); + let pre_mint = PreMintSecrets::random( + active_keyset_id, + total_inputs - half, + &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); let mut usd_outputs = usd_pre_mint.blinded_messages(); let mut sat_outputs = pre_mint.blinded_messages(); @@ -870,6 +918,7 @@ async fn test_fake_mint_multiple_unit_melt() { } { + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); let inputs: Proofs = vec![proofs.first().expect("There is a proof").clone()]; let input_amount: u64 = inputs.total_amount().unwrap().into(); @@ -882,10 +931,16 @@ async fn test_fake_mint_multiple_unit_melt() { usd_active_keyset_id, inputs.total_amount().unwrap() + 100.into(), &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); + let pre_mint = PreMintSecrets::random( + active_keyset_id, + 100.into(), + &SplitTarget::None, + &fee_and_amounts, ) .unwrap(); - let pre_mint = - PreMintSecrets::random(active_keyset_id, 100.into(), &SplitTarget::None).unwrap(); let mut usd_outputs = usd_pre_mint.blinded_messages(); let mut sat_outputs = pre_mint.blinded_messages(); @@ -944,6 +999,7 @@ async fn test_fake_mint_input_output_mismatch() { ) .expect("failed to create new usd wallet"); let usd_active_keyset_id = wallet_usd.fetch_active_keyset().await.unwrap().id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); let inputs = proofs; @@ -951,6 +1007,7 @@ async fn test_fake_mint_input_output_mismatch() { usd_active_keyset_id, inputs.total_amount().unwrap(), &SplitTarget::None, + &fee_and_amounts, ) .unwrap(); @@ -985,6 +1042,7 @@ async fn test_fake_mint_swap_inflated() { let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None); + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); let proofs = proof_streams .next() @@ -993,8 +1051,13 @@ async fn test_fake_mint_swap_inflated() { .expect("no error"); let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id; - let pre_mint = - PreMintSecrets::random(active_keyset_id, 101.into(), &SplitTarget::None).unwrap(); + let pre_mint = PreMintSecrets::random( + active_keyset_id, + 101.into(), + &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(proofs, pre_mint.blinded_messages()); @@ -1037,9 +1100,15 @@ async fn test_fake_mint_swap_spend_after_fail() { .expect("no error"); let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); - let pre_mint = - PreMintSecrets::random(active_keyset_id, 100.into(), &SplitTarget::None).unwrap(); + let pre_mint = PreMintSecrets::random( + active_keyset_id, + 100.into(), + &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(proofs.clone(), pre_mint.blinded_messages()); @@ -1048,8 +1117,13 @@ async fn test_fake_mint_swap_spend_after_fail() { assert!(response.is_ok()); - let pre_mint = - PreMintSecrets::random(active_keyset_id, 101.into(), &SplitTarget::None).unwrap(); + let pre_mint = PreMintSecrets::random( + active_keyset_id, + 101.into(), + &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(proofs.clone(), pre_mint.blinded_messages()); @@ -1064,8 +1138,13 @@ async fn test_fake_mint_swap_spend_after_fail() { Ok(_) => panic!("Should not have allowed swap with unbalanced"), } - let pre_mint = - PreMintSecrets::random(active_keyset_id, 100.into(), &SplitTarget::None).unwrap(); + let pre_mint = PreMintSecrets::random( + active_keyset_id, + 100.into(), + &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(proofs, pre_mint.blinded_messages()); @@ -1108,9 +1187,15 @@ async fn test_fake_mint_melt_spend_after_fail() { .expect("no error"); let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); - let pre_mint = - PreMintSecrets::random(active_keyset_id, 100.into(), &SplitTarget::None).unwrap(); + let pre_mint = PreMintSecrets::random( + active_keyset_id, + 100.into(), + &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(proofs.clone(), pre_mint.blinded_messages()); @@ -1119,8 +1204,13 @@ async fn test_fake_mint_melt_spend_after_fail() { assert!(response.is_ok()); - let pre_mint = - PreMintSecrets::random(active_keyset_id, 101.into(), &SplitTarget::None).unwrap(); + let pre_mint = PreMintSecrets::random( + active_keyset_id, + 101.into(), + &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(proofs.clone(), pre_mint.blinded_messages()); @@ -1180,6 +1270,7 @@ async fn test_fake_mint_duplicate_proofs_swap() { .expect("no error"); let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); let inputs = vec![proofs[0].clone(), proofs[0].clone()]; @@ -1187,6 +1278,7 @@ async fn test_fake_mint_duplicate_proofs_swap() { active_keyset_id, inputs.total_amount().unwrap(), &SplitTarget::None, + &fee_and_amounts, ) .unwrap(); diff --git a/crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs b/crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs index ba2a06bd..d0389b9a 100644 --- a/crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs +++ b/crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs @@ -376,9 +376,15 @@ async fn test_fake_melt_change_in_quote() { let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap(); let keyset = wallet.fetch_active_keyset().await.unwrap(); + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); - let premint_secrets = - PreMintSecrets::random(keyset.id, 100.into(), &SplitTarget::default()).unwrap(); + let premint_secrets = PreMintSecrets::random( + keyset.id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); let client = HttpClient::new(get_mint_url_from_env().parse().unwrap(), None); diff --git a/crates/cdk-integration-tests/tests/integration_tests_pure.rs b/crates/cdk-integration-tests/tests/integration_tests_pure.rs index 8cb645e7..9ab1fb00 100644 --- a/crates/cdk-integration-tests/tests/integration_tests_pure.rs +++ b/crates/cdk-integration-tests/tests/integration_tests_pure.rs @@ -243,11 +243,13 @@ async fn test_mint_double_spend() { let keys = mint_bob.pubkeys().keysets.first().unwrap().clone(); let keyset_id = keys.id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); let preswap = PreMintSecrets::random( keyset_id, proofs.total_amount().unwrap(), &SplitTarget::default(), + &fee_and_amounts, ) .unwrap(); @@ -260,6 +262,7 @@ async fn test_mint_double_spend() { keyset_id, proofs.total_amount().unwrap(), &SplitTarget::default(), + &fee_and_amounts, ) .unwrap(); @@ -300,14 +303,30 @@ async fn test_attempt_to_swap_by_overflowing() { let keys = mint_bob.pubkeys().keysets.first().unwrap().clone(); let keyset_id = keys.id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); - let pre_mint_amount = - PreMintSecrets::random(keyset_id, amount.into(), &SplitTarget::default()).unwrap(); - let pre_mint_amount_two = - PreMintSecrets::random(keyset_id, amount.into(), &SplitTarget::default()).unwrap(); + let pre_mint_amount = PreMintSecrets::random( + keyset_id, + amount.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); + let pre_mint_amount_two = PreMintSecrets::random( + keyset_id, + amount.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); - let mut pre_mint = - PreMintSecrets::random(keyset_id, 1.into(), &SplitTarget::default()).unwrap(); + let mut pre_mint = PreMintSecrets::random( + keyset_id, + 1.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); pre_mint.combine(pre_mint_amount); pre_mint.combine(pre_mint_amount_two); @@ -320,6 +339,7 @@ async fn test_attempt_to_swap_by_overflowing() { cdk::Error::NUT03(cdk::nuts::nut03::Error::Amount(_)) => (), cdk::Error::AmountOverflow => (), cdk::Error::AmountError(_) => (), + cdk::Error::TransactionUnbalanced(_, _, _) => (), _ => { panic!("Wrong error returned in swap overflow {:?}", err); } @@ -353,9 +373,16 @@ async fn test_swap_unbalanced() { let keyset_id = get_keyset_id(&mint_bob).await; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); + // Try to swap for less than the input amount (95 < 100) - let preswap = PreMintSecrets::random(keyset_id, 95.into(), &SplitTarget::default()) - .expect("Failed to create preswap"); + let preswap = PreMintSecrets::random( + keyset_id, + 95.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap"); let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages()); @@ -368,8 +395,13 @@ async fn test_swap_unbalanced() { } // Try to swap for more than the input amount (101 > 100) - let preswap = PreMintSecrets::random(keyset_id, 101.into(), &SplitTarget::default()) - .expect("Failed to create preswap"); + let preswap = PreMintSecrets::random( + keyset_id, + 101.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap"); let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages()); @@ -407,12 +439,14 @@ pub async fn test_p2pk_swap() { let secret = SecretKey::generate(); let spending_conditions = SpendingConditions::new_p2pk(secret.public_key(), None); + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); let pre_swap = PreMintSecrets::with_conditions( keyset_id, 100.into(), &SplitTarget::default(), &spending_conditions, + &fee_and_amounts, ) .unwrap(); @@ -430,7 +464,13 @@ pub async fn test_p2pk_swap() { ) .unwrap(); - let pre_swap = PreMintSecrets::random(keyset_id, 100.into(), &SplitTarget::default()).unwrap(); + let pre_swap = PreMintSecrets::random( + keyset_id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(proofs.clone(), pre_swap.blinded_messages()); @@ -536,8 +576,15 @@ async fn test_swap_overpay_underpay_fee() { let keys = mint_bob.pubkeys().keysets.first().unwrap().clone().keys; let keyset_id = Id::v1_from_keys(&keys); + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); - let preswap = PreMintSecrets::random(keyset_id, 9998.into(), &SplitTarget::default()).unwrap(); + let preswap = PreMintSecrets::random( + keyset_id, + 9998.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages()); @@ -553,7 +600,13 @@ async fn test_swap_overpay_underpay_fee() { }, } - let preswap = PreMintSecrets::random(keyset_id, 1000.into(), &SplitTarget::default()).unwrap(); + let preswap = PreMintSecrets::random( + keyset_id, + 1000.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages()); @@ -602,10 +655,17 @@ async fn test_mint_enforce_fee() { let keys = mint_bob.pubkeys().keysets.first().unwrap().clone(); let keyset_id = keys.id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); let five_proofs: Vec<_> = proofs.drain(..5).collect(); - let preswap = PreMintSecrets::random(keyset_id, 5.into(), &SplitTarget::default()).unwrap(); + let preswap = PreMintSecrets::random( + keyset_id, + 5.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(five_proofs.clone(), preswap.blinded_messages()); @@ -621,7 +681,13 @@ async fn test_mint_enforce_fee() { }, } - let preswap = PreMintSecrets::random(keyset_id, 4.into(), &SplitTarget::default()).unwrap(); + let preswap = PreMintSecrets::random( + keyset_id, + 4.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(five_proofs.clone(), preswap.blinded_messages()); @@ -631,7 +697,13 @@ async fn test_mint_enforce_fee() { let thousnad_proofs: Vec<_> = proofs.drain(..1001).collect(); - let preswap = PreMintSecrets::random(keyset_id, 1000.into(), &SplitTarget::default()).unwrap(); + let preswap = PreMintSecrets::random( + keyset_id, + 1000.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(thousnad_proofs.clone(), preswap.blinded_messages()); @@ -647,7 +719,13 @@ async fn test_mint_enforce_fee() { }, } - let preswap = PreMintSecrets::random(keyset_id, 999.into(), &SplitTarget::default()).unwrap(); + let preswap = PreMintSecrets::random( + keyset_id, + 999.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(thousnad_proofs.clone(), preswap.blinded_messages()); @@ -721,18 +799,34 @@ async fn test_concurrent_double_spend_swap() { .expect("Could not get proofs"); let keyset_id = get_keyset_id(&mint_bob).await; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); // Create 3 identical swap requests with the same proofs - let preswap1 = PreMintSecrets::random(keyset_id, 100.into(), &SplitTarget::default()) - .expect("Failed to create preswap"); + let preswap1 = PreMintSecrets::random( + keyset_id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap"); let swap_request1 = SwapRequest::new(proofs.clone(), preswap1.blinded_messages()); - let preswap2 = PreMintSecrets::random(keyset_id, 100.into(), &SplitTarget::default()) - .expect("Failed to create preswap"); + let preswap2 = PreMintSecrets::random( + keyset_id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap"); let swap_request2 = SwapRequest::new(proofs.clone(), preswap2.blinded_messages()); - let preswap3 = PreMintSecrets::random(keyset_id, 100.into(), &SplitTarget::default()) - .expect("Failed to create preswap"); + let preswap3 = PreMintSecrets::random( + keyset_id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap"); let swap_request3 = SwapRequest::new(proofs.clone(), preswap3.blinded_messages()); // Spawn 3 concurrent tasks to process the swap requests diff --git a/crates/cdk-integration-tests/tests/regtest.rs b/crates/cdk-integration-tests/tests/regtest.rs index e726ab25..ce0a73cd 100644 --- a/crates/cdk-integration-tests/tests/regtest.rs +++ b/crates/cdk-integration-tests/tests/regtest.rs @@ -315,9 +315,15 @@ async fn test_cached_mint() { .expect("payment"); let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); let http_client = HttpClient::new(get_mint_url_from_env().parse().unwrap(), None); - let premint_secrets = - PreMintSecrets::random(active_keyset_id, 100.into(), &SplitTarget::default()).unwrap(); + let premint_secrets = PreMintSecrets::random( + active_keyset_id, + 100.into(), + &SplitTarget::default().to_owned(), + &fee_and_amounts, + ) + .unwrap(); let mut request = MintRequest { quote: quote.id, diff --git a/crates/cdk-signatory/src/proto/convert.rs b/crates/cdk-signatory/src/proto/convert.rs index 91692cd0..da5a3fdd 100644 --- a/crates/cdk-signatory/src/proto/convert.rs +++ b/crates/cdk-signatory/src/proto/convert.rs @@ -60,6 +60,7 @@ impl TryInto for KeySet { .map(|(amount, pk)| PublicKey::from_slice(&pk).map(|pk| (amount.into(), pk))) .collect::, _>>()?, ), + amounts: self.amounts, final_expiry: self.final_expiry, }) } @@ -80,6 +81,7 @@ impl From for KeySet { .collect(), }), final_expiry: keyset.final_expiry, + amounts: keyset.amounts, version: Default::default(), } } @@ -361,6 +363,7 @@ impl From for KeySet { input_fee_ppk: value.input_fee_ppk, keys: Default::default(), final_expiry: value.final_expiry, + amounts: vec![], version: Default::default(), } } diff --git a/crates/cdk-signatory/src/proto/signatory.proto b/crates/cdk-signatory/src/proto/signatory.proto index 5b82408a..3da00f97 100644 --- a/crates/cdk-signatory/src/proto/signatory.proto +++ b/crates/cdk-signatory/src/proto/signatory.proto @@ -64,6 +64,7 @@ message KeySet { Keys keys = 5; optional uint64 final_expiry = 6; uint64 version = 7; + repeated uint64 amounts = 8; } message Keys { diff --git a/crates/cdk-signatory/src/signatory.rs b/crates/cdk-signatory/src/signatory.rs index 1b7e2257..0b987a72 100644 --- a/crates/cdk-signatory/src/signatory.rs +++ b/crates/cdk-signatory/src/signatory.rs @@ -71,6 +71,8 @@ pub struct SignatoryKeySet { pub active: bool, /// The list of public keys pub keys: Keys, + /// Amounts supported by the keyset + pub amounts: Vec, /// Information about the fee per public key pub input_fee_ppk: u64, /// Final expiry of the keyset (unix timestamp in the future) @@ -110,7 +112,7 @@ impl From for MintKeySetInfo { derivation_path: Default::default(), derivation_path_index: Default::default(), max_order: 0, - amounts: vec![], + amounts: val.amounts, final_expiry: val.final_expiry, valid_from: 0, } @@ -124,6 +126,7 @@ impl From<&(MintKeySetInfo, MintKeySet)> for SignatoryKeySet { unit: key.unit.clone(), active: info.active, input_fee_ppk: info.input_fee_ppk, + amounts: info.amounts.clone(), keys: key.keys.clone().into(), final_expiry: key.final_expiry, } diff --git a/crates/cdk/src/mint/melt.rs b/crates/cdk/src/mint/melt.rs index 6ceb9bb3..a226a032 100644 --- a/crates/cdk/src/mint/melt.rs +++ b/crates/cdk/src/mint/melt.rs @@ -930,7 +930,24 @@ impl Mint { let change_target = inputs_amount - total_spent - inputs_fee; - let mut amounts = change_target.split(); + let fee_and_amounts = self + .keysets + .load() + .iter() + .filter_map(|keyset| { + if keyset.active && Some(keyset.id) == outputs.first().map(|x| x.keyset_id) + { + Some((keyset.input_fee_ppk, keyset.amounts.clone()).into()) + } else { + None + } + }) + .next() + .unwrap_or_else(|| { + (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into() + }); + + let mut amounts = change_target.split(&fee_and_amounts); if outputs.len().lt(&amounts.len()) { tracing::debug!( diff --git a/crates/cdk/src/wallet/auth/auth_wallet.rs b/crates/cdk/src/wallet/auth/auth_wallet.rs index d61f2bb5..58e683e8 100644 --- a/crates/cdk/src/wallet/auth/auth_wallet.rs +++ b/crates/cdk/src/wallet/auth/auth_wallet.rs @@ -393,10 +393,33 @@ impl AuthWallet { } } - let active_keyset_id = self.fetch_active_keyset().await?.id; + let keysets = self + .load_mint_keysets() + .await? + .into_iter() + .map(|x| (x.id, x)) + .collect::>(); - let premint_secrets = - PreMintSecrets::random(active_keyset_id, amount, &SplitTarget::Value(1.into()))?; + let active_keyset_id = self.fetch_active_keyset().await?.id; + let fee_and_amounts = ( + keysets + .get(&active_keyset_id) + .map(|x| x.input_fee_ppk) + .unwrap_or_default(), + self.load_keyset_keys(active_keyset_id) + .await? + .iter() + .map(|(amount, _)| amount.to_u64()) + .collect::>(), + ) + .into(); + + let premint_secrets = PreMintSecrets::random( + active_keyset_id, + amount, + &SplitTarget::Value(1.into()), + &fee_and_amounts, + )?; let request = MintAuthRequest { outputs: premint_secrets.blinded_messages(), diff --git a/crates/cdk/src/wallet/issue/issue_bolt11.rs b/crates/cdk/src/wallet/issue/issue_bolt11.rs index dfe17a52..98ac3780 100644 --- a/crates/cdk/src/wallet/issue/issue_bolt11.rs +++ b/crates/cdk/src/wallet/issue/issue_bolt11.rs @@ -222,6 +222,9 @@ impl Wallet { } let active_keyset_id = self.fetch_active_keyset().await?.id; + let fee_and_amounts = self + .get_keyset_fees_and_amounts_by_id(active_keyset_id) + .await?; let premint_secrets = match &spending_conditions { Some(spending_conditions) => PreMintSecrets::with_conditions( @@ -229,10 +232,12 @@ impl Wallet { amount_mintable, &amount_split_target, spending_conditions, + &fee_and_amounts, )?, None => { // Calculate how many secrets we'll need - let amount_split = amount_mintable.split_targeted(&amount_split_target)?; + let amount_split = + amount_mintable.split_targeted(&amount_split_target, &fee_and_amounts)?; let num_secrets = amount_split.len() as u32; tracing::debug!( @@ -255,6 +260,7 @@ impl Wallet { &self.seed, amount_mintable, &amount_split_target, + &fee_and_amounts, )? } }; diff --git a/crates/cdk/src/wallet/issue/issue_bolt12.rs b/crates/cdk/src/wallet/issue/issue_bolt12.rs index 1673ac89..470f2582 100644 --- a/crates/cdk/src/wallet/issue/issue_bolt12.rs +++ b/crates/cdk/src/wallet/issue/issue_bolt12.rs @@ -100,6 +100,9 @@ impl Wallet { }; let active_keyset_id = self.fetch_active_keyset().await?.id; + let fee_and_amounts = self + .get_keyset_fees_and_amounts_by_id(active_keyset_id) + .await?; let amount = match amount { Some(amount) => amount, @@ -123,10 +126,11 @@ impl Wallet { amount, &amount_split_target, spending_conditions, + &fee_and_amounts, )?, None => { // Calculate how many secrets we'll need without generating them - let amount_split = amount.split_targeted(&amount_split_target)?; + let amount_split = amount.split_targeted(&amount_split_target, &fee_and_amounts)?; let num_secrets = amount_split.len() as u32; tracing::debug!( @@ -149,6 +153,7 @@ impl Wallet { &self.seed, amount, &amount_split_target, + &fee_and_amounts, )? } }; diff --git a/crates/cdk/src/wallet/keysets.rs b/crates/cdk/src/wallet/keysets.rs index 302b8e85..47f08820 100644 --- a/crates/cdk/src/wallet/keysets.rs +++ b/crates/cdk/src/wallet/keysets.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; +use cdk_common::amount::{FeeAndAmounts, KeysetFeeAndAmounts}; use cdk_common::nut02::{KeySetInfos, KeySetInfosMethods}; use tracing::instrument; @@ -139,12 +140,12 @@ impl Wallet { } } - /// Get keyset fees for mint from local database only - offline operation + /// Get keyset fees and amounts for mint from local database only - offline operation /// /// Returns a HashMap of keyset IDs to their input fee rates (per-proof-per-thousand) /// from cached keysets in the local database. This is an offline operation that does /// not contact the mint. If no keysets are found locally, returns an error. - pub async fn get_keyset_fees(&self) -> Result, Error> { + pub async fn get_keyset_fees_and_amounts(&self) -> Result { let keysets = self .localstore .get_mint_keysets(self.mint_url.clone()) @@ -153,19 +154,33 @@ impl Wallet { let mut fees = HashMap::new(); for keyset in keysets { - fees.insert(keyset.id, keyset.input_fee_ppk); + fees.insert( + keyset.id, + ( + keyset.input_fee_ppk, + self.load_keyset_keys(keyset.id) + .await? + .iter() + .map(|(amount, _)| amount.to_u64()) + .collect::>(), + ) + .into(), + ); } Ok(fees) } - /// Get keyset fees for mint by keyset id from local database only - offline operation + /// Get keyset fees and amounts for mint by keyset id from local database only - offline operation /// /// Returns the input fee rate (per-proof-per-thousand) for a specific keyset ID from /// cached keysets in the local database. This is an offline operation that does not /// contact the mint. If the keyset is not found locally, returns an error. - pub async fn get_keyset_fees_by_id(&self, keyset_id: Id) -> Result { - self.get_keyset_fees() + pub async fn get_keyset_fees_and_amounts_by_id( + &self, + keyset_id: Id, + ) -> Result { + self.get_keyset_fees_and_amounts() .await? .get(&keyset_id) .cloned() diff --git a/crates/cdk/src/wallet/melt/melt_bolt11.rs b/crates/cdk/src/wallet/melt/melt_bolt11.rs index 050f7a83..5583b763 100644 --- a/crates/cdk/src/wallet/melt/melt_bolt11.rs +++ b/crates/cdk/src/wallet/melt/melt_bolt11.rs @@ -341,7 +341,7 @@ impl Wallet { .into_iter() .map(|k| k.id) .collect(); - let keyset_fees = self.get_keyset_fees().await?; + let keyset_fees = self.get_keyset_fees_and_amounts().await?; let (mut input_proofs, mut exchange) = Wallet::select_exact_proofs( inputs_needed_amount, available_proofs, diff --git a/crates/cdk/src/wallet/mod.rs b/crates/cdk/src/wallet/mod.rs index 43c738d0..c2adec46 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/crates/cdk/src/wallet/mod.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use std::str::FromStr; use std::sync::Arc; +use cdk_common::amount::FeeAndAmounts; use cdk_common::database::{self, WalletDatabase}; use cdk_common::subscription::Params; use getrandom::getrandom; @@ -326,34 +327,36 @@ impl Wallet { /// Get amounts needed to refill proof state #[instrument(skip(self))] - pub async fn amounts_needed_for_state_target(&self) -> Result, Error> { + pub async fn amounts_needed_for_state_target( + &self, + fee_and_amounts: &FeeAndAmounts, + ) -> Result, Error> { let unspent_proofs = self.get_unspent_proofs().await?; - let amounts_count: HashMap = + let amounts_count: HashMap = unspent_proofs .iter() .fold(HashMap::new(), |mut acc, proof| { let amount = proof.amount; - let counter = acc.entry(u64::from(amount) as usize).or_insert(0); + let counter = acc.entry(u64::from(amount)).or_insert(0); *counter += 1; acc }); - let all_possible_amounts: Vec = (0..32).map(|i| 2usize.pow(i as u32)).collect(); + let needed_amounts = + fee_and_amounts + .amounts() + .iter() + .fold(Vec::new(), |mut acc, amount| { + let count_needed = (self.target_proof_count as u64) + .saturating_sub(*amounts_count.get(amount).unwrap_or(&0)); - let needed_amounts = all_possible_amounts - .iter() - .fold(Vec::new(), |mut acc, amount| { - let count_needed: usize = self - .target_proof_count - .saturating_sub(*amounts_count.get(amount).unwrap_or(&0)); + for _i in 0..count_needed { + acc.push(Amount::from(*amount)); + } - for _i in 0..count_needed { - acc.push(Amount::from(*amount as u64)); - } - - acc - }); + acc + }); Ok(needed_amounts) } @@ -362,8 +365,11 @@ impl Wallet { async fn determine_split_target_values( &self, change_amount: Amount, + fee_and_amounts: &FeeAndAmounts, ) -> Result { - let mut amounts_needed_refill = self.amounts_needed_for_state_target().await?; + let mut amounts_needed_refill = self + .amounts_needed_for_state_target(fee_and_amounts) + .await?; amounts_needed_refill.sort(); diff --git a/crates/cdk/src/wallet/proofs.rs b/crates/cdk/src/wallet/proofs.rs index 2fe0c9e6..c90488e6 100644 --- a/crates/cdk/src/wallet/proofs.rs +++ b/crates/cdk/src/wallet/proofs.rs @@ -1,5 +1,6 @@ use std::collections::{HashMap, HashSet}; +use cdk_common::amount::KeysetFeeAndAmounts; use cdk_common::wallet::TransactionId; use cdk_common::Id; use tracing::instrument; @@ -188,11 +189,16 @@ impl Wallet { amount: Amount, proofs: Proofs, active_keyset_ids: &Vec, - keyset_fees: &HashMap, + fees_and_keyset_amounts: &KeysetFeeAndAmounts, include_fees: bool, ) -> Result<(Proofs, Option<(Proof, Amount)>), Error> { - let mut input_proofs = - Self::select_proofs(amount, proofs, active_keyset_ids, keyset_fees, include_fees)?; + let mut input_proofs = Self::select_proofs( + amount, + proofs, + active_keyset_ids, + fees_and_keyset_amounts, + include_fees, + )?; let mut exchange = None; // How much amounts do we have selected in our proof sets? @@ -211,9 +217,9 @@ impl Wallet { input_proofs.sort_by(|a, b| a.amount.cmp(&b.amount)); if let Some(proof_to_exchange) = input_proofs.pop() { - let fee_ppk = keyset_fees + let fee_ppk = fees_and_keyset_amounts .get(&proof_to_exchange.keyset_id) - .cloned() + .map(|fee_and_amounts| fee_and_amounts.fee()) .unwrap_or_default() .into(); @@ -239,7 +245,7 @@ impl Wallet { amount: Amount, proofs: Proofs, active_keyset_ids: &Vec, - keyset_fees: &HashMap, + fees_and_keyset_amounts: &KeysetFeeAndAmounts, include_fees: bool, ) -> Result { tracing::debug!( @@ -256,9 +262,6 @@ impl Wallet { let mut proofs = proofs; proofs.sort_by(|a, b| a.cmp(b).reverse()); - // Split the amount into optimal amounts - let optimal_amounts = amount.split(); - // Track selected proofs and remaining amounts (include all inactive proofs first) let mut selected_proofs: HashSet = proofs .iter() @@ -295,10 +298,13 @@ impl Wallet { }; // 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); + for (_, fee_and_amounts) in fees_and_keyset_amounts.iter() { + // Split the amount into optimal amounts + for optimal_amount in amount.split(fee_and_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); + } } } @@ -311,7 +317,7 @@ impl Wallet { proofs, selected_proofs.into_iter().collect(), active_keyset_ids, - keyset_fees, + fees_and_keyset_amounts, ); } else { return Ok(selected_proofs.into_iter().collect()); @@ -373,7 +379,7 @@ impl Wallet { proofs, selected_proofs, active_keyset_ids, - keyset_fees, + fees_and_keyset_amounts, ); } @@ -429,11 +435,17 @@ impl Wallet { proofs: Proofs, mut selected_proofs: Proofs, active_keyset_ids: &Vec, - keyset_fees: &HashMap, + fees_and_keyset_amounts: &KeysetFeeAndAmounts, ) -> Result { tracing::debug!("Including fees"); - let fee = - calculate_fee(&selected_proofs.count_by_keyset(), keyset_fees).unwrap_or_default(); + let fee = calculate_fee( + &selected_proofs.count_by_keyset(), + &fees_and_keyset_amounts + .iter() + .map(|(key, values)| (*key, values.fee())) + .collect(), + ) + .unwrap_or_default(); let net_amount = selected_proofs.total_amount()? - fee; tracing::debug!( "Net amount={}, fee={}, total amount={}", @@ -503,17 +515,40 @@ mod tests { #[test] fn test_select_proofs_empty() { + let active_id = id(); + let mut keyset_fee_and_amounts = HashMap::new(); + keyset_fee_and_amounts.insert( + active_id, + (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(), + ); let proofs = vec![]; - let selected_proofs = - Wallet::select_proofs(0.into(), proofs, &vec![id()], &HashMap::new(), false).unwrap(); + let selected_proofs = Wallet::select_proofs( + 0.into(), + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + false, + ) + .unwrap(); assert_eq!(selected_proofs.len(), 0); } #[test] fn test_select_proofs_insufficient() { + let active_id = id(); + let mut keyset_fee_and_amounts = HashMap::new(); + keyset_fee_and_amounts.insert( + active_id, + (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(), + ); let proofs = vec![proof(1), proof(2), proof(4)]; - let selected_proofs = - Wallet::select_proofs(8.into(), proofs, &vec![id()], &HashMap::new(), false); + let selected_proofs = Wallet::select_proofs( + 8.into(), + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + false, + ); assert!(selected_proofs.is_err()); } @@ -528,8 +563,22 @@ mod tests { proof(32), proof(64), ]; - let mut selected_proofs = - Wallet::select_proofs(77.into(), proofs, &vec![id()], &HashMap::new(), false).unwrap(); + + let active_id = id(); + let mut keyset_fee_and_amounts = HashMap::new(); + keyset_fee_and_amounts.insert( + active_id, + (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(), + ); + + let mut selected_proofs = Wallet::select_proofs( + 77.into(), + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + false, + ) + .unwrap(); selected_proofs.sort(); assert_eq!(selected_proofs.len(), 4); assert_eq!(selected_proofs[0].amount, 1.into()); @@ -540,9 +589,21 @@ mod tests { #[test] fn test_select_proofs_over() { + let active_id = id(); + let mut keyset_fee_and_amounts = HashMap::new(); + keyset_fee_and_amounts.insert( + active_id, + (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(), + ); 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(); + let selected_proofs = Wallet::select_proofs( + 31.into(), + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + false, + ) + .unwrap(); assert_eq!(selected_proofs.len(), 1); assert_eq!(selected_proofs[0].amount, 32.into()); } @@ -550,8 +611,21 @@ mod tests { #[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(); + let active_id = id(); + let mut keyset_fee_and_amounts = HashMap::new(); + keyset_fee_and_amounts.insert( + active_id, + (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(), + ); + + let selected_proofs = Wallet::select_proofs( + 23.into(), + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + false, + ) + .unwrap(); assert_eq!(selected_proofs.len(), 2); assert_eq!(selected_proofs[0].amount, 16.into()); assert_eq!(selected_proofs[1].amount, 8.into()); @@ -559,10 +633,21 @@ mod tests { #[test] fn test_select_proofs_many_ones() { + let active_id = id(); + let mut fee_and_keyset_amounts = HashMap::new(); + fee_and_keyset_amounts.insert( + active_id, + (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(), + ); let proofs = (0..1024).map(|_| proof(1)).collect::>(); - let selected_proofs = - Wallet::select_proofs(1024.into(), proofs, &vec![id()], &HashMap::new(), false) - .unwrap(); + let selected_proofs = Wallet::select_proofs( + 1024.into(), + proofs, + &vec![active_id], + &fee_and_keyset_amounts, + false, + ) + .unwrap(); assert_eq!(selected_proofs.len(), 1024); selected_proofs .iter() @@ -571,10 +656,21 @@ mod tests { #[test] fn test_select_proof_change() { + let active_id = id(); + let mut keyset_fee_and_amounts = HashMap::new(); + keyset_fee_and_amounts.insert( + active_id, + (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(), + ); let proofs = vec![proof(64), proof(4), proof(32)]; - let (selected_proofs, exchange) = - Wallet::select_exact_proofs(97.into(), proofs, &vec![id()], &HashMap::new(), false) - .unwrap(); + let (selected_proofs, exchange) = Wallet::select_exact_proofs( + 97.into(), + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + false, + ) + .unwrap(); assert!(exchange.is_some()); let (proof_to_exchange, amount) = exchange.unwrap(); @@ -585,14 +681,20 @@ mod tests { #[test] fn test_select_proofs_huge_proofs() { + let active_id = id(); + let mut keyset_fee_and_amounts = HashMap::new(); + keyset_fee_and_amounts.insert( + active_id, + (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(), + ); let proofs = (0..32) .flat_map(|i| (0..5).map(|_| proof(1 << i)).collect::>()) .collect::>(); let mut selected_proofs = Wallet::select_proofs( ((1u64 << 32) - 1).into(), proofs, - &vec![id()], - &HashMap::new(), + &vec![active_id], + &keyset_fee_and_amounts, false, ) .unwrap(); @@ -608,10 +710,16 @@ mod tests { #[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(); + let mut keyset_fee_and_amounts = HashMap::new(); + keyset_fee_and_amounts.insert(id(), (100, (0..32).map(|x| 2u64.pow(x)).collect()).into()); + let selected_proofs = Wallet::select_proofs( + 10.into(), + proofs, + &vec![id()], + &keyset_fee_and_amounts, + 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 0bab22d7..0f41f8e9 100644 --- a/crates/cdk/src/wallet/send.rs +++ b/crates/cdk/src/wallet/send.rs @@ -39,7 +39,7 @@ impl Wallet { } // Get keyset fees from localstore - let keyset_fees = self.get_keyset_fees().await?; + let keyset_fees = self.get_keyset_fees_and_amounts().await?; // Get available proofs matching conditions let mut available_proofs = self @@ -129,11 +129,13 @@ impl Wallet { force_swap: bool, ) -> Result { // Split amount with fee if necessary + let active_keyset_id = self.get_active_keyset().await?.id; + let fee_and_amounts = self + .get_keyset_fees_and_amounts_by_id(active_keyset_id) + .await?; let (send_amounts, send_fee) = if opts.include_fee { - let active_keyset_id = self.get_active_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)?; + tracing::debug!("Keyset fee per proof: {:?}", fee_and_amounts.fee()); + let send_split = amount.split_with_fee(&fee_and_amounts)?; let send_fee = self .get_proofs_fee_by_count( vec![(active_keyset_id, send_split.len() as u64)] @@ -143,7 +145,7 @@ impl Wallet { .await?; (send_split, send_fee) } else { - let send_split = amount.split(); + let send_split = amount.split(&fee_and_amounts); let send_fee = Amount::ZERO; (send_split, send_fee) }; @@ -265,7 +267,10 @@ impl PreparedSend { tracing::debug!("Active keyset ID: {:?}", active_keyset_id); // Get keyset fees - let keyset_fee_ppk = self.wallet.get_keyset_fees_by_id(active_keyset_id).await?; + let keyset_fee_ppk = self + .wallet + .get_keyset_fees_and_amounts_by_id(active_keyset_id) + .await?; tracing::debug!("Keyset fees: {:?}", keyset_fee_ppk); // Calculate total send amount diff --git a/crates/cdk/src/wallet/swap.rs b/crates/cdk/src/wallet/swap.rs index 8adfef6c..6e52eeb7 100644 --- a/crates/cdk/src/wallet/swap.rs +++ b/crates/cdk/src/wallet/swap.rs @@ -40,6 +40,9 @@ impl Wallet { let swap_response = self.client.post_swap(pre_swap.swap_request).await?; let active_keyset_id = pre_swap.pre_mint_secrets.keyset_id; + let fee_and_amounts = self + .get_keyset_fees_and_amounts_by_id(active_keyset_id) + .await?; let active_keys = self .localstore @@ -74,7 +77,8 @@ impl Wallet { let mut proofs_to_send = Proofs::new(); let mut proofs_to_keep = Proofs::new(); - let mut amount_split = amount.split_targeted(&amount_split_target)?; + let mut amount_split = + amount.split_targeted(&amount_split_target, &fee_and_amounts)?; for proof in all_proofs { if let Some(idx) = amount_split.iter().position(|&a| a == proof.amount) @@ -172,7 +176,7 @@ impl Wallet { .map(|k| k.id) .collect(); - let keyset_fees = self.get_keyset_fees().await?; + let keyset_fees = self.get_keyset_fees_and_amounts().await?; let proofs = Wallet::select_proofs( amount, available_proofs, @@ -224,11 +228,15 @@ impl Wallet { .checked_sub(total_to_subtract) .ok_or(Error::InsufficientFunds)?; + let fee_and_amounts = self + .get_keyset_fees_and_amounts_by_id(active_keyset_id) + .await?; + let (send_amount, change_amount) = match include_fees { true => { let split_count = amount .unwrap_or(Amount::ZERO) - .split_targeted(&SplitTarget::default()) + .split_targeted(&SplitTarget::default(), &fee_and_amounts) .unwrap() .len(); @@ -251,7 +259,10 @@ impl Wallet { // 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?, + SplitTarget::None => { + self.determine_split_target_values(change_amount, &fee_and_amounts) + .await? + } s => s, }; @@ -261,15 +272,19 @@ impl Wallet { let total_secrets_needed = match spending_conditions { Some(_) => { // For spending conditions, we only need to count change secrets - change_amount.split_targeted(&change_split_target)?.len() as u32 + change_amount + .split_targeted(&change_split_target, &fee_and_amounts)? + .len() as u32 } None => { // For no spending conditions, count both send and change secrets let send_count = send_amount .unwrap_or(Amount::ZERO) - .split_targeted(&SplitTarget::default())? + .split_targeted(&SplitTarget::default(), &fee_and_amounts)? + .len() as u32; + let change_count = change_amount + .split_targeted(&change_split_target, &fee_and_amounts)? .len() as u32; - let change_count = change_amount.split_targeted(&change_split_target)?.len() as u32; send_count + change_count } }; @@ -302,6 +317,7 @@ impl Wallet { &self.seed, change_amount, &change_split_target, + &fee_and_amounts, )?; derived_secret_count = change_premint_secrets.len(); @@ -312,6 +328,7 @@ impl Wallet { send_amount.unwrap_or(Amount::ZERO), &SplitTarget::default(), &conditions, + &fee_and_amounts, )?, change_premint_secrets, ) @@ -323,6 +340,7 @@ impl Wallet { &self.seed, send_amount.unwrap_or(Amount::ZERO), &SplitTarget::default(), + &fee_and_amounts, )?; count += premint_secrets.len() as u32; @@ -333,6 +351,7 @@ impl Wallet { &self.seed, change_amount, &change_split_target, + &fee_and_amounts, )?; derived_secret_count = change_premint_secrets.len() + premint_secrets.len();