From 0f0b78c0b0e330b5b1ca3f30f6f17b402c781d92 Mon Sep 17 00:00:00 2001 From: C Date: Wed, 24 Sep 2025 09:02:24 -0300 Subject: [PATCH 01/17] Include supported amounts instead of assuming the power of 2 (#1055) * Include supported amounts instead of assuming the power of 2 The mint's signatory defines the amounts and the wallet, and the mint, when paying, should use them instead of assuming the supported amounts are 2^(0..32), which is not part of the spec. * Introduce FeeAndAmount struct --- crates/cashu/src/amount.rs | 153 ++++++++++---- crates/cashu/src/nuts/nut00/mod.rs | 8 +- crates/cashu/src/nuts/nut13.rs | 9 +- crates/cdk-common/src/database/mint/mod.rs | 8 +- crates/cdk-ffi/src/wallet.rs | 11 +- crates/cdk-integration-tests/tests/bolt12.rs | 9 +- .../tests/fake_wallet.rs | 152 +++++++++++--- .../tests/happy_path_mint_wallet.rs | 10 +- .../tests/integration_tests_pure.rs | 140 ++++++++++--- crates/cdk-integration-tests/tests/regtest.rs | 10 +- crates/cdk-signatory/src/proto/convert.rs | 3 + .../cdk-signatory/src/proto/signatory.proto | 1 + crates/cdk-signatory/src/signatory.rs | 5 +- crates/cdk/src/mint/melt.rs | 19 +- crates/cdk/src/wallet/auth/auth_wallet.rs | 29 ++- crates/cdk/src/wallet/issue/issue_bolt11.rs | 8 +- crates/cdk/src/wallet/issue/issue_bolt12.rs | 7 +- crates/cdk/src/wallet/keysets.rs | 27 ++- crates/cdk/src/wallet/melt/melt_bolt11.rs | 2 +- crates/cdk/src/wallet/mod.rs | 40 ++-- crates/cdk/src/wallet/proofs.rs | 188 ++++++++++++++---- crates/cdk/src/wallet/send.rs | 19 +- crates/cdk/src/wallet/swap.rs | 33 ++- 23 files changed, 691 insertions(+), 200 deletions(-) diff --git a/crates/cashu/src/amount.rs b/crates/cashu/src/amount.rs index db55528bab..64c8f3f846 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 4858293475..bc30a8ec69 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 66fcf808ac..c8841b041c 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 01af134eef..922c9d29de 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 04d2035f8c..b342e14276 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 45c7458d43..76d7e8d1f3 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 1e01eed030..7237c66f1b 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 ba2a06bd95..d0389b9a68 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 8cb645e7cb..9ab1fb0030 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 e726ab253f..ce0a73cd75 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 91692cd007..da5a3fddbf 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 5b82408a21..3da00f9713 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 1b7e2257bb..0b987a72f1 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 6ceb9bb308..a226a03244 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 d61f2bb5b6..58e683e899 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 dfe17a526b..98ac37805d 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 1673ac894a..470f25827f 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 302b8e85d1..47f0882043 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 050f7a8394..5583b763d2 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 43c738d021..c2adec46e9 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 = 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)); + 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)); - for _i in 0..count_needed { - acc.push(Amount::from(*amount as u64)); - } + for _i in 0..count_needed { + acc.push(Amount::from(*amount)); + } - 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 2fe0c9e673..c90488e607 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 0bab22d7fe..0f41f8e967 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 8adfef6cc7..6e52eeb7d4 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(); From f41ce0a3fbc7690cc873e8585a01afa8df5b8692 Mon Sep 17 00:00:00 2001 From: asmo Date: Wed, 24 Sep 2025 15:56:51 +0200 Subject: [PATCH 02/17] optional client identity in grpc payment processor (#1084) * fix: update TLS configuration to handle missing client certificates gracefully --- .../cdk-payment-processor/src/proto/client.rs | 33 +++++++------------ 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/crates/cdk-payment-processor/src/proto/client.rs b/crates/cdk-payment-processor/src/proto/client.rs index d88876af0b..bd6482c390 100644 --- a/crates/cdk-payment-processor/src/proto/client.rs +++ b/crates/cdk-payment-processor/src/proto/client.rs @@ -47,32 +47,23 @@ impl PaymentProcessorClient { // Check for client.pem let client_pem_path = tls_dir.join("client.pem"); - if !client_pem_path.exists() { - let err_msg = format!( - "Client certificate file not found: {}", - client_pem_path.display() - ); - tracing::error!("{}", err_msg); - return Err(anyhow!(err_msg)); - } // Check for client.key let client_key_path = tls_dir.join("client.key"); - if !client_key_path.exists() { - let err_msg = format!("Client key file not found: {}", client_key_path.display()); - tracing::error!("{}", err_msg); - return Err(anyhow!(err_msg)); - } - + // check for ca cert let server_root_ca_cert = std::fs::read_to_string(&ca_pem_path)?; let server_root_ca_cert = Certificate::from_pem(server_root_ca_cert); - let client_cert = std::fs::read_to_string(&client_pem_path)?; - let client_key = std::fs::read_to_string(&client_key_path)?; - let client_identity = Identity::from_pem(client_cert, client_key); - let tls = ClientTlsConfig::new() - .ca_certificate(server_root_ca_cert) - .identity(client_identity); - + let tls: ClientTlsConfig = match client_pem_path.exists() && client_key_path.exists() { + true => { + let client_cert = std::fs::read_to_string(&client_pem_path)?; + let client_key = std::fs::read_to_string(&client_key_path)?; + let client_identity = Identity::from_pem(client_cert, client_key); + ClientTlsConfig::new() + .ca_certificate(server_root_ca_cert) + .identity(client_identity) + } + false => ClientTlsConfig::new().ca_certificate(server_root_ca_cert), + }; Channel::from_shared(addr)? .tls_config(tls)? .connect() From e07a9c8e1eebd16b772775d2d305983b62ef7149 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Thu, 25 Sep 2025 08:31:57 +0100 Subject: [PATCH 03/17] feat(cdk): add WebSocket authentication support (#1116) * feat(cdk): add WebSocket authentication support with comprehensive configuration - Add WebSocket auth token injection for client connections - Implement server-side WebSocket authentication verification - Add configuration options for per-endpoint WebSocket auth types - Include comprehensive documentation and example configuration - Support clear, blind, and no-auth modes for WebSocket endpoin --- crates/cashu/src/nuts/auth/nut21.rs | 4 ++ crates/cdk-axum/src/router_handlers.rs | 17 +++++- .../src/bin/start_fake_auth_mint.rs | 1 + crates/cdk-mintd/src/config.rs | 3 + crates/cdk-mintd/src/env_vars/auth.rs | 10 ++++ crates/cdk-mintd/src/lib.rs | 6 ++ .../src/wallet/mint_connector/http_client.rs | 2 +- crates/cdk/src/wallet/subscription/ws.rs | 57 ++++++++++++++++++- 8 files changed, 95 insertions(+), 5 deletions(-) diff --git a/crates/cashu/src/nuts/auth/nut21.rs b/crates/cashu/src/nuts/auth/nut21.rs index 6392bd4ae2..7ecc798b6b 100644 --- a/crates/cashu/src/nuts/auth/nut21.rs +++ b/crates/cashu/src/nuts/auth/nut21.rs @@ -161,6 +161,10 @@ pub enum RoutePath { /// Bolt12 Quote #[serde(rename = "/v1/melt/bolt12")] MeltBolt12, + + /// WebSocket + #[serde(rename = "/v1/ws")] + Ws, } /// Returns [`RoutePath`]s that match regex diff --git a/crates/cdk-axum/src/router_handlers.rs b/crates/cdk-axum/src/router_handlers.rs index 207f34c742..ab70e97723 100644 --- a/crates/cdk-axum/src/router_handlers.rs +++ b/crates/cdk-axum/src/router_handlers.rs @@ -219,10 +219,23 @@ pub(crate) async fn get_check_mint_bolt11_quote( #[instrument(skip_all)] pub(crate) async fn ws_handler( + #[cfg(feature = "auth")] auth: AuthHeader, State(state): State, ws: WebSocketUpgrade, -) -> impl IntoResponse { - ws.on_upgrade(|ws| main_websocket(ws, state)) +) -> Result { + #[cfg(feature = "auth")] + { + state + .mint + .verify_auth( + auth.into(), + &ProtectedEndpoint::new(Method::Get, RoutePath::Ws), + ) + .await + .map_err(into_response)?; + } + + Ok(ws.on_upgrade(|ws| main_websocket(ws, state))) } /// Mint tokens by paying a BOLT11 Lightning invoice. diff --git a/crates/cdk-integration-tests/src/bin/start_fake_auth_mint.rs b/crates/cdk-integration-tests/src/bin/start_fake_auth_mint.rs index 43e5e4a8e4..7d7dc8e1ad 100644 --- a/crates/cdk-integration-tests/src/bin/start_fake_auth_mint.rs +++ b/crates/cdk-integration-tests/src/bin/start_fake_auth_mint.rs @@ -85,6 +85,7 @@ async fn start_fake_auth_mint( swap: AuthType::Blind, restore: AuthType::Blind, check_proof_state: AuthType::Blind, + websocket_auth: AuthType::Blind, }); // Set description for the mint diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index fa5bc6ff3e..a5b2ed9e33 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -457,6 +457,9 @@ pub struct Auth { pub restore: AuthType, #[serde(default)] pub check_proof_state: AuthType, + /// Enable WebSocket authentication support + #[serde(default = "default_blind")] + pub websocket_auth: AuthType, } fn default_blind() -> AuthType { diff --git a/crates/cdk-mintd/src/env_vars/auth.rs b/crates/cdk-mintd/src/env_vars/auth.rs index 62d3259773..c80e647551 100644 --- a/crates/cdk-mintd/src/env_vars/auth.rs +++ b/crates/cdk-mintd/src/env_vars/auth.rs @@ -17,6 +17,10 @@ pub const ENV_AUTH_CHECK_MELT_QUOTE: &str = "CDK_MINTD_AUTH_CHECK_MELT_QUOTE"; pub const ENV_AUTH_SWAP: &str = "CDK_MINTD_AUTH_SWAP"; pub const ENV_AUTH_RESTORE: &str = "CDK_MINTD_AUTH_RESTORE"; pub const ENV_AUTH_CHECK_PROOF_STATE: &str = "CDK_MINTD_AUTH_CHECK_PROOF_STATE"; +pub const ENV_AUTH_WEBSOCKET: &str = "CDK_MINTD_AUTH_WEBSOCKET"; +pub const ENV_AUTH_WS_MINT_QUOTE: &str = "CDK_MINTD_AUTH_WS_MINT_QUOTE"; +pub const ENV_AUTH_WS_MELT_QUOTE: &str = "CDK_MINTD_AUTH_WS_MELT_QUOTE"; +pub const ENV_AUTH_WS_PROOF_STATE: &str = "CDK_MINTD_AUTH_WS_PROOF_STATE"; impl Auth { pub fn from_env(mut self) -> Self { @@ -94,6 +98,12 @@ impl Auth { } } + if let Ok(ws_auth_str) = env::var(ENV_AUTH_WEBSOCKET) { + if let Ok(auth_type) = ws_auth_str.parse() { + self.websocket_auth = auth_type; + } + } + self } } diff --git a/crates/cdk-mintd/src/lib.rs b/crates/cdk-mintd/src/lib.rs index 2a058e950e..34ed483390 100644 --- a/crates/cdk-mintd/src/lib.rs +++ b/crates/cdk-mintd/src/lib.rs @@ -792,6 +792,12 @@ async fn setup_authentication( add_endpoint(state_protected_endpoint, &auth_settings.check_proof_state); } + // Ws endpoint + { + let ws_protected_endpoint = ProtectedEndpoint::new(Method::Get, RoutePath::Ws); + add_endpoint(ws_protected_endpoint, &auth_settings.websocket_auth); + } + mint_builder = mint_builder.with_auth( auth_localstore.clone(), auth_settings.openid_discovery, diff --git a/crates/cdk/src/wallet/mint_connector/http_client.rs b/crates/cdk/src/wallet/mint_connector/http_client.rs index c2f29bc969..1a954a7240 100644 --- a/crates/cdk/src/wallet/mint_connector/http_client.rs +++ b/crates/cdk/src/wallet/mint_connector/http_client.rs @@ -71,7 +71,7 @@ where /// Get auth token for a protected endpoint #[cfg(feature = "auth")] #[instrument(skip(self))] - async fn get_auth_token( + pub async fn get_auth_token( &self, method: Method, path: RoutePath, diff --git a/crates/cdk/src/wallet/subscription/ws.rs b/crates/cdk/src/wallet/subscription/ws.rs index d39fe2381a..f0204a9015 100644 --- a/crates/cdk/src/wallet/subscription/ws.rs +++ b/crates/cdk/src/wallet/subscription/ws.rs @@ -4,9 +4,12 @@ use std::sync::Arc; use cdk_common::subscription::Params; use cdk_common::ws::{WsMessageOrResponse, WsMethodRequest, WsRequest, WsUnsubscribeRequest}; +#[cfg(feature = "auth")] +use cdk_common::{Method, RoutePath}; use futures::{SinkExt, StreamExt}; use tokio::sync::{mpsc, RwLock}; use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::Message; use super::http::http_main; @@ -37,14 +40,64 @@ pub async fn ws_main( url.set_scheme("ws").expect("Could not set scheme"); } - let url = url.to_string(); + let request = match url.to_string().into_client_request() { + Ok(req) => req, + Err(err) => { + tracing::error!("Failed to create client request: {:?}", err); + // Fallback to HTTP client if we can't create the WebSocket request + return http_main( + std::iter::empty(), + http_client, + subscriptions, + new_subscription_recv, + on_drop, + wallet, + ) + .await; + } + }; let mut active_subscriptions = HashMap::>::new(); let mut failure_count = 0; loop { + let mut request_clone = request.clone(); + #[cfg(feature = "auth")] + { + let auth_wallet = http_client.get_auth_wallet().await; + let token = match auth_wallet.as_ref() { + Some(auth_wallet) => { + let endpoint = cdk_common::ProtectedEndpoint::new(Method::Get, RoutePath::Ws); + match auth_wallet.get_auth_for_request(&endpoint).await { + Ok(token) => token, + Err(err) => { + tracing::warn!("Failed to get auth token: {:?}", err); + None + } + } + } + None => None, + }; + + if let Some(auth_token) = token { + let header_key = match &auth_token { + cdk_common::AuthToken::ClearAuth(_) => "Clear-auth", + cdk_common::AuthToken::BlindAuth(_) => "Blind-auth", + }; + + match auth_token.to_string().parse() { + Ok(header_value) => { + request_clone.headers_mut().insert(header_key, header_value); + } + Err(err) => { + tracing::warn!("Failed to parse auth token as header value: {:?}", err); + } + } + } + } + tracing::debug!("Connecting to {}", url); - let ws_stream = match connect_async(&url).await { + let ws_stream = match connect_async(request_clone.clone()).await { Ok((ws_stream, _)) => ws_stream, Err(err) => { failure_count += 1; From 9d4559a47af4b512a812adca6d1b56d00c66d657 Mon Sep 17 00:00:00 2001 From: gudnuf <108303703+gudnuf@users.noreply.github.com> Date: Thu, 25 Sep 2025 00:57:41 -0700 Subject: [PATCH 04/17] fix: only settle same unit quote internally (#1115) --- crates/cdk/src/mint/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/cdk/src/mint/mod.rs b/crates/cdk/src/mint/mod.rs index 45c11c3999..3f99691cfb 100644 --- a/crates/cdk/src/mint/mod.rs +++ b/crates/cdk/src/mint/mod.rs @@ -887,9 +887,9 @@ impl Mint { .get_mint_quote_by_request(&melt_quote.request.to_string()) .await { - Ok(Some(mint_quote)) => mint_quote, - // Not an internal melt -> mint - Ok(None) => return Ok(None), + Ok(Some(mint_quote)) if mint_quote.unit == melt_quote.unit => mint_quote, + // Not an internal melt -> mint or unit mismatch + Ok(_) => return Ok(None), Err(err) => { tracing::debug!("Error attempting to get mint quote: {}", err); return Err(Error::Internal); From caba6978e750946d5803f9a05e2edbdc7869dfd2 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Thu, 25 Sep 2025 16:18:37 +0100 Subject: [PATCH 05/17] refactor(cdk): simplify mint addition in MultiMintWallet (#1121) Remove unnecessary mint info fetching and keyset refresh during mint addition, and improve error propagation in token restoration flow --- crates/cdk/src/wallet/multi_mint_wallet.rs | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/crates/cdk/src/wallet/multi_mint_wallet.rs b/crates/cdk/src/wallet/multi_mint_wallet.rs index 6906f04dbe..ad34a4592a 100644 --- a/crates/cdk/src/wallet/multi_mint_wallet.rs +++ b/crates/cdk/src/wallet/multi_mint_wallet.rs @@ -205,9 +205,6 @@ impl MultiMintWallet { )? }; - wallet.fetch_mint_info().await?; - wallet.refresh_keysets().await?; - let mut wallets = self.wallets.write().await; wallets.insert(mint_url, wallet); @@ -242,13 +239,7 @@ impl MultiMintWallet { if mint_has_proofs_for_unit { // Add mint to the MultiMintWallet if not already present if !self.has_mint(&mint_url).await { - if let Err(err) = self.add_mint(mint_url.clone(), None).await { - tracing::error!( - "Could not add {} to wallet {}.", - mint_url, - err.to_string() - ); - } + self.add_mint(mint_url.clone(), None).await? } } } From 500d162f6743251f2808ec6f73517ab03269b700 Mon Sep 17 00:00:00 2001 From: gudnuf <108303703+gudnuf@users.noreply.github.com> Date: Thu, 25 Sep 2025 10:57:54 -0700 Subject: [PATCH 06/17] fix: handle fiat melt amount conversions (#1109) * fix: handle fiat melt amount conversions * feat: add check that processor returns quote unit --------- Co-authored-by: thesimplekid --- crates/cdk-fake-wallet/src/lib.rs | 183 ++++++++++++++++++++++++++++-- crates/cdk/src/mint/melt.rs | 58 ++++++---- 2 files changed, 205 insertions(+), 36 deletions(-) diff --git a/crates/cdk-fake-wallet/src/lib.rs b/crates/cdk-fake-wallet/src/lib.rs index 58630d143a..80e221ea47 100644 --- a/crates/cdk-fake-wallet/src/lib.rs +++ b/crates/cdk-fake-wallet/src/lib.rs @@ -18,6 +18,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::pin::Pin; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::time::{Duration, Instant}; use async_trait::async_trait; use bitcoin::hashes::{sha256, Hash}; @@ -50,6 +51,139 @@ pub mod error; /// Default maximum size for the secondary repayment queue const DEFAULT_REPAY_QUEUE_MAX_SIZE: usize = 100; +/// Cache duration for exchange rate (5 minutes) +const RATE_CACHE_DURATION: Duration = Duration::from_secs(300); + +/// Mempool.space prices API response structure +#[derive(Debug, Deserialize)] +struct MempoolPricesResponse { + #[serde(rename = "USD")] + usd: f64, + #[serde(rename = "EUR")] + eur: f64, +} + +/// Exchange rate cache with built-in fallback rates +#[derive(Debug, Clone)] +struct ExchangeRateCache { + rates: Arc>>, +} + +impl ExchangeRateCache { + fn new() -> Self { + Self { + rates: Arc::new(Mutex::new(None)), + } + } + + /// Get current BTC rate for the specified currency with caching and fallback + async fn get_btc_rate(&self, currency: &CurrencyUnit) -> Result { + // Return cached rate if still valid + { + let cached_rates = self.rates.lock().await; + if let Some((rates, timestamp)) = &*cached_rates { + if timestamp.elapsed() < RATE_CACHE_DURATION { + return Self::rate_for_currency(rates, currency); + } + } + } + + // Try to fetch fresh rates, fallback on error + match self.fetch_fresh_rate(currency).await { + Ok(rate) => Ok(rate), + Err(e) => { + tracing::warn!( + "Failed to fetch exchange rates, using fallback for {:?}: {}", + currency, + e + ); + Self::fallback_rate(currency) + } + } + } + + /// Fetch fresh rate and update cache + async fn fetch_fresh_rate(&self, currency: &CurrencyUnit) -> Result { + let url = "https://mempool.space/api/v1/prices"; + let response = reqwest::get(url) + .await + .map_err(|_| Error::UnknownInvoiceAmount)? + .json::() + .await + .map_err(|_| Error::UnknownInvoiceAmount)?; + + let rate = Self::rate_for_currency(&response, currency)?; + *self.rates.lock().await = Some((response, Instant::now())); + Ok(rate) + } + + fn rate_for_currency( + rates: &MempoolPricesResponse, + currency: &CurrencyUnit, + ) -> Result { + match currency { + CurrencyUnit::Usd => Ok(rates.usd), + CurrencyUnit::Eur => Ok(rates.eur), + _ => Err(Error::UnknownInvoiceAmount), + } + } + + fn fallback_rate(currency: &CurrencyUnit) -> Result { + match currency { + CurrencyUnit::Usd => Ok(110_000.0), // $110k per BTC + CurrencyUnit::Eur => Ok(95_000.0), // €95k per BTC + _ => Err(Error::UnknownInvoiceAmount), + } + } +} + +async fn convert_currency_amount( + amount: u64, + from_unit: &CurrencyUnit, + target_unit: &CurrencyUnit, + rate_cache: &ExchangeRateCache, +) -> Result { + use CurrencyUnit::*; + + // Try basic unit conversion first (handles SAT/MSAT and same-unit conversions) + if let Ok(converted) = to_unit(amount, from_unit, target_unit) { + return Ok(converted); + } + + // Handle fiat <-> bitcoin conversions that require exchange rates + match (from_unit, target_unit) { + // Fiat to Bitcoin conversions + (Usd | Eur, Sat) => { + let rate = rate_cache.get_btc_rate(from_unit).await?; + let fiat_amount = amount as f64 / 100.0; // cents to dollars/euros + Ok(Amount::from( + (fiat_amount / rate * 100_000_000.0).round() as u64 + )) // to sats + } + (Usd | Eur, Msat) => { + let rate = rate_cache.get_btc_rate(from_unit).await?; + let fiat_amount = amount as f64 / 100.0; // cents to dollars/euros + Ok(Amount::from( + (fiat_amount / rate * 100_000_000_000.0).round() as u64, + )) // to msats + } + + // Bitcoin to fiat conversions + (Sat, Usd | Eur) => { + let rate = rate_cache.get_btc_rate(target_unit).await?; + let btc_amount = amount as f64 / 100_000_000.0; // sats to BTC + Ok(Amount::from((btc_amount * rate * 100.0).round() as u64)) // to cents + } + (Msat, Usd | Eur) => { + let rate = rate_cache.get_btc_rate(target_unit).await?; + let btc_amount = amount as f64 / 100_000_000_000.0; // msats to BTC + Ok(Amount::from((btc_amount * rate * 100.0).round() as u64)) // to cents + } + + _ => Err(Error::UnknownInvoiceAmount), // Unsupported conversion + } +} + /// Secondary repayment queue manager for any-amount invoices #[derive(Debug, Clone)] struct SecondaryRepaymentQueue { @@ -201,6 +335,7 @@ pub struct FakeWallet { incoming_payments: Arc>>>, unit: CurrencyUnit, secondary_repayment_queue: SecondaryRepaymentQueue, + exchange_rate_cache: ExchangeRateCache, } impl FakeWallet { @@ -249,6 +384,7 @@ impl FakeWallet { incoming_payments, unit, secondary_repayment_queue, + exchange_rate_cache: ExchangeRateCache::new(), } } } @@ -376,7 +512,13 @@ impl MintPayment for FakeWallet { } }; - let amount = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?; + let amount = convert_currency_amount( + amount_msat, + &CurrencyUnit::Msat, + unit, + &self.exchange_rate_cache, + ) + .await?; let relative_fee_reserve = (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; @@ -441,7 +583,13 @@ impl MintPayment for FakeWallet { .ok_or(Error::UnknownInvoiceAmount)? }; - let total_spent = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?; + let total_spent = convert_currency_amount( + amount_msat, + &CurrencyUnit::Msat, + unit, + &self.exchange_rate_cache, + ) + .await?; Ok(MakePaymentResponse { payment_proof: Some("".to_string()), @@ -466,7 +614,13 @@ impl MintPayment for FakeWallet { } }; - let total_spent = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?; + let total_spent = convert_currency_amount( + amount_msat, + &CurrencyUnit::Msat, + unit, + &self.exchange_rate_cache, + ) + .await?; Ok(MakePaymentResponse { payment_proof: Some("".to_string()), @@ -499,7 +653,13 @@ impl MintPayment for FakeWallet { let offer_builder = match amount { Some(amount) => { - let amount_msat = to_unit(amount, unit, &CurrencyUnit::Msat)?; + let amount_msat = convert_currency_amount( + u64::from(amount), + unit, + &CurrencyUnit::Msat, + &self.exchange_rate_cache, + ) + .await?; offer_builder.amount_msats(amount_msat.into()) } None => offer_builder, @@ -519,13 +679,14 @@ impl MintPayment for FakeWallet { let amount = bolt11_options.amount; let expiry = bolt11_options.unix_expiry; - // For fake invoices, always use msats regardless of unit - let amount_msat = if unit == &CurrencyUnit::Sat { - u64::from(amount) * 1000 - } else { - // If unit is Msat, use as-is - u64::from(amount) - }; + let amount_msat = convert_currency_amount( + u64::from(amount), + unit, + &CurrencyUnit::Msat, + &self.exchange_rate_cache, + ) + .await? + .into(); let invoice = create_fake_invoice(amount_msat, description.clone()); let payment_hash = invoice.payment_hash(); diff --git a/crates/cdk/src/mint/melt.rs b/crates/cdk/src/mint/melt.rs index a226a03244..f237a70059 100644 --- a/crates/cdk/src/mint/melt.rs +++ b/crates/cdk/src/mint/melt.rs @@ -142,19 +142,6 @@ impl Mint { .. } = melt_request; - let amount_msats = melt_request.amount_msat()?; - - let amount_quote_unit = to_unit(amount_msats, &CurrencyUnit::Msat, unit)?; - - self.check_melt_request_acceptable( - amount_quote_unit, - unit.clone(), - PaymentMethod::Bolt11, - request.to_string(), - *options, - ) - .await?; - let ln = self .payment_processors .get(&PaymentProcessorKey::new( @@ -196,6 +183,20 @@ impl Mint { Error::UnsupportedUnit })?; + if &payment_quote.unit != unit { + return Err(Error::UnitMismatch); + } + + // Validate using processor quote amount for currency conversion + self.check_melt_request_acceptable( + payment_quote.amount, + unit.clone(), + PaymentMethod::Bolt11, + request.to_string(), + *options, + ) + .await?; + let melt_ttl = self.quote_ttl().await?.melt_ttl; let quote = MeltQuote::new( @@ -215,7 +216,7 @@ impl Mint { "New {} melt quote {} for {} {} with request id {:?}", quote.payment_method, quote.id, - amount_quote_unit, + payment_quote.amount, unit, payment_quote.request_lookup_id ); @@ -251,15 +252,6 @@ impl Mint { None => amount_for_offer(&offer, unit).map_err(|_| Error::UnsupportedUnit)?, }; - self.check_melt_request_acceptable( - amount, - unit.clone(), - PaymentMethod::Bolt12, - request.clone(), - *options, - ) - .await?; - let ln = self .payment_processors .get(&PaymentProcessorKey::new( @@ -297,6 +289,20 @@ impl Mint { Error::UnsupportedUnit })?; + if &payment_quote.unit != unit { + return Err(Error::UnitMismatch); + } + + // Validate using processor quote amount for currency conversion + self.check_melt_request_acceptable( + payment_quote.amount, + unit.clone(), + PaymentMethod::Bolt12, + request.clone(), + *options, + ) + .await?; + let payment_request = MeltPaymentRequest::Bolt12 { offer: Box::new(offer), }; @@ -506,8 +512,6 @@ impl Mint { unit: input_unit, } = input_verification; - ensure_cdk!(input_unit.is_some(), Error::UnsupportedUnit); - let mut proof_writer = ProofWriter::new(self.localstore.clone(), self.pubsub_manager.clone()); @@ -524,6 +528,10 @@ impl Mint { .update_melt_quote_state(melt_request.quote(), MeltQuoteState::Pending, None) .await?; + if input_unit != Some(quote.unit.clone()) { + return Err(Error::UnitMismatch); + } + match state { MeltQuoteState::Unpaid | MeltQuoteState::Failed => Ok(()), MeltQuoteState::Pending => Err(Error::PendingQuote), From f48660f344d5c5e6a6022daa8a7fe01d8e98304a Mon Sep 17 00:00:00 2001 From: David Caseria Date: Thu, 25 Sep 2025 13:59:01 -0400 Subject: [PATCH 07/17] Make Uniffi Records Codable in Swift (#1125) --- crates/cdk-ffi/uniffi.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/cdk-ffi/uniffi.toml b/crates/cdk-ffi/uniffi.toml index 7b95d596a1..31cb7cb44a 100644 --- a/crates/cdk-ffi/uniffi.toml +++ b/crates/cdk-ffi/uniffi.toml @@ -8,3 +8,5 @@ cdylib_name = "cdk_ffi" [bindings.swift] module_name = "CashuDevKit" cdylib_name = "cdk_ffi" +generate_codable_conformance = true +generate_immutable_records = true From 05acb914feedbae2f0b5f8b2b4c5361339e17297 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Fri, 26 Sep 2025 14:37:43 +0100 Subject: [PATCH 08/17] feat: remove delete fns for quotes (#1122) --- crates/cdk-common/src/database/mint/mod.rs | 6 ++-- crates/cdk-sql-common/src/mint/mod.rs | 22 --------------- crates/cdk/src/mint/issue/mod.rs | 33 ---------------------- 3 files changed, 2 insertions(+), 59 deletions(-) diff --git a/crates/cdk-common/src/database/mint/mod.rs b/crates/cdk-common/src/database/mint/mod.rs index 922c9d29de..9c08734c38 100644 --- a/crates/cdk-common/src/database/mint/mod.rs +++ b/crates/cdk-common/src/database/mint/mod.rs @@ -170,8 +170,7 @@ pub trait QuotesTransaction<'a> { quote_id: &QuoteId, amount_issued: Amount, ) -> Result; - /// Remove [`MintMintQuote`] - async fn remove_mint_quote(&mut self, quote_id: &QuoteId) -> Result<(), Self::Err>; + /// Get [`mint::MeltQuote`] and lock it for update in this transaction async fn get_melt_quote( &mut self, @@ -196,8 +195,7 @@ pub trait QuotesTransaction<'a> { new_state: MeltQuoteState, payment_proof: Option, ) -> Result<(MeltQuoteState, mint::MeltQuote), Self::Err>; - /// Remove [`mint::MeltQuote`] - async fn remove_melt_quote(&mut self, quote_id: &QuoteId) -> Result<(), Self::Err>; + /// Get all [`MintMintQuote`]s and lock it for update in this transaction async fn get_mint_quote_by_request( &mut self, diff --git a/crates/cdk-sql-common/src/mint/mod.rs b/crates/cdk-sql-common/src/mint/mod.rs index 3bd0c4bfca..b3bef8d888 100644 --- a/crates/cdk-sql-common/src/mint/mod.rs +++ b/crates/cdk-sql-common/src/mint/mod.rs @@ -878,14 +878,6 @@ VALUES (:quote_id, :amount, :timestamp); Ok(()) } - async fn remove_mint_quote(&mut self, quote_id: &QuoteId) -> Result<(), Self::Err> { - query(r#"DELETE FROM mint_quote WHERE id=:id"#)? - .bind("id", quote_id.to_string()) - .execute(&self.inner) - .await?; - Ok(()) - } - async fn add_melt_quote(&mut self, quote: mint::MeltQuote) -> Result<(), Self::Err> { // Now insert the new quote query( @@ -1020,20 +1012,6 @@ VALUES (:quote_id, :amount, :timestamp); Ok((old_state, quote)) } - async fn remove_melt_quote(&mut self, quote_id: &QuoteId) -> Result<(), Self::Err> { - query( - r#" - DELETE FROM melt_quote - WHERE id=:id - "#, - )? - .bind("id", quote_id.to_string()) - .execute(&self.inner) - .await?; - - Ok(()) - } - async fn get_mint_quote(&mut self, quote_id: &QuoteId) -> Result, Self::Err> { let payments = get_mint_quote_payments(&self.inner, quote_id).await?; let issuance = get_mint_quote_issuance(&self.inner, quote_id).await?; diff --git a/crates/cdk/src/mint/issue/mod.rs b/crates/cdk/src/mint/issue/mod.rs index 45c0268e32..8104b2e3aa 100644 --- a/crates/cdk/src/mint/issue/mod.rs +++ b/crates/cdk/src/mint/issue/mod.rs @@ -376,39 +376,6 @@ impl Mint { result } - /// Removes a mint quote from the database - /// - /// # Arguments - /// * `quote_id` - The UUID of the quote to remove - /// - /// # Returns - /// * `Ok(())` if removal was successful - /// * `Error` if the quote doesn't exist or removal fails - #[instrument(skip_all)] - pub async fn remove_mint_quote(&self, quote_id: &QuoteId) -> Result<(), Error> { - #[cfg(feature = "prometheus")] - METRICS.inc_in_flight_requests("remove_mint_quote"); - - let result = async { - let mut tx = self.localstore.begin_transaction().await?; - tx.remove_mint_quote(quote_id).await?; - tx.commit().await?; - Ok(()) - } - .await; - - #[cfg(feature = "prometheus")] - { - METRICS.dec_in_flight_requests("remove_mint_quote"); - METRICS.record_mint_operation("remove_mint_quote", result.is_ok()); - if result.is_err() { - METRICS.record_error(); - } - } - - result - } - /// Marks a mint quote as paid based on the payment request ID /// /// Looks up the mint quote by the payment request ID and marks it as paid From 676463f730c2da179b4d73497d9f7c046b44f4a9 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Fri, 26 Sep 2025 14:43:34 +0100 Subject: [PATCH 09/17] Revert "feat: remove redis cache (#1096)" (#1129) This reverts commit be7c83689d2444ba9028e39c9b4a0416eec13a99. --- .github/workflows/ci.yml | 7 +- crates/cdk-axum/Cargo.toml | 4 + crates/cdk-axum/src/cache/backend/mod.rs | 4 + crates/cdk-axum/src/cache/backend/redis.rs | 96 ++++++++++++++++++++++ crates/cdk-axum/src/cache/config.rs | 34 ++++++++ crates/cdk-axum/src/cache/mod.rs | 19 ++++- crates/cdk-mintd/Cargo.toml | 1 + crates/cdk-mintd/example.config.toml | 5 +- docker-compose.yaml | 26 +++++- 9 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 crates/cdk-axum/src/cache/backend/redis.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 649af8b843..ecec7efa5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,7 +103,8 @@ jobs: # HTTP/API layer - consolidated -p cdk-axum, -p cdk-axum --no-default-features, - -p cdk-axum --no-default-features --features swagger, + -p cdk-axum --no-default-features --features redis, + -p cdk-axum --no-default-features --features "redis swagger", # Lightning backends -p cdk-cln, @@ -126,7 +127,7 @@ jobs: --bin cdk-cli --features sqlcipher, --bin cdk-cli --features redb, --bin cdk-mintd, - + --bin cdk-mintd --features redis, --bin cdk-mintd --features sqlcipher, --bin cdk-mintd --no-default-features --features lnd --features sqlite, --bin cdk-mintd --no-default-features --features cln --features postgres, @@ -337,7 +338,7 @@ jobs: -p cdk --no-default-features --features "wallet auth", -p cdk --no-default-features --features "http_subscription", -p cdk-axum, - + -p cdk-axum --no-default-features --features redis, -p cdk-lnbits, -p cdk-fake-wallet, -p cdk-cln, diff --git a/crates/cdk-axum/Cargo.toml b/crates/cdk-axum/Cargo.toml index 5f0a9c930a..7ef7e14540 100644 --- a/crates/cdk-axum/Cargo.toml +++ b/crates/cdk-axum/Cargo.toml @@ -12,6 +12,7 @@ readme = "README.md" [features] default = ["auth"] +redis = ["dep:redis"] swagger = ["cdk/swagger", "dep:utoipa"] auth = ["cdk/auth"] prometheus = ["dep:cdk-prometheus"] @@ -33,6 +34,9 @@ paste = "1.0.15" serde.workspace = true uuid.workspace = true sha2 = "0.10.8" +redis = { version = "0.31.0", features = [ + "tokio-rustls-comp", +], optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] uuid = { workspace = true, features = ["js"] } diff --git a/crates/cdk-axum/src/cache/backend/mod.rs b/crates/cdk-axum/src/cache/backend/mod.rs index cc0e8ad667..511d156f96 100644 --- a/crates/cdk-axum/src/cache/backend/mod.rs +++ b/crates/cdk-axum/src/cache/backend/mod.rs @@ -1,3 +1,7 @@ mod memory; +#[cfg(feature = "redis")] +mod redis; pub use self::memory::InMemoryHttpCache; +#[cfg(feature = "redis")] +pub use self::redis::{Config as RedisConfig, HttpCacheRedis}; diff --git a/crates/cdk-axum/src/cache/backend/redis.rs b/crates/cdk-axum/src/cache/backend/redis.rs new file mode 100644 index 0000000000..358350f6f1 --- /dev/null +++ b/crates/cdk-axum/src/cache/backend/redis.rs @@ -0,0 +1,96 @@ +use std::time::Duration; + +use redis::AsyncCommands; +use serde::{Deserialize, Serialize}; + +use crate::cache::{HttpCacheKey, HttpCacheStorage}; + +/// Redis cache storage for the HTTP cache. +/// +/// This cache storage backend uses Redis to store the cache. +pub struct HttpCacheRedis { + cache_ttl: Duration, + prefix: Option>, + client: redis::Client, +} + +/// Configuration for the Redis cache storage. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Config { + /// Commong key prefix + pub key_prefix: Option, + + /// Connection string to the Redis server. + pub connection_string: String, +} + +impl HttpCacheRedis { + /// Create a new Redis cache. + pub fn new(client: redis::Client) -> Self { + Self { + client, + prefix: None, + cache_ttl: Duration::from_secs(60), + } + } + + /// Set a prefix for the cache keys. + /// + /// This is useful to have all the HTTP cache keys under a common prefix, + /// some sort of namespace, to make management of the database easier. + pub fn set_prefix(mut self, prefix: Vec) -> Self { + self.prefix = Some(prefix); + self + } +} + +#[async_trait::async_trait] +impl HttpCacheStorage for HttpCacheRedis { + fn set_expiration_times(&mut self, cache_ttl: Duration, _cache_tti: Duration) { + self.cache_ttl = cache_ttl; + } + + async fn get(&self, key: &HttpCacheKey) -> Option> { + let mut conn = self + .client + .get_multiplexed_tokio_connection() + .await + .map_err(|err| { + tracing::error!("Failed to get redis connection: {:?}", err); + err + }) + .ok()?; + + let mut db_key = self.prefix.clone().unwrap_or_default(); + db_key.extend(&**key); + + conn.get(db_key) + .await + .map_err(|err| { + tracing::error!("Failed to get value from redis: {:?}", err); + err + }) + .ok()? + } + + async fn set(&self, key: HttpCacheKey, value: Vec) { + let mut db_key = self.prefix.clone().unwrap_or_default(); + db_key.extend(&*key); + + let mut conn = match self.client.get_multiplexed_tokio_connection().await { + Ok(conn) => conn, + Err(err) => { + tracing::error!("Failed to get redis connection: {:?}", err); + return; + } + }; + + let _: Result<(), _> = conn + .set_ex(db_key, value, self.cache_ttl.as_secs()) + .await + .map_err(|err| { + tracing::error!("Failed to set value in redis: {:?}", err); + err + }); + } +} diff --git a/crates/cdk-axum/src/cache/config.rs b/crates/cdk-axum/src/cache/config.rs index c507764b25..f50551221d 100644 --- a/crates/cdk-axum/src/cache/config.rs +++ b/crates/cdk-axum/src/cache/config.rs @@ -2,6 +2,11 @@ use serde::{Deserialize, Serialize}; pub const ENV_CDK_MINTD_CACHE_BACKEND: &str = "CDK_MINTD_CACHE_BACKEND"; +#[cfg(feature = "redis")] +pub const ENV_CDK_MINTD_CACHE_REDIS_URL: &str = "CDK_MINTD_CACHE_REDIS_URL"; +#[cfg(feature = "redis")] +pub const ENV_CDK_MINTD_CACHE_REDIS_KEY_PREFIX: &str = "CDK_MINTD_CACHE_REDIS_KEY_PREFIX"; + pub const ENV_CDK_MINTD_CACHE_TTI: &str = "CDK_MINTD_CACHE_TTI"; pub const ENV_CDK_MINTD_CACHE_TTL: &str = "CDK_MINTD_CACHE_TTL"; @@ -11,12 +16,27 @@ pub const ENV_CDK_MINTD_CACHE_TTL: &str = "CDK_MINTD_CACHE_TTL"; pub enum Backend { #[default] Memory, + #[cfg(feature = "redis")] + Redis(super::backend::RedisConfig), } impl Backend { pub fn from_env_str(backend_str: &str) -> Option { match backend_str.to_lowercase().as_str() { "memory" => Some(Self::Memory), + #[cfg(feature = "redis")] + "redis" => { + // Get Redis configuration from environment + let connection_string = std::env::var(ENV_CDK_MINTD_CACHE_REDIS_URL) + .unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + + let key_prefix = std::env::var(ENV_CDK_MINTD_CACHE_REDIS_KEY_PREFIX).ok(); + + Some(Self::Redis(super::backend::RedisConfig { + connection_string, + key_prefix, + })) + } _ => None, } } @@ -46,6 +66,20 @@ impl Config { if let Ok(backend_str) = env::var(ENV_CDK_MINTD_CACHE_BACKEND) { if let Some(backend) = Backend::from_env_str(&backend_str) { self.backend = backend; + + // If Redis backend is selected, parse Redis configuration + #[cfg(feature = "redis")] + if matches!(self.backend, Backend::Redis(_)) { + let connection_string = env::var(ENV_CDK_MINTD_CACHE_REDIS_URL) + .unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + + let key_prefix = env::var(ENV_CDK_MINTD_CACHE_REDIS_KEY_PREFIX).ok(); + + self.backend = Backend::Redis(super::backend::RedisConfig { + connection_string, + key_prefix, + }); + } } } diff --git a/crates/cdk-axum/src/cache/mod.rs b/crates/cdk-axum/src/cache/mod.rs index de56d66667..f07968d0e2 100644 --- a/crates/cdk-axum/src/cache/mod.rs +++ b/crates/cdk-axum/src/cache/mod.rs @@ -7,7 +7,7 @@ //! idempotent operations. //! //! This mod also provides common backend implementations as well, such as In -//! Memory (default). +//! Memory (default) and Redis. use std::ops::Deref; use std::sync::Arc; use std::time::Duration; @@ -89,6 +89,23 @@ impl From for HttpCache { Duration::from_secs(config.tti.unwrap_or(DEFAULT_TTI_SECS)), None, ), + #[cfg(feature = "redis")] + config::Backend::Redis(redis_config) => { + let client = redis::Client::open(redis_config.connection_string) + .expect("Failed to create Redis client"); + let storage = HttpCacheRedis::new(client).set_prefix( + redis_config + .key_prefix + .unwrap_or_default() + .as_bytes() + .to_vec(), + ); + Self::new( + Duration::from_secs(config.ttl.unwrap_or(DEFAULT_TTL_SECS)), + Duration::from_secs(config.tti.unwrap_or(DEFAULT_TTI_SECS)), + Some(Box::new(storage)), + ) + } } } } diff --git a/crates/cdk-mintd/Cargo.toml b/crates/cdk-mintd/Cargo.toml index 85f231109a..ae83b75158 100644 --- a/crates/cdk-mintd/Cargo.toml +++ b/crates/cdk-mintd/Cargo.toml @@ -26,6 +26,7 @@ grpc-processor = ["dep:cdk-payment-processor", "cdk-signatory/grpc"] sqlcipher = ["sqlite", "cdk-sqlite/sqlcipher"] # MSRV is not committed to with swagger enabled swagger = ["cdk-axum/swagger", "dep:utoipa", "dep:utoipa-swagger-ui"] +redis = ["cdk-axum/redis"] auth = ["cdk/auth", "cdk-axum/auth", "cdk-sqlite?/auth", "cdk-postgres?/auth"] prometheus = ["cdk/prometheus", "dep:cdk-prometheus", "cdk-sqlite?/prometheus", "cdk-axum/prometheus"] diff --git a/crates/cdk-mintd/example.config.toml b/crates/cdk-mintd/example.config.toml index 79e0f86754..c65105396c 100644 --- a/crates/cdk-mintd/example.config.toml +++ b/crates/cdk-mintd/example.config.toml @@ -33,10 +33,13 @@ enabled = false #port = 9090 # [info.http_cache] -# backend type: memory (default) +# memory or redis backend = "memory" ttl = 60 tti = 60 +# `key_prefix` and `connection_string` required for redis +# key_prefix = "mintd" +# connection_string = "redis://localhost" # NOTE: If [mint_management_rpc] is enabled these values will only be used on first start up. # Further changes must be made through the rpc. diff --git a/docker-compose.yaml b/docker-compose.yaml index 9a97689481..b318835cc1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -58,6 +58,9 @@ services: # - CDK_MINTD_DATABASE_URL=postgresql://cdk_user:cdk_password@postgres:5432/cdk_mint # Cache configuration - CDK_MINTD_CACHE_BACKEND=memory + # For Redis cache (requires redis service, enable with: docker-compose --profile redis up): + # - CDK_MINTD_CACHE_REDIS_URL=redis://redis:6379 + # - CDK_MINTD_CACHE_REDIS_KEY_PREFIX=cdk-mintd - CDK_MINTD_PROMETHEUS_ENABLED=true - CDK_MINTD_PROMETHEUS_ADDRESS=0.0.0.0 - CDK_MINTD_PROMETHEUS_PORT=9000 @@ -131,13 +134,34 @@ services: # timeout: 5s # retries: 5 - + # Redis cache service (optional) + # Enable with: docker-compose --profile redis up +# redis: +# image: redis:7-alpine +# container_name: mint_redis +# restart: unless-stopped +# profiles: +# - redis +# ports: +# - "6379:6379" +# volumes: +# - redis_data:/data +# command: redis-server --save 60 1 --loglevel warning +# healthcheck: +# test: ["CMD", "redis-cli", "ping"] +# interval: 10s +# timeout: 3s +# retries: 5 volumes: postgres_data: driver: local ldk_node_data: driver: local +# redis_data: +# driver: local + + networks: cdk: From 6d0003a4fceb7738b0e4d0f8d76b8925bbf5fd6e Mon Sep 17 00:00:00 2001 From: lollerfirst <43107113+lollerfirst@users.noreply.github.com> Date: Fri, 26 Sep 2025 21:56:01 +0200 Subject: [PATCH 10/17] token: add spending-condition inspection helpers and token_secrets() (#1124) * token: add Token::token_secrets() and spending-condition helpers - New helpers on Token that do not require mint keysets: - spending_conditions() - p2pk_pubkeys() - p2pk_refund_pubkeys() - htlc_hashes() - locktimes() - Introduce token_secrets() to unify V3/V4 proof traversal and avoid duplication - Bypass short->long keyset-id mapping since only Secret is needed for conditions - Use &Secret for TryFrom to fix compile error --- crates/cashu/src/nuts/nut00/token.rs | 244 +++++++++++++++++- crates/cdk-ffi/src/lib.rs | 1 + crates/cdk-ffi/src/multi_mint_wallet.rs | 1 + crates/cdk-ffi/src/token.rs | 158 ++++++++++++ crates/cdk-ffi/src/types.rs | 93 +------ crates/cdk-ffi/src/wallet.rs | 1 + .../tests/ffi_minting_integration.rs | 2 +- crates/cdk-postgres/src/lib.rs | 5 +- 8 files changed, 407 insertions(+), 98 deletions(-) create mode 100644 crates/cdk-ffi/src/token.rs diff --git a/crates/cashu/src/nuts/nut00/token.rs b/crates/cashu/src/nuts/nut00/token.rs index f054875f7c..a9d4bc40b9 100644 --- a/crates/cashu/src/nuts/nut00/token.rs +++ b/crates/cashu/src/nuts/nut00/token.rs @@ -2,18 +2,20 @@ //! //! -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap, HashSet}; use std::fmt; use std::str::FromStr; use bitcoin::base64::engine::{general_purpose, GeneralPurpose}; use bitcoin::base64::{alphabet, Engine as _}; +use bitcoin::hashes::sha256; use serde::{Deserialize, Serialize}; use super::{Error, Proof, ProofV3, ProofV4, Proofs}; use crate::mint_url::MintUrl; use crate::nut02::ShortKeysetId; -use crate::nuts::{CurrencyUnit, Id}; +use crate::nuts::nut11::SpendingConditions; +use crate::nuts::{CurrencyUnit, Id, Kind, PublicKey}; use crate::{ensure_cdk, Amount, KeySetInfo}; /// Token Enum @@ -128,6 +130,90 @@ impl Token { Self::TokenV4(token) => token.to_raw_bytes(), } } + + /// Return all proof secrets in this token without keyset-id mapping, across V3/V4 + /// This is intended for spending-condition inspection where only the secret matters. + pub fn token_secrets(&self) -> Vec<&crate::secret::Secret> { + match self { + Token::TokenV3(t) => t + .token + .iter() + .flat_map(|kt| kt.proofs.iter().map(|p| &p.secret)) + .collect(), + Token::TokenV4(t) => t + .token + .iter() + .flat_map(|kt| kt.proofs.iter().map(|p| &p.secret)) + .collect(), + } + } + + /// Extract unique spending conditions across all proofs + pub fn spending_conditions(&self) -> Result, Error> { + let mut set = HashSet::new(); + for secret in self.token_secrets().into_iter() { + if let Ok(cond) = SpendingConditions::try_from(secret) { + set.insert(cond); + } + } + Ok(set) + } + + /// Collect pubkeys for P2PK-locked ecash + pub fn p2pk_pubkeys(&self) -> Result, Error> { + let mut keys: HashSet = HashSet::new(); + for secret in self.token_secrets().into_iter() { + if let Ok(cond) = SpendingConditions::try_from(secret) { + if cond.kind() == Kind::P2PK { + if let Some(ps) = cond.pubkeys() { + keys.extend(ps); + } + } + } + } + Ok(keys) + } + + /// Collect refund pubkeys from P2PK conditions + pub fn p2pk_refund_pubkeys(&self) -> Result, Error> { + let mut keys: HashSet = HashSet::new(); + for secret in self.token_secrets().into_iter() { + if let Ok(cond) = SpendingConditions::try_from(secret) { + if cond.kind() == Kind::P2PK { + if let Some(ps) = cond.refund_keys() { + keys.extend(ps); + } + } + } + } + Ok(keys) + } + + /// Collect HTLC hashes + pub fn htlc_hashes(&self) -> Result, Error> { + let mut hashes: HashSet = HashSet::new(); + for secret in self.token_secrets().into_iter() { + if let Ok(SpendingConditions::HTLCConditions { data, .. }) = + SpendingConditions::try_from(secret) + { + hashes.insert(data); + } + } + Ok(hashes) + } + + /// Collect unique locktimes from spending conditions + pub fn locktimes(&self) -> Result, Error> { + let mut set: BTreeSet = BTreeSet::new(); + for secret in self.token_secrets().into_iter() { + if let Ok(cond) = SpendingConditions::try_from(secret) { + if let Some(lt) = cond.locktime() { + set.insert(lt); + } + } + } + Ok(set) + } } impl FromStr for Token { @@ -535,10 +621,13 @@ mod tests { use std::str::FromStr; use bip39::rand::{self, RngCore}; + use bitcoin::hashes::sha256::Hash as Sha256Hash; + use bitcoin::hashes::Hash; use super::*; use crate::dhke::hash_to_curve; use crate::mint_url::MintUrl; + use crate::nuts::nut11::{Conditions, SigFlag, SpendingConditions}; use crate::secret::Secret; use crate::util::hex; @@ -826,4 +915,155 @@ mod tests { let proofs1 = token1.unwrap().proofs(&keysets_info); assert!(proofs1.is_err()); } + #[test] + fn test_token_spending_condition_helpers_p2pk_htlc_v4() { + let mint_url = MintUrl::from_str("https://example.com").unwrap(); + let keyset_id = Id::from_str("009a1f293253e41e").unwrap(); + + // P2PK: base pubkey plus an extra pubkey via tags, refund key, and locktime + let sk1 = crate::nuts::SecretKey::generate(); + let pk1 = sk1.public_key(); + let sk2 = crate::nuts::SecretKey::generate(); + let pk2 = sk2.public_key(); + let refund_sk = crate::nuts::SecretKey::generate(); + let refund_pk = refund_sk.public_key(); + + let cond_p2pk = Conditions { + locktime: Some(1_700_000_000), + pubkeys: Some(vec![pk2]), + refund_keys: Some(vec![refund_pk]), + num_sigs: Some(1), + sig_flag: SigFlag::SigInputs, + num_sigs_refund: None, + }; + + let nut10_p2pk = crate::nuts::Nut10Secret::new( + crate::nuts::Kind::P2PK, + pk1.to_string(), + Some(cond_p2pk.clone()), + ); + let secret_p2pk: Secret = nut10_p2pk.try_into().unwrap(); + + // HTLC: use a known preimage hash and its own locktime + let preimage = b"cdk-test-preimage"; + let htlc_hash = Sha256Hash::hash(preimage); + let cond_htlc = Conditions { + locktime: Some(1_800_000_000), + ..Default::default() + }; + let nut10_htlc = crate::nuts::Nut10Secret::new( + crate::nuts::Kind::HTLC, + htlc_hash.to_string(), + Some(cond_htlc.clone()), + ); + let secret_htlc: Secret = nut10_htlc.try_into().unwrap(); + + // Build two proofs (one P2PK, one HTLC) + let proof_p2pk = Proof::new(Amount::from(1), keyset_id, secret_p2pk.clone(), pk1); + let proof_htlc = Proof::new(Amount::from(2), keyset_id, secret_htlc.clone(), pk2); + let token = Token::new( + mint_url, + vec![proof_p2pk, proof_htlc].into_iter().collect(), + None, + CurrencyUnit::Sat, + ); + + // token_secrets should see both + assert_eq!(token.token_secrets().len(), 2); + + // spending_conditions should contain both kinds with their conditions + let sc = token.spending_conditions().unwrap(); + assert!(sc.contains(&SpendingConditions::P2PKConditions { + data: pk1, + conditions: Some(cond_p2pk.clone()) + })); + assert!(sc.contains(&SpendingConditions::HTLCConditions { + data: htlc_hash, + conditions: Some(cond_htlc.clone()) + })); + + // p2pk_pubkeys should include base pk1 and extra pk2 from tags (deduped) + let pks = token.p2pk_pubkeys().unwrap(); + assert!(pks.contains(&pk1)); + assert!(pks.contains(&pk2)); + assert_eq!(pks.len(), 2); + + // p2pk_refund_pubkeys should include refund_pk only + let refund = token.p2pk_refund_pubkeys().unwrap(); + assert!(refund.contains(&refund_pk)); + assert_eq!(refund.len(), 1); + + // htlc_hashes should include exactly our hash + let hashes = token.htlc_hashes().unwrap(); + assert!(hashes.contains(&htlc_hash)); + assert_eq!(hashes.len(), 1); + + // locktimes should include both unique locktimes + let lts = token.locktimes().unwrap(); + assert!(lts.contains(&1_700_000_000)); + assert!(lts.contains(&1_800_000_000)); + assert_eq!(lts.len(), 2); + } + + #[test] + fn test_token_spending_condition_helpers_dedup_and_v3() { + let mint_url = MintUrl::from_str("https://example.org").unwrap(); + let id = Id::from_str("00ad268c4d1f5826").unwrap(); + + // Same P2PK conditions duplicated across two proofs + let sk = crate::nuts::SecretKey::generate(); + let pk = sk.public_key(); + + let cond = Conditions { + locktime: Some(1_650_000_000), + pubkeys: Some(vec![pk]), // include itself to test dedup inside pubkeys() + refund_keys: Some(vec![pk]), // deliberate duplicate + num_sigs: Some(1), + sig_flag: SigFlag::SigInputs, + num_sigs_refund: None, + }; + + let nut10 = crate::nuts::Nut10Secret::new( + crate::nuts::Kind::P2PK, + pk.to_string(), + Some(cond.clone()), + ); + let secret: Secret = nut10.try_into().unwrap(); + + let p1 = Proof::new(Amount::from(1), id, secret.clone(), pk); + let p2 = Proof::new(Amount::from(2), id, secret.clone(), pk); + + // Build a V3 token explicitly and wrap into Token::TokenV3 + let token_v3 = TokenV3::new( + mint_url, + vec![p1, p2].into_iter().collect(), + None, + Some(CurrencyUnit::Sat), + ) + .unwrap(); + let token = Token::TokenV3(token_v3); + + // Helpers should dedup + let sc = token.spending_conditions().unwrap(); + assert_eq!(sc.len(), 1); // identical conditions across proofs + + let pks = token.p2pk_pubkeys().unwrap(); + assert!(pks.contains(&pk)); + assert_eq!(pks.len(), 1); // duplicates removed + + let refunds = token.p2pk_refund_pubkeys().unwrap(); + assert!(refunds.contains(&pk)); + assert_eq!(refunds.len(), 1); + + let lts = token.locktimes().unwrap(); + assert!(lts.contains(&1_650_000_000)); + assert_eq!(lts.len(), 1); + + // No HTLC here + let hashes = token.htlc_hashes().unwrap(); + assert!(hashes.is_empty()); + + // token_secrets length equals number of proofs even if conditions identical + assert_eq!(token.token_secrets().len(), 2); + } } diff --git a/crates/cdk-ffi/src/lib.rs b/crates/cdk-ffi/src/lib.rs index fe6ddabaf2..beb5046fcc 100644 --- a/crates/cdk-ffi/src/lib.rs +++ b/crates/cdk-ffi/src/lib.rs @@ -7,6 +7,7 @@ pub mod database; pub mod error; pub mod multi_mint_wallet; +pub mod token; pub mod types; pub mod wallet; diff --git a/crates/cdk-ffi/src/multi_mint_wallet.rs b/crates/cdk-ffi/src/multi_mint_wallet.rs index d8b807fb6f..6fc72af937 100644 --- a/crates/cdk-ffi/src/multi_mint_wallet.rs +++ b/crates/cdk-ffi/src/multi_mint_wallet.rs @@ -12,6 +12,7 @@ use cdk::wallet::multi_mint_wallet::{ }; use crate::error::FfiError; +use crate::token::Token; use crate::types::*; /// FFI-compatible MultiMintWallet diff --git a/crates/cdk-ffi/src/token.rs b/crates/cdk-ffi/src/token.rs new file mode 100644 index 0000000000..31a22e424a --- /dev/null +++ b/crates/cdk-ffi/src/token.rs @@ -0,0 +1,158 @@ +//! FFI token bindings + +use std::collections::BTreeSet; +use std::str::FromStr; + +use crate::error::FfiError; +use crate::{Amount, CurrencyUnit, MintUrl, Proofs}; + +/// FFI-compatible Token +#[derive(Debug, uniffi::Object)] +pub struct Token { + pub(crate) inner: cdk::nuts::Token, +} + +impl std::fmt::Display for Token { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.inner) + } +} + +impl FromStr for Token { + type Err = FfiError; + + fn from_str(s: &str) -> Result { + let token = cdk::nuts::Token::from_str(s) + .map_err(|e| FfiError::InvalidToken { msg: e.to_string() })?; + Ok(Token { inner: token }) + } +} + +impl From for Token { + fn from(token: cdk::nuts::Token) -> Self { + Self { inner: token } + } +} + +impl From for cdk::nuts::Token { + fn from(token: Token) -> Self { + token.inner + } +} + +#[uniffi::export] +impl Token { + /// Create a new Token from string + #[uniffi::constructor] + pub fn from_string(encoded_token: String) -> Result { + let token = cdk::nuts::Token::from_str(&encoded_token) + .map_err(|e| FfiError::InvalidToken { msg: e.to_string() })?; + Ok(Token { inner: token }) + } + + /// Get the total value of the token + pub fn value(&self) -> Result { + Ok(self.inner.value()?.into()) + } + + /// Get the memo from the token + pub fn memo(&self) -> Option { + self.inner.memo().clone() + } + + /// Get the currency unit + pub fn unit(&self) -> Option { + self.inner.unit().map(Into::into) + } + + /// Get the mint URL + pub fn mint_url(&self) -> Result { + Ok(self.inner.mint_url()?.into()) + } + + /// Get proofs from the token (simplified - no keyset filtering for now) + pub fn proofs_simple(&self) -> Result { + // For now, return empty keysets to get all proofs + let empty_keysets = vec![]; + let proofs = self.inner.proofs(&empty_keysets)?; + Ok(proofs + .into_iter() + .map(|p| std::sync::Arc::new(p.into())) + .collect()) + } + + /// Convert token to raw bytes + pub fn to_raw_bytes(&self) -> Result, FfiError> { + Ok(self.inner.to_raw_bytes()?) + } + + /// Encode token to string representation + pub fn encode(&self) -> String { + self.to_string() + } + + /// Decode token from string representation + #[uniffi::constructor] + pub fn decode(encoded_token: String) -> Result { + encoded_token.parse() + } + + /// Return unique spending conditions across all proofs in this token + pub fn spending_conditions(&self) -> Vec { + self.inner + .spending_conditions() + .map(|set| set.into_iter().map(Into::into).collect()) + .unwrap_or_default() + } + + /// Return all P2PK pubkeys referenced by this token's spending conditions + pub fn p2pk_pubkeys(&self) -> Vec { + let set = self + .inner + .p2pk_pubkeys() + .map(|keys| { + keys.into_iter() + .map(|k| k.to_string()) + .collect::>() + }) + .unwrap_or_default(); + set.into_iter().collect() + } + + /// Return all refund pubkeys from P2PK spending conditions + pub fn p2pk_refund_pubkeys(&self) -> Vec { + let set = self + .inner + .p2pk_refund_pubkeys() + .map(|keys| { + keys.into_iter() + .map(|k| k.to_string()) + .collect::>() + }) + .unwrap_or_default(); + set.into_iter().collect() + } + + /// Return all HTLC hashes from spending conditions + pub fn htlc_hashes(&self) -> Vec { + let set = self + .inner + .htlc_hashes() + .map(|hashes| { + hashes + .into_iter() + .map(|h| h.to_string()) + .collect::>() + }) + .unwrap_or_default(); + set.into_iter().collect() + } + + /// Return all locktimes from spending conditions (sorted ascending) + pub fn locktimes(&self) -> Vec { + self.inner + .locktimes() + .map(|s| s.into_iter().collect()) + .unwrap_or_default() + } +} diff --git a/crates/cdk-ffi/src/types.rs b/crates/cdk-ffi/src/types.rs index 9d4b04488d..1a6d1d4df7 100644 --- a/crates/cdk-ffi/src/types.rs +++ b/crates/cdk-ffi/src/types.rs @@ -10,6 +10,7 @@ use cdk::Amount as CdkAmount; use serde::{Deserialize, Serialize}; use crate::error::FfiError; +use crate::token::Token; /// FFI-compatible Amount type #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] @@ -200,98 +201,6 @@ impl From for CdkState { } } -/// FFI-compatible Token -#[derive(Debug, uniffi::Object)] -pub struct Token { - pub(crate) inner: cdk::nuts::Token, -} - -impl std::fmt::Display for Token { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.inner) - } -} - -impl FromStr for Token { - type Err = FfiError; - - fn from_str(s: &str) -> Result { - let token = cdk::nuts::Token::from_str(s) - .map_err(|e| FfiError::InvalidToken { msg: e.to_string() })?; - Ok(Token { inner: token }) - } -} - -impl From for Token { - fn from(token: cdk::nuts::Token) -> Self { - Self { inner: token } - } -} - -impl From for cdk::nuts::Token { - fn from(token: Token) -> Self { - token.inner - } -} - -#[uniffi::export] -impl Token { - /// Create a new Token from string - #[uniffi::constructor] - pub fn from_string(encoded_token: String) -> Result { - let token = cdk::nuts::Token::from_str(&encoded_token) - .map_err(|e| FfiError::InvalidToken { msg: e.to_string() })?; - Ok(Token { inner: token }) - } - - /// Get the total value of the token - pub fn value(&self) -> Result { - Ok(self.inner.value()?.into()) - } - - /// Get the memo from the token - pub fn memo(&self) -> Option { - self.inner.memo().clone() - } - - /// Get the currency unit - pub fn unit(&self) -> Option { - self.inner.unit().map(Into::into) - } - - /// Get the mint URL - pub fn mint_url(&self) -> Result { - Ok(self.inner.mint_url()?.into()) - } - - /// Get proofs from the token (simplified - no keyset filtering for now) - pub fn proofs_simple(&self) -> Result { - // For now, return empty keysets to get all proofs - let empty_keysets = vec![]; - let proofs = self.inner.proofs(&empty_keysets)?; - Ok(proofs - .into_iter() - .map(|p| std::sync::Arc::new(p.into())) - .collect()) - } - - /// Convert token to raw bytes - pub fn to_raw_bytes(&self) -> Result, FfiError> { - Ok(self.inner.to_raw_bytes()?) - } - - /// Encode token to string representation - pub fn encode(&self) -> String { - self.to_string() - } - - /// Decode token from string representation - #[uniffi::constructor] - pub fn decode(encoded_token: String) -> Result { - encoded_token.parse() - } -} - /// FFI-compatible SendMemo #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct SendMemo { diff --git a/crates/cdk-ffi/src/wallet.rs b/crates/cdk-ffi/src/wallet.rs index b342e14276..802a873e3e 100644 --- a/crates/cdk-ffi/src/wallet.rs +++ b/crates/cdk-ffi/src/wallet.rs @@ -7,6 +7,7 @@ use bip39::Mnemonic; use cdk::wallet::{Wallet as CdkWallet, WalletBuilder as CdkWalletBuilder}; use crate::error::FfiError; +use crate::token::Token; use crate::types::*; /// FFI-compatible Wallet diff --git a/crates/cdk-integration-tests/tests/ffi_minting_integration.rs b/crates/cdk-integration-tests/tests/ffi_minting_integration.rs index 44857b9c8c..8d3b320439 100644 --- a/crates/cdk-integration-tests/tests/ffi_minting_integration.rs +++ b/crates/cdk-integration-tests/tests/ffi_minting_integration.rs @@ -214,7 +214,7 @@ async fn test_ffi_mint_quote_creation() { let quote = wallet .mint_quote(amount, Some(description.clone())) .await - .expect(&format!("Failed to create quote for {} sats", amount_value)); + .unwrap_or_else(|_| panic!("Failed to create quote for {} sats", amount_value)); // Verify quote properties assert_eq!(quote.amount, Some(amount)); diff --git a/crates/cdk-postgres/src/lib.rs b/crates/cdk-postgres/src/lib.rs index d0a60c4263..0d89c06c62 100644 --- a/crates/cdk-postgres/src/lib.rs +++ b/crates/cdk-postgres/src/lib.rs @@ -335,10 +335,9 @@ mod test { let db_url = format!("{db_url} schema={test_id}"); - let db = MintPgDatabase::new(db_url.as_str()) + MintPgDatabase::new(db_url.as_str()) .await - .expect("database"); - db + .expect("database") } mint_db_test!(provide_db); From 4b04d10383d1beb733710b139f409ed5e0c3cfe8 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Sat, 27 Sep 2025 23:51:24 +0100 Subject: [PATCH 11/17] Check change unique (#1112) * fix(cdk): prevent duplicate blinded message processing with database constraints Add unique constraints on blinded_message column in both PostgreSQL and SQLite databases, and implement application-level checks to prevent duplicate blinded messages from being processed. Also ensure proper cleanup of melt requests after successful processing. * feat: db tests for unique * refactor(cdk-sql): consolidate blinded messages into blind signature table Migrate from separate blinded_messages table to unified blind_signature table. Add signed_time column and make c column nullable to track both pending blind messages (c=NULL) and completed signatures. Update insert/update logic to handle upsert scenarios for blind signature completion. * refactor(cdk-sql): remove unique constraint migration and filter queries for signed messages Remove database-level unique constraint on blinded_message and instead filter queries to only consider messages with signatures (c IS NOT NULL * refactor(database): improve blinded message duplicate detection using database constraints Replace manual duplicate checking with database constraint handling for better reliability and simplified code flow in melt request processing. * refactor(cdk-sql): optimize blind signature processing with batch queries Replace individual queries per blinded message with single batch query and HashMap lookup to eliminate N+1 query performance issue. * fix: signed time to swap sigs * refactor(cdk): split blinded message handling and improve duplicate detection - Split add_melt_request_and_blinded_messages into separate methods - Add blinded messages to database before signing in swap operations - Improve duplicate output detection with proper error handling - Make add_blinded_messages method accept optional quote_id for flexibility * refactor(cdk): add BlindedMessageWriter for improved transaction rollback - Add BlindedMessageWriter component for managing blinded message state - Implement proper rollback mechanisms in swap operations - Add delete_blinded_messages database interface for cleanup - Improve error handling with better state management --- .typos.toml | 3 +- crates/cdk-common/src/database/mint/mod.rs | 16 +- .../cdk-common/src/database/mint/test/mint.rs | 198 +++++++++++++- .../cdk-common/src/database/mint/test/mod.rs | 6 +- ...e_blinded_messages_to_blind_signatures.sql | 23 ++ ...e_blinded_messages_to_blind_signatures.sql | 40 +++ crates/cdk-sql-common/src/mint/mod.rs | 244 ++++++++++++++---- crates/cdk/src/mint/blinded_message_writer.rs | 152 +++++++++++ crates/cdk/src/mint/melt.rs | 12 +- crates/cdk/src/mint/mod.rs | 1 + crates/cdk/src/mint/swap.rs | 52 +++- 11 files changed, 692 insertions(+), 55 deletions(-) create mode 100644 crates/cdk-sql-common/src/mint/migrations/postgres/20250924215800_migrate_blinded_messages_to_blind_signatures.sql create mode 100644 crates/cdk-sql-common/src/mint/migrations/sqlite/20250924215800_migrate_blinded_messages_to_blind_signatures.sql create mode 100644 crates/cdk/src/mint/blinded_message_writer.rs diff --git a/.typos.toml b/.typos.toml index e3cc2980e3..6a6405af21 100644 --- a/.typos.toml +++ b/.typos.toml @@ -6,5 +6,6 @@ extend-ignore-re = [ "casshuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9", "autheticator", "Gam", - "flate2" + "flate2", + "lnbc[A-Za-z0-9-_]+" ] diff --git a/crates/cdk-common/src/database/mint/mod.rs b/crates/cdk-common/src/database/mint/mod.rs index 9c08734c38..5ece68c47a 100644 --- a/crates/cdk-common/src/database/mint/mod.rs +++ b/crates/cdk-common/src/database/mint/mod.rs @@ -132,15 +132,27 @@ pub trait QuotesTransaction<'a> { /// Mint Quotes Database Error type Err: Into + From; - /// Add melt_request with quote_id, inputs_amount, and blinded_messages - async fn add_melt_request_and_blinded_messages( + /// Add melt_request with quote_id, inputs_amount, and inputs_fee + async fn add_melt_request( &mut self, quote_id: &QuoteId, inputs_amount: Amount, inputs_fee: Amount, + ) -> Result<(), Self::Err>; + + /// Add blinded_messages for a quote_id + async fn add_blinded_messages( + &mut self, + quote_id: Option<&QuoteId>, blinded_messages: &[BlindedMessage], ) -> Result<(), Self::Err>; + /// Delete blinded_messages by their blinded secrets + async fn delete_blinded_messages( + &mut self, + blinded_secrets: &[PublicKey], + ) -> Result<(), Self::Err>; + /// Get melt_request and associated blinded_messages by quote_id async fn get_melt_request_and_blinded_messages( &mut self, diff --git a/crates/cdk-common/src/database/mint/test/mint.rs b/crates/cdk-common/src/database/mint/test/mint.rs index ee4641a150..531dc9245a 100644 --- a/crates/cdk-common/src/database/mint/test/mint.rs +++ b/crates/cdk-common/src/database/mint/test/mint.rs @@ -1,8 +1,14 @@ //! Payments +use std::str::FromStr; + +use cashu::quote_id::QuoteId; +use cashu::{Amount, Id, SecretKey}; + use crate::database::mint::test::unique_string; use crate::database::mint::{Database, Error, KeysDatabase}; -use crate::mint::MintQuote; +use crate::database::MintSignaturesDatabase; +use crate::mint::{MeltPaymentRequest, MeltQuote, MintQuote}; use crate::payment::PaymentIdentifier; /// Add a mint quote @@ -404,3 +410,193 @@ where .await .is_err()); } +/// Successful melt with unique blinded messages +pub async fn add_melt_request_unique_blinded_messages(db: DB) +where + DB: Database + KeysDatabase + MintSignaturesDatabase, +{ + let inputs_amount = Amount::from(100u64); + let inputs_fee = Amount::from(1u64); + let keyset_id = Id::from_str("001711afb1de20cb").unwrap(); + + // Create a dummy blinded message + let blinded_secret = SecretKey::generate().public_key(); + let blinded_message = cashu::BlindedMessage { + blinded_secret, + keyset_id, + amount: Amount::from(100u64), + witness: None, + }; + let blinded_messages = vec![blinded_message]; + + let mut tx = Database::begin_transaction(&db).await.unwrap(); + let quote = MeltQuote::new(MeltPaymentRequest::Bolt11 { bolt11: "lnbc330n1p5d85skpp5344v3ktclujsjl3h09wgsfm7zytumr7h7zhrl857f5w8nv0a52zqdqqcqzzsxqyz5vqrzjqvueefmrckfdwyyu39m0lf24sqzcr9vcrmxrvgfn6empxz7phrjxvrttncqq0lcqqyqqqqlgqqqqqqgq2qsp5j3rrg8kvpemqxtf86j8tjm90wq77c7ende4e5qmrerq4xsg02vhq9qxpqysgqjltywgyk6uc5qcgwh8xnzmawl2tjlhz8d28tgp3yx8xwtz76x0jqkfh6mmq70hervjxs0keun7ur0spldgll29l0dnz3md50d65sfqqqwrwpsu".parse().unwrap() }, cashu::CurrencyUnit::Sat, 33.into(), Amount::ZERO, 0, None, None, cashu::PaymentMethod::Bolt11); + tx.add_melt_quote(quote.clone()).await.unwrap(); + tx.add_melt_request("e.id, inputs_amount, inputs_fee) + .await + .unwrap(); + tx.add_blinded_messages(Some("e.id), &blinded_messages) + .await + .unwrap(); + tx.commit().await.unwrap(); + + // Verify retrieval + let mut tx = Database::begin_transaction(&db).await.unwrap(); + let retrieved = tx + .get_melt_request_and_blinded_messages("e.id) + .await + .unwrap() + .unwrap(); + assert_eq!(retrieved.inputs_amount, inputs_amount); + assert_eq!(retrieved.inputs_fee, inputs_fee); + assert_eq!(retrieved.change_outputs.len(), 1); + assert_eq!(retrieved.change_outputs[0].amount, Amount::from(100u64)); + tx.commit().await.unwrap(); +} + +/// Reject melt with duplicate blinded message (already signed) +pub async fn reject_melt_duplicate_blinded_signature(db: DB) +where + DB: Database + KeysDatabase + MintSignaturesDatabase, +{ + let quote_id1 = QuoteId::new_uuid(); + let inputs_amount = Amount::from(100u64); + let inputs_fee = Amount::from(1u64); + let keyset_id = Id::from_str("001711afb1de20cb").unwrap(); + + // Create a dummy blinded message + let blinded_secret = SecretKey::generate().public_key(); + let blinded_message = cashu::BlindedMessage { + blinded_secret, + keyset_id, + amount: Amount::from(100u64), + witness: None, + }; + let blinded_messages = vec![blinded_message.clone()]; + + // First, "sign" it by adding to blind_signature (simulate successful mint) + let mut tx = Database::begin_transaction(&db).await.unwrap(); + let c = SecretKey::generate().public_key(); + let blind_sig = cashu::BlindSignature { + amount: Amount::from(100u64), + keyset_id, + c, + dleq: None, + }; + let blinded_secrets = vec![blinded_message.blinded_secret]; + tx.add_blind_signatures(&blinded_secrets, &[blind_sig], Some(quote_id1)) + .await + .unwrap(); + tx.commit().await.unwrap(); + + // Now try to add melt request with the same blinded message - should fail due to constraint + let mut tx = Database::begin_transaction(&db).await.unwrap(); + let quote2 = MeltQuote::new(MeltPaymentRequest::Bolt11 { bolt11: "lnbc330n1p5d85skpp5344v3ktclujsjl3h09wgsfm7zytumr7h7zhrl857f5w8nv0a52zqdqqcqzzsxqyz5vqrzjqvueefmrckfdwyyu39m0lf24sqzcr9vcrmxrvgfn6empxz7phrjxvrttncqq0lcqqyqqqqlgqqqqqqgq2qsp5j3rrg8kvpemqxtf86j8tjm90wq77c7ende4e5qmrerq4xsg02vhq9qxpqysgqjltywgyk6uc5qcgwh8xnzmawl2tjlhz8d28tgp3yx8xwtz76x0jqkfh6mmq70hervjxs0keun7ur0spldgll29l0dnz3md50d65sfqqqwrwpsu".parse().unwrap() }, cashu::CurrencyUnit::Sat, 33.into(), Amount::ZERO, 0, None, None, cashu::PaymentMethod::Bolt11); + tx.add_melt_quote(quote2.clone()).await.unwrap(); + tx.add_melt_request("e2.id, inputs_amount, inputs_fee) + .await + .unwrap(); + let result = tx + .add_blinded_messages(Some("e2.id), &blinded_messages) + .await; + assert!(result.is_err() && matches!(result.unwrap_err(), Error::Duplicate)); + tx.rollback().await.unwrap(); // Rollback to avoid partial state +} + +/// Reject duplicate blinded message insert via DB constraint (different quotes) +pub async fn reject_duplicate_blinded_message_db_constraint(db: DB) +where + DB: Database + KeysDatabase, +{ + let inputs_amount = Amount::from(100u64); + let inputs_fee = Amount::from(1u64); + let keyset_id = Id::from_str("001711afb1de20cb").unwrap(); + + // Create a dummy blinded message + let blinded_secret = SecretKey::generate().public_key(); + let blinded_message = cashu::BlindedMessage { + blinded_secret, + keyset_id, + amount: Amount::from(100u64), + witness: None, + }; + let blinded_messages = vec![blinded_message]; + + // First insert succeeds + let mut tx = Database::begin_transaction(&db).await.unwrap(); + let quote = MeltQuote::new(MeltPaymentRequest::Bolt11 { bolt11: "lnbc330n1p5d85skpp5344v3ktclujsjl3h09wgsfm7zytumr7h7zhrl857f5w8nv0a52zqdqqcqzzsxqyz5vqrzjqvueefmrckfdwyyu39m0lf24sqzcr9vcrmxrvgfn6empxz7phrjxvrttncqq0lcqqyqqqqlgqqqqqqgq2qsp5j3rrg8kvpemqxtf86j8tjm90wq77c7ende4e5qmrerq4xsg02vhq9qxpqysgqjltywgyk6uc5qcgwh8xnzmawl2tjlhz8d28tgp3yx8xwtz76x0jqkfh6mmq70hervjxs0keun7ur0spldgll29l0dnz3md50d65sfqqqwrwpsu".parse().unwrap() }, cashu::CurrencyUnit::Sat, 33.into(), Amount::ZERO, 0, None, None, cashu::PaymentMethod::Bolt11); + tx.add_melt_quote(quote.clone()).await.unwrap(); + tx.add_melt_request("e.id, inputs_amount, inputs_fee) + .await + .unwrap(); + assert!(tx + .add_blinded_messages(Some("e.id), &blinded_messages) + .await + .is_ok()); + tx.commit().await.unwrap(); + + // Second insert with same blinded_message but different quote_id should fail due to unique constraint on blinded_message + let mut tx = Database::begin_transaction(&db).await.unwrap(); + let quote = MeltQuote::new(MeltPaymentRequest::Bolt11 { bolt11: "lnbc330n1p5d85skpp5344v3ktclujsjl3h09wgsfm7zytumr7h7zhrl857f5w8nv0a52zqdqqcqzzsxqyz5vqrzjqvueefmrckfdwyyu39m0lf24sqzcr9vcrmxrvgfn6empxz7phrjxvrttncqq0lcqqyqqqqlgqqqqqqgq2qsp5j3rrg8kvpemqxtf86j8tjm90wq77c7ende4e5qmrerq4xsg02vhq9qxpqysgqjltywgyk6uc5qcgwh8xnzmawl2tjlhz8d28tgp3yx8xwtz76x0jqkfh6mmq70hervjxs0keun7ur0spldgll29l0dnz3md50d65sfqqqwrwpsu".parse().unwrap() }, cashu::CurrencyUnit::Sat, 33.into(), Amount::ZERO, 0, None, None, cashu::PaymentMethod::Bolt11); + tx.add_melt_quote(quote.clone()).await.unwrap(); + tx.add_melt_request("e.id, inputs_amount, inputs_fee) + .await + .unwrap(); + let result = tx + .add_blinded_messages(Some("e.id), &blinded_messages) + .await; + // Expect a database error due to unique violation + assert!(result.is_err()); // Specific error might be DB-specific, e.g., SqliteError or PostgresError + tx.rollback().await.unwrap(); +} + +/// Cleanup of melt request after processing +pub async fn cleanup_melt_request_after_processing(db: DB) +where + DB: Database + KeysDatabase, +{ + let inputs_amount = Amount::from(100u64); + let inputs_fee = Amount::from(1u64); + let keyset_id = Id::from_str("001711afb1de20cb").unwrap(); + + // Create dummy blinded message + let blinded_secret = SecretKey::generate().public_key(); + let blinded_message = cashu::BlindedMessage { + blinded_secret, + keyset_id, + amount: Amount::from(100u64), + witness: None, + }; + let blinded_messages = vec![blinded_message]; + + // Insert melt request + let mut tx1 = Database::begin_transaction(&db).await.unwrap(); + let quote = MeltQuote::new(MeltPaymentRequest::Bolt11 { bolt11: "lnbc330n1p5d85skpp5344v3ktclujsjl3h09wgsfm7zytumr7h7zhrl857f5w8nv0a52zqdqqcqzzsxqyz5vqrzjqvueefmrckfdwyyu39m0lf24sqzcr9vcrmxrvgfn6empxz7phrjxvrttncqq0lcqqyqqqqlgqqqqqqgq2qsp5j3rrg8kvpemqxtf86j8tjm90wq77c7ende4e5qmrerq4xsg02vhq9qxpqysgqjltywgyk6uc5qcgwh8xnzmawl2tjlhz8d28tgp3yx8xwtz76x0jqkfh6mmq70hervjxs0keun7ur0spldgll29l0dnz3md50d65sfqqqwrwpsu".parse().unwrap() }, cashu::CurrencyUnit::Sat, 33.into(), Amount::ZERO, 0, None, None, cashu::PaymentMethod::Bolt11); + tx1.add_melt_quote(quote.clone()).await.unwrap(); + tx1.add_melt_request("e.id, inputs_amount, inputs_fee) + .await + .unwrap(); + tx1.add_blinded_messages(Some("e.id), &blinded_messages) + .await + .unwrap(); + tx1.commit().await.unwrap(); + + // Simulate processing: get and delete + let mut tx2 = Database::begin_transaction(&db).await.unwrap(); + let _retrieved = tx2 + .get_melt_request_and_blinded_messages("e.id) + .await + .unwrap() + .unwrap(); + tx2.delete_melt_request("e.id).await.unwrap(); + tx2.commit().await.unwrap(); + + // Verify melt_request is deleted + let mut tx3 = Database::begin_transaction(&db).await.unwrap(); + let retrieved = tx3 + .get_melt_request_and_blinded_messages("e.id) + .await + .unwrap(); + assert!(retrieved.is_none()); + tx3.commit().await.unwrap(); +} diff --git a/crates/cdk-common/src/database/mint/test/mod.rs b/crates/cdk-common/src/database/mint/test/mod.rs index abc5d53ec3..1bdfdf3c2e 100644 --- a/crates/cdk-common/src/database/mint/test/mod.rs +++ b/crates/cdk-common/src/database/mint/test/mod.rs @@ -235,7 +235,11 @@ macro_rules! mint_db_test { reject_over_issue_same_tx, reject_over_issue_different_tx, reject_over_issue_with_payment, - reject_over_issue_with_payment_different_tx + reject_over_issue_with_payment_different_tx, + add_melt_request_unique_blinded_messages, + reject_melt_duplicate_blinded_signature, + reject_duplicate_blinded_message_db_constraint, + cleanup_melt_request_after_processing ); }; ($make_db_fn:ident, $($name:ident),+ $(,)?) => { diff --git a/crates/cdk-sql-common/src/mint/migrations/postgres/20250924215800_migrate_blinded_messages_to_blind_signatures.sql b/crates/cdk-sql-common/src/mint/migrations/postgres/20250924215800_migrate_blinded_messages_to_blind_signatures.sql new file mode 100644 index 0000000000..8543cd4921 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/postgres/20250924215800_migrate_blinded_messages_to_blind_signatures.sql @@ -0,0 +1,23 @@ +-- Remove NOT NULL constraint from c column in blind_signature table +ALTER TABLE blind_signature ALTER COLUMN c DROP NOT NULL; + +-- Add signed_time column to blind_signature table +ALTER TABLE blind_signature ADD COLUMN signed_time INTEGER NULL; + +-- Update existing records to set signed_time equal to created_time for existing signatures +UPDATE blind_signature SET signed_time = created_time WHERE c IS NOT NULL; + +-- Insert data from blinded_messages table into blind_signature table with NULL c column +INSERT INTO blind_signature (blinded_message, amount, keyset_id, c, quote_id, created_time, signed_time) +SELECT blinded_message, amount, keyset_id, NULL as c, quote_id, 0 as created_time, NULL as signed_time +FROM blinded_messages +WHERE NOT EXISTS ( + SELECT 1 FROM blind_signature + WHERE blind_signature.blinded_message = blinded_messages.blinded_message +); + +-- Create index on quote_id if it does not exist +CREATE INDEX IF NOT EXISTS blind_signature_quote_id_index ON blind_signature(quote_id); + +-- Drop the blinded_messages table as data has been migrated +DROP TABLE IF EXISTS blinded_messages; diff --git a/crates/cdk-sql-common/src/mint/migrations/sqlite/20250924215800_migrate_blinded_messages_to_blind_signatures.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250924215800_migrate_blinded_messages_to_blind_signatures.sql new file mode 100644 index 0000000000..42c0fc9367 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250924215800_migrate_blinded_messages_to_blind_signatures.sql @@ -0,0 +1,40 @@ +-- Remove NOT NULL constraint from c column in blind_signature table +-- SQLite does not support ALTER COLUMN directly, so we need to recreate the table + +-- Step 1 - Create new table with nullable c column and signed_time column +CREATE TABLE blind_signature_new ( + blinded_message BLOB PRIMARY KEY, + amount INTEGER NOT NULL, + keyset_id TEXT NOT NULL, + c BLOB NULL, + dleq_e TEXT, + dleq_s TEXT, + quote_id TEXT, + created_time INTEGER NOT NULL DEFAULT 0, + signed_time INTEGER +); + +-- Step 2 - Copy existing data from old blind_signature table +INSERT INTO blind_signature_new (blinded_message, amount, keyset_id, c, dleq_e, dleq_s, quote_id, created_time) +SELECT blinded_message, amount, keyset_id, c, dleq_e, dleq_s, quote_id, created_time +FROM blind_signature; + +-- Step 3 - Insert data from blinded_messages table with NULL c column +INSERT INTO blind_signature_new (blinded_message, amount, keyset_id, c, quote_id, created_time) +SELECT blinded_message, amount, keyset_id, NULL as c, quote_id, 0 as created_time +FROM blinded_messages +WHERE NOT EXISTS ( + SELECT 1 FROM blind_signature_new + WHERE blind_signature_new.blinded_message = blinded_messages.blinded_message +); + +-- Step 4 - Drop old table and rename new table +DROP TABLE blind_signature; +ALTER TABLE blind_signature_new RENAME TO blind_signature; + +-- Step 5 - Recreate indexes +CREATE INDEX IF NOT EXISTS keyset_id_index ON blind_signature(keyset_id); +CREATE INDEX IF NOT EXISTS blind_signature_quote_id_index ON blind_signature(quote_id); + +-- Step 6 - Drop the blinded_messages table as data has been migrated +DROP TABLE IF EXISTS blinded_messages; diff --git a/crates/cdk-sql-common/src/mint/mod.rs b/crates/cdk-sql-common/src/mint/mod.rs index b3bef8d888..a1d7ed932a 100644 --- a/crates/cdk-sql-common/src/mint/mod.rs +++ b/crates/cdk-sql-common/src/mint/mod.rs @@ -546,13 +546,13 @@ where { type Err = Error; - async fn add_melt_request_and_blinded_messages( + async fn add_melt_request( &mut self, quote_id: &QuoteId, inputs_amount: Amount, inputs_fee: Amount, - blinded_messages: &[BlindedMessage], ) -> Result<(), Self::Err> { + // Insert melt_request query( r#" INSERT INTO melt_request @@ -567,26 +567,79 @@ where .execute(&self.inner) .await?; + Ok(()) + } + + async fn add_blinded_messages( + &mut self, + quote_id: Option<&QuoteId>, + blinded_messages: &[BlindedMessage], + ) -> Result<(), Self::Err> { + let current_time = unix_time(); + + // Insert blinded_messages directly into blind_signature with c = NULL + // Let the database constraint handle duplicate detection for message in blinded_messages { - query( + match query( r#" - INSERT INTO blinded_messages - (quote_id, blinded_message, keyset_id, amount) + INSERT INTO blind_signature + (blinded_message, amount, keyset_id, c, quote_id, created_time) VALUES - (:quote_id, :blinded_message, :keyset_id, :amount) + (:blinded_message, :amount, :keyset_id, NULL, :quote_id, :created_time) "#, )? - .bind("quote_id", quote_id.to_string()) .bind( "blinded_message", message.blinded_secret.to_bytes().to_vec(), ) - .bind("keyset_id", message.keyset_id.to_string()) .bind("amount", message.amount.to_i64()) + .bind("keyset_id", message.keyset_id.to_string()) + .bind("quote_id", quote_id.map(|q| q.to_string())) + .bind("created_time", current_time as i64) .execute(&self.inner) - .await?; + .await + { + Ok(_) => continue, + Err(database::Error::Duplicate) => { + // Primary key constraint violation - blinded message already exists + // This could be either: + // 1. Already signed (c IS NOT NULL) - definitely an error + // 2. Already pending (c IS NULL) - also an error + return Err(database::Error::Duplicate); + } + Err(err) => return Err(err), + } + } + + Ok(()) + } + + async fn delete_blinded_messages( + &mut self, + blinded_secrets: &[PublicKey], + ) -> Result<(), Self::Err> { + if blinded_secrets.is_empty() { + return Ok(()); } + // Delete blinded messages from blind_signature table where c IS NULL + // (only delete unsigned blinded messages) + query( + r#" + DELETE FROM blind_signature + WHERE blinded_message IN (:blinded_secrets) AND c IS NULL + "#, + )? + .bind_vec( + "blinded_secrets", + blinded_secrets + .iter() + .map(|secret| secret.to_bytes().to_vec()) + .collect(), + ) + .execute(&self.inner) + .await?; + Ok(()) } @@ -610,11 +663,12 @@ where let inputs_amount: u64 = column_as_number!(row[0].clone()); let inputs_fee: u64 = column_as_number!(row[1].clone()); + // Get blinded messages from blind_signature table where c IS NULL let blinded_messages_rows = query( r#" SELECT blinded_message, keyset_id, amount - FROM blinded_messages - WHERE quote_id = :quote_id + FROM blind_signature + WHERE quote_id = :quote_id AND c IS NULL "#, )? .bind("quote_id", quote_id.to_string()) @@ -650,6 +704,7 @@ where } async fn delete_melt_request(&mut self, quote_id: &QuoteId) -> Result<(), Self::Err> { + // Delete from melt_request table query( r#" DELETE FROM melt_request @@ -660,6 +715,17 @@ where .execute(&self.inner) .await?; + // Also delete blinded messages (where c IS NULL) from blind_signature table + query( + r#" + DELETE FROM blind_signature + WHERE quote_id = :quote_id AND c IS NULL + "#, + )? + .bind("quote_id", quote_id.to_string()) + .execute(&self.inner) + .await?; + Ok(()) } @@ -1560,34 +1626,122 @@ where ) -> Result<(), Self::Err> { let current_time = unix_time(); + if blinded_messages.len() != blind_signatures.len() { + return Err(database::Error::Internal( + "Mismatched array lengths for blinded messages and blind signatures".to_string(), + )); + } + + // Select all existing rows for the given blinded messages at once + let mut existing_rows = query( + r#" + SELECT blinded_message, c, dleq_e, dleq_s + FROM blind_signature + WHERE blinded_message IN (:blinded_messages) + FOR UPDATE + "#, + )? + .bind_vec( + "blinded_messages", + blinded_messages + .iter() + .map(|message| message.to_bytes().to_vec()) + .collect(), + ) + .fetch_all(&self.inner) + .await? + .into_iter() + .map(|mut row| { + Ok(( + column_as_string!(&row.remove(0), PublicKey::from_hex, PublicKey::from_slice), + (row[0].clone(), row[1].clone(), row[2].clone()), + )) + }) + .collect::, Error>>()?; + + // Iterate over the provided blinded messages and signatures for (message, signature) in blinded_messages.iter().zip(blind_signatures) { - query( - r#" - INSERT INTO blind_signature - (blinded_message, amount, keyset_id, c, quote_id, dleq_e, dleq_s, created_time) - VALUES - (:blinded_message, :amount, :keyset_id, :c, :quote_id, :dleq_e, :dleq_s, :created_time) - "#, - )? - .bind("blinded_message", message.to_bytes().to_vec()) - .bind("amount", u64::from(signature.amount) as i64) - .bind("keyset_id", signature.keyset_id.to_string()) - .bind("c", signature.c.to_bytes().to_vec()) - .bind("quote_id", quote_id.as_ref().map(|q| match q { - QuoteId::BASE64(s) => s.to_string(), - QuoteId::UUID(u) => u.hyphenated().to_string(), - })) - .bind( - "dleq_e", - signature.dleq.as_ref().map(|dleq| dleq.e.to_secret_hex()), - ) - .bind( - "dleq_s", - signature.dleq.as_ref().map(|dleq| dleq.s.to_secret_hex()), - ) - .bind("created_time", current_time as i64) - .execute(&self.inner) - .await?; + match existing_rows.remove(message) { + None => { + // Unknown blind message: Insert new row with all columns + query( + r#" + INSERT INTO blind_signature + (blinded_message, amount, keyset_id, c, quote_id, dleq_e, dleq_s, created_time, signed_time) + VALUES + (:blinded_message, :amount, :keyset_id, :c, :quote_id, :dleq_e, :dleq_s, :created_time, :signed_time) + "#, + )? + .bind("blinded_message", message.to_bytes().to_vec()) + .bind("amount", u64::from(signature.amount) as i64) + .bind("keyset_id", signature.keyset_id.to_string()) + .bind("c", signature.c.to_bytes().to_vec()) + .bind("quote_id", quote_id.as_ref().map(|q| q.to_string())) + .bind( + "dleq_e", + signature.dleq.as_ref().map(|dleq| dleq.e.to_secret_hex()), + ) + .bind( + "dleq_s", + signature.dleq.as_ref().map(|dleq| dleq.s.to_secret_hex()), + ) + .bind("created_time", current_time as i64) + .bind("signed_time", current_time as i64) + .execute(&self.inner) + .await?; + } + Some((c, _dleq_e, _dleq_s)) => { + // Blind message exists: check if c is NULL + match c { + Column::Null => { + // Blind message with no c: Update with missing columns c, dleq_e, dleq_s + query( + r#" + UPDATE blind_signature + SET c = :c, dleq_e = :dleq_e, dleq_s = :dleq_s, signed_time = :signed_time, amount = :amount + WHERE blinded_message = :blinded_message + "#, + )? + .bind("c", signature.c.to_bytes().to_vec()) + .bind( + "dleq_e", + signature.dleq.as_ref().map(|dleq| dleq.e.to_secret_hex()), + ) + .bind( + "dleq_s", + signature.dleq.as_ref().map(|dleq| dleq.s.to_secret_hex()), + ) + .bind("blinded_message", message.to_bytes().to_vec()) + .bind("signed_time", current_time as i64) + .bind("amount", u64::from(signature.amount) as i64) + .execute(&self.inner) + .await?; + } + _ => { + // Blind message already has c: Error + tracing::error!( + "Attempting to add signature to message already signed {}", + message + ); + + return Err(database::Error::Duplicate); + } + } + } + } + } + + debug_assert!( + existing_rows.is_empty(), + "Unexpected existing rows remain: {:?}", + existing_rows.keys().collect::>() + ); + + if !existing_rows.is_empty() { + tracing::error!("Did not check all existing rows"); + return Err(Error::Internal( + "Did not check all existing rows".to_string(), + )); } Ok(()) @@ -1607,14 +1761,14 @@ where blinded_message FROM blind_signature - WHERE blinded_message IN (:y) + WHERE blinded_message IN (:b) AND c IS NOT NULL "#, )? .bind_vec( - "y", + "b", blinded_messages .iter() - .map(|y| y.to_bytes().to_vec()) + .map(|b| b.to_bytes().to_vec()) .collect(), ) .fetch_all(&self.inner) @@ -1660,11 +1814,11 @@ where blinded_message FROM blind_signature - WHERE blinded_message IN (:blinded_message) + WHERE blinded_message IN (:b) AND c IS NOT NULL "#, )? .bind_vec( - "blinded_message", + "b", blinded_messages .iter() .map(|b_| b_.to_bytes().to_vec()) @@ -1706,7 +1860,7 @@ where FROM blind_signature WHERE - keyset_id=:keyset_id + keyset_id=:keyset_id AND c IS NOT NULL "#, )? .bind("keyset_id", keyset_id.to_string()) @@ -1734,7 +1888,7 @@ where FROM blind_signature WHERE - quote_id=:quote_id + quote_id=:quote_id AND c IS NOT NULL "#, )? .bind("quote_id", quote_id.to_string()) diff --git a/crates/cdk/src/mint/blinded_message_writer.rs b/crates/cdk/src/mint/blinded_message_writer.rs new file mode 100644 index 0000000000..c7f81751c0 --- /dev/null +++ b/crates/cdk/src/mint/blinded_message_writer.rs @@ -0,0 +1,152 @@ +//! Blinded message writer +use std::collections::HashSet; + +use cdk_common::database::{self, DynMintDatabase, MintTransaction}; +use cdk_common::nuts::BlindedMessage; +use cdk_common::{Error, PublicKey, QuoteId}; + +type Tx<'a, 'b> = Box + Send + Sync + 'b>; + +/// Blinded message writer +/// +/// This is a blinded message writer that emulates a database transaction but without holding the +/// transaction alive while waiting for external events to be fully committed to the database; +/// instead, it maintains a `pending` state. +/// +/// This struct allows for premature exit on error, enabling it to remove blinded messages that +/// were added during the operation. +/// +/// This struct is not fully ACID. If the process exits due to a panic, and the `Drop` function +/// cannot be run, the cleanup process should reset the state. +pub struct BlindedMessageWriter { + db: Option, + added_blinded_secrets: Option>, +} + +impl BlindedMessageWriter { + /// Creates a new BlindedMessageWriter on top of the database + pub fn new(db: DynMintDatabase) -> Self { + Self { + db: Some(db), + added_blinded_secrets: Some(Default::default()), + } + } + + /// The changes are permanent, consume the struct removing the database, so the Drop does + /// nothing + pub fn commit(mut self) { + self.db.take(); + self.added_blinded_secrets.take(); + } + + /// Add blinded messages + pub async fn add_blinded_messages( + &mut self, + tx: &mut Tx<'_, '_>, + quote_id: Option, + blinded_messages: &[BlindedMessage], + ) -> Result, Error> { + let added_secrets = if let Some(secrets) = self.added_blinded_secrets.as_mut() { + secrets + } else { + return Err(Error::Internal); + }; + + if let Some(err) = tx + .add_blinded_messages(quote_id.as_ref(), blinded_messages) + .await + .err() + { + return match err { + cdk_common::database::Error::Duplicate => Err(Error::DuplicateOutputs), + err => Err(Error::Database(err)), + }; + } + + let blinded_secrets: Vec = blinded_messages + .iter() + .map(|bm| bm.blinded_secret) + .collect(); + + for blinded_secret in &blinded_secrets { + added_secrets.insert(*blinded_secret); + } + + Ok(blinded_secrets) + } + + /// Rollback all changes in this BlindedMessageWriter consuming it. + pub async fn rollback(mut self) -> Result<(), Error> { + let db = if let Some(db) = self.db.take() { + db + } else { + return Ok(()); + }; + let mut tx = db.begin_transaction().await?; + let blinded_secrets: Vec = + if let Some(secrets) = self.added_blinded_secrets.take() { + secrets.into_iter().collect() + } else { + return Ok(()); + }; + + if !blinded_secrets.is_empty() { + tracing::info!("Rollback {} blinded messages", blinded_secrets.len(),); + + remove_blinded_messages(&mut tx, &blinded_secrets).await?; + } + + tx.commit().await?; + + Ok(()) + } +} + +/// Removes blinded messages from the database +#[inline(always)] +async fn remove_blinded_messages( + tx: &mut Tx<'_, '_>, + blinded_secrets: &[PublicKey], +) -> Result<(), Error> { + tx.delete_blinded_messages(blinded_secrets) + .await + .map_err(Error::Database) +} + +#[inline(always)] +async fn rollback_blinded_messages( + db: DynMintDatabase, + blinded_secrets: Vec, +) -> Result<(), Error> { + let mut tx = db.begin_transaction().await?; + remove_blinded_messages(&mut tx, &blinded_secrets).await?; + tx.commit().await?; + + Ok(()) +} + +impl Drop for BlindedMessageWriter { + fn drop(&mut self) { + let db = if let Some(db) = self.db.take() { + db + } else { + tracing::debug!("Blinded message writer dropped after commit, no need to rollback."); + return; + }; + let blinded_secrets: Vec = + if let Some(secrets) = self.added_blinded_secrets.take() { + secrets.into_iter().collect() + } else { + return; + }; + + if !blinded_secrets.is_empty() { + tracing::debug!("Blinded message writer dropper with messages attempting to remove."); + tokio::spawn(async move { + if let Err(err) = rollback_blinded_messages(db, blinded_secrets).await { + tracing::error!("Failed to rollback blinded messages in Drop: {}", err); + } + }); + } + } +} diff --git a/crates/cdk/src/mint/melt.rs b/crates/cdk/src/mint/melt.rs index f237a70059..166e554f3a 100644 --- a/crates/cdk/src/mint/melt.rs +++ b/crates/cdk/src/mint/melt.rs @@ -636,10 +636,15 @@ impl Mint { let inputs_fee = self.get_proofs_fee(melt_request.inputs()).await?; - tx.add_melt_request_and_blinded_messages( + tx.add_melt_request( melt_request.quote_id(), melt_request.inputs_amount()?, inputs_fee, + ) + .await?; + + tx.add_blinded_messages( + Some(melt_request.quote_id()), melt_request.outputs().as_ref().unwrap_or(&Vec::new()), ) .await?; @@ -997,6 +1002,9 @@ impl Mint { change = Some(change_sigs); proof_writer.commit(); + + tx.delete_melt_request("e.id).await?; + tx.commit().await?; } else { tracing::info!( @@ -1006,11 +1014,13 @@ impl Mint { total_spent ); proof_writer.commit(); + tx.delete_melt_request("e.id).await?; tx.commit().await?; } } else { tracing::debug!("No change required for melt {}", quote.id); proof_writer.commit(); + tx.delete_melt_request("e.id).await?; tx.commit().await?; } diff --git a/crates/cdk/src/mint/mod.rs b/crates/cdk/src/mint/mod.rs index 3f99691cfb..f93be3b657 100644 --- a/crates/cdk/src/mint/mod.rs +++ b/crates/cdk/src/mint/mod.rs @@ -34,6 +34,7 @@ use crate::{cdk_database, Amount}; #[cfg(feature = "auth")] pub(crate) mod auth; +mod blinded_message_writer; mod builder; mod check_spendable; mod issue; diff --git a/crates/cdk/src/mint/swap.rs b/crates/cdk/src/mint/swap.rs index 0cab12a318..8b3cc37118 100644 --- a/crates/cdk/src/mint/swap.rs +++ b/crates/cdk/src/mint/swap.rs @@ -2,6 +2,7 @@ use cdk_prometheus::METRICS; use tracing::instrument; +use super::blinded_message_writer::BlindedMessageWriter; use super::nut11::{enforce_sig_flag, EnforceSigFlag}; use super::proof_writer::ProofWriter; use super::{Mint, PublicKey, SigFlag, State, SwapRequest, SwapResponse}; @@ -21,11 +22,41 @@ impl Mint { swap_request.input_amount()?; swap_request.output_amount()?; + // We add blinded messages to db before attempting to sign + // this ensures that they are unique and have not been used before + let mut blinded_message_writer = BlindedMessageWriter::new(self.localstore.clone()); + let mut tx = self.localstore.begin_transaction().await?; + + match blinded_message_writer + .add_blinded_messages(&mut tx, None, swap_request.outputs()) + .await + { + Ok(_) => { + tx.commit().await?; + } + Err(err) => { + #[cfg(feature = "prometheus")] + { + METRICS.dec_in_flight_requests("process_swap_request"); + METRICS.record_mint_operation("process_swap_request", false); + METRICS.record_error(); + } + return Err(err); + } + } + let promises = self.blind_sign(swap_request.outputs().to_owned()).await?; let input_verification = self.verify_inputs(swap_request.inputs()) .await .map_err(|err| { + #[cfg(feature = "prometheus")] + { + METRICS.dec_in_flight_requests("process_swap_request"); + METRICS.record_mint_operation("process_swap_request", false); + METRICS.record_error(); + } + tracing::debug!("Input verification failed: {:?}", err); err })?; @@ -49,14 +80,21 @@ impl Mint { METRICS.record_error(); } + tx.rollback().await?; + blinded_message_writer.rollback().await?; + return Err(err); }; let validate_sig_result = self.validate_sig_flag(&swap_request).await; - if validate_sig_result.is_err() { + + if let Err(err) = validate_sig_result { + tx.rollback().await?; + blinded_message_writer.rollback().await?; + #[cfg(feature = "prometheus")] self.record_swap_failure("process_swap_request"); - return Err(validate_sig_result.err().unwrap()); + return Err(err); } let mut proof_writer = ProofWriter::new(self.localstore.clone(), self.pubsub_manager.clone()); @@ -72,6 +110,8 @@ impl Mint { METRICS.record_mint_operation("process_swap_request", false); METRICS.record_error(); } + tx.rollback().await?; + blinded_message_writer.rollback().await?; return Err(err); } }; @@ -80,10 +120,13 @@ impl Mint { .update_proofs_states(&mut tx, &input_ys, State::Spent) .await; - if update_proof_states_result.is_err() { + if let Err(err) = update_proof_states_result { #[cfg(feature = "prometheus")] self.record_swap_failure("process_swap_request"); - return Err(update_proof_states_result.err().unwrap()); + + tx.rollback().await?; + blinded_message_writer.rollback().await?; + return Err(err); } tx.add_blind_signatures( @@ -98,6 +141,7 @@ impl Mint { .await?; proof_writer.commit(); + blinded_message_writer.commit(); tx.commit().await?; let response = SwapResponse::new(promises); From f3235440409190338d81dddc85e22e9d2967c80e Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Sun, 28 Sep 2025 15:24:55 +0100 Subject: [PATCH 12/17] fix: lnbits msats error (#1134) * fix: lnbits msats error * fix: lnbits msats error --- crates/cdk-lnbits/src/lib.rs | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/crates/cdk-lnbits/src/lib.rs b/crates/cdk-lnbits/src/lib.rs index 2f5f84bba2..fec078c390 100644 --- a/crates/cdk-lnbits/src/lib.rs +++ b/crates/cdk-lnbits/src/lib.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use anyhow::anyhow; use async_trait::async_trait; -use cdk_common::amount::{to_unit, Amount, MSAT_IN_SAT}; +use cdk_common::amount::{to_unit, Amount}; use cdk_common::common::FeeReserve; use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState}; use cdk_common::payment::{ @@ -191,10 +191,6 @@ impl MintPayment for LNbits { unit: &CurrencyUnit, options: OutgoingPaymentOptions, ) -> Result { - if unit != &CurrencyUnit::Sat { - return Err(Self::Err::Anyhow(anyhow!("Unsupported unit"))); - } - match options { OutgoingPaymentOptions::Bolt11(bolt11_options) => { let amount_msat = match bolt11_options.melt_options { @@ -211,10 +207,8 @@ impl MintPayment for LNbits { .into(), }; - let amount = amount_msat / MSAT_IN_SAT.into(); - let relative_fee_reserve = - (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; + (self.fee_reserve.percent_fee_reserve * u64::from(amount_msat) as f32) as u64; let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into(); @@ -224,7 +218,7 @@ impl MintPayment for LNbits { request_lookup_id: Some(PaymentIdentifier::PaymentHash( *bolt11_options.bolt11.payment_hash().as_ref(), )), - amount, + amount: to_unit(amount_msat, &CurrencyUnit::Msat, unit)?, fee: fee.into(), state: MeltQuoteState::Unpaid, unit: unit.clone(), @@ -302,10 +296,6 @@ impl MintPayment for LNbits { unit: &CurrencyUnit, options: IncomingPaymentOptions, ) -> Result { - if unit != &CurrencyUnit::Sat { - return Err(Self::Err::Anyhow(anyhow!("Unsupported unit"))); - } - match options { IncomingPaymentOptions::Bolt11(bolt11_options) => { let description = bolt11_options.description.unwrap_or_default(); From 5e93529398f4bdad17412b52dcd01b18f5409352 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Tue, 30 Sep 2025 09:12:29 +0100 Subject: [PATCH 13/17] fix: show ammounts (#1136) --- crates/cdk-cli/src/sub_commands/melt.rs | 143 +++++++++++++----------- crates/cdk-cli/src/sub_commands/send.rs | 102 +++++++++++------ 2 files changed, 143 insertions(+), 102 deletions(-) diff --git a/crates/cdk-cli/src/sub_commands/melt.rs b/crates/cdk-cli/src/sub_commands/melt.rs index a07db2ff96..39d7c867b2 100644 --- a/crates/cdk-cli/src/sub_commands/melt.rs +++ b/crates/cdk-cli/src/sub_commands/melt.rs @@ -71,6 +71,50 @@ pub async fn pay( bail!("No funds available"); } + // Determine which mint to use for melting BEFORE processing payment (unless using MPP) + let selected_mint = if sub_command_args.mpp { + None // MPP mode handles mint selection differently + } else if let Some(mint_url) = &sub_command_args.mint_url { + Some(MintUrl::from_str(mint_url)?) + } else { + // Display all mints with their balances and let user select + let balances_map = multi_mint_wallet.get_balances().await?; + if balances_map.is_empty() { + bail!("No mints available in the wallet"); + } + + let balances_vec: Vec<(MintUrl, Amount)> = balances_map.into_iter().collect(); + + println!("\nAvailable mints and balances:"); + for (index, (mint_url, balance)) in balances_vec.iter().enumerate() { + println!( + " {}: {} - {} {}", + index, + mint_url, + balance, + multi_mint_wallet.unit() + ); + } + println!(" {}: Any mint (auto-select best)", balances_vec.len()); + + let selection = loop { + let selection: usize = + get_number_input("Enter mint number to melt from (or select Any)")?; + + if selection == balances_vec.len() { + break None; // "Any" option selected + } + + if let Some((mint_url, _)) = balances_vec.get(selection) { + break Some(mint_url.clone()); + } + + println!("Invalid selection, please try again."); + }; + + selection + }; + if sub_command_args.mpp { // Manual MPP - user specifies which mints and amounts to use if !matches!(sub_command_args.method, PaymentType::Bolt11) { @@ -180,12 +224,9 @@ pub async fn pay( let options = create_melt_options(available_funds, bolt11.amount_milli_satoshis(), &prompt)?; - // Use mint-specific functions or auto-select - let melted = if let Some(mint_url) = &sub_command_args.mint_url { - // User specified a mint - use the new mint-specific functions - let mint_url = MintUrl::from_str(mint_url)?; - - // Create a melt quote for the specific mint + // Use selected mint or auto-select + let melted = if let Some(mint_url) = selected_mint { + // User selected a specific mint - use the new mint-specific functions let quote = multi_mint_wallet .melt_quote(&mint_url, bolt11_str.clone(), options) .await?; @@ -200,7 +241,7 @@ pub async fn pay( .melt_with_mint(&mint_url, "e.id) .await? } else { - // Let the wallet automatically select the best mint + // User selected "Any" - let the wallet auto-select the best mint multi_mint_wallet.melt(&bolt11_str, options, None).await? }; @@ -227,41 +268,25 @@ pub async fn pay( let options = create_melt_options(available_funds, amount_msat, &prompt)?; - // Get wallet for BOLT12 - let wallet = if let Some(mint_url) = &sub_command_args.mint_url { - // User specified a mint - let mint_url = MintUrl::from_str(mint_url)?; - multi_mint_wallet - .get_wallet(&mint_url) - .await - .ok_or_else(|| anyhow::anyhow!("Mint {} not found", mint_url))? + // Get wallet for BOLT12 using the selected mint + let mint_url = if let Some(specific_mint) = selected_mint { + specific_mint } else { - // Show available mints and let user select + // User selected "Any" - just pick the first mint with any balance let balances = multi_mint_wallet.get_balances().await?; - println!("\nAvailable mints:"); - for (i, (mint_url, balance)) in balances.iter().enumerate() { - println!( - " {}: {} - {} {}", - i, - mint_url, - balance, - multi_mint_wallet.unit() - ); - } - - let mint_number: usize = get_number_input("Enter mint number to melt from")?; - let selected_mint = balances - .iter() - .nth(mint_number) - .map(|(url, _)| url) - .ok_or_else(|| anyhow::anyhow!("Invalid mint number"))?; - multi_mint_wallet - .get_wallet(selected_mint) - .await - .ok_or_else(|| anyhow::anyhow!("Mint {} not found", selected_mint))? + balances + .into_iter() + .find(|(_, balance)| *balance > Amount::ZERO) + .map(|(mint_url, _)| mint_url) + .ok_or_else(|| anyhow::anyhow!("No mint available for BOLT12 payment"))? }; + let wallet = multi_mint_wallet + .get_wallet(&mint_url) + .await + .ok_or_else(|| anyhow::anyhow!("Mint {} not found", mint_url))?; + // Get melt quote for BOLT12 let quote = wallet.melt_bolt12_quote(offer_str, options).await?; @@ -293,41 +318,25 @@ pub async fn pay( // BIP353 payments are always amountless for now let options = create_melt_options(available_funds, None, &prompt)?; - // Get wallet for BIP353 - let wallet = if let Some(mint_url) = &sub_command_args.mint_url { - // User specified a mint - let mint_url = MintUrl::from_str(mint_url)?; - multi_mint_wallet - .get_wallet(&mint_url) - .await - .ok_or_else(|| anyhow::anyhow!("Mint {} not found", mint_url))? + // Get wallet for BIP353 using the selected mint + let mint_url = if let Some(specific_mint) = selected_mint { + specific_mint } else { - // Show available mints and let user select + // User selected "Any" - just pick the first mint with any balance let balances = multi_mint_wallet.get_balances().await?; - println!("\nAvailable mints:"); - for (i, (mint_url, balance)) in balances.iter().enumerate() { - println!( - " {}: {} - {} {}", - i, - mint_url, - balance, - multi_mint_wallet.unit() - ); - } - - let mint_number: usize = get_number_input("Enter mint number to melt from")?; - let selected_mint = balances - .iter() - .nth(mint_number) - .map(|(url, _)| url) - .ok_or_else(|| anyhow::anyhow!("Invalid mint number"))?; - multi_mint_wallet - .get_wallet(selected_mint) - .await - .ok_or_else(|| anyhow::anyhow!("Mint {} not found", selected_mint))? + balances + .into_iter() + .find(|(_, balance)| *balance > Amount::ZERO) + .map(|(mint_url, _)| mint_url) + .ok_or_else(|| anyhow::anyhow!("No mint available for BIP353 payment"))? }; + let wallet = multi_mint_wallet + .get_wallet(&mint_url) + .await + .ok_or_else(|| anyhow::anyhow!("Mint {} not found", mint_url))?; + // Get melt quote for BIP353 address (internally resolves and gets BOLT12 quote) let quote = wallet .melt_bip353_quote( diff --git a/crates/cdk-cli/src/sub_commands/send.rs b/crates/cdk-cli/src/sub_commands/send.rs index 0c8fb61f25..8e2dc4af59 100644 --- a/crates/cdk-cli/src/sub_commands/send.rs +++ b/crates/cdk-cli/src/sub_commands/send.rs @@ -54,9 +54,7 @@ pub struct SendSubCommand { /// Maximum amount to transfer from other mints #[arg(long)] max_transfer_amount: Option, - /// Specific mints allowed for transfers (can be specified multiple times) - #[arg(long, action = clap::ArgAction::Append)] - allowed_mints: Vec, + /// Specific mints to exclude from transfers (can be specified multiple times) #[arg(long, action = clap::ArgAction::Append)] excluded_mints: Vec, @@ -66,6 +64,48 @@ pub async fn send( multi_mint_wallet: &MultiMintWallet, sub_command_args: &SendSubCommand, ) -> Result<()> { + // Determine which mint to use for sending BEFORE asking for amount + let selected_mint = if let Some(mint_url) = &sub_command_args.mint_url { + Some(MintUrl::from_str(mint_url)?) + } else { + // Display all mints with their balances and let user select + let balances_map = multi_mint_wallet.get_balances().await?; + if balances_map.is_empty() { + return Err(anyhow!("No mints available in the wallet")); + } + + let balances_vec: Vec<(MintUrl, Amount)> = balances_map.into_iter().collect(); + + println!("\nAvailable mints and balances:"); + for (index, (mint_url, balance)) in balances_vec.iter().enumerate() { + println!( + " {}: {} - {} {}", + index, + mint_url, + balance, + multi_mint_wallet.unit() + ); + } + println!(" {}: Any mint (auto-select best)", balances_vec.len()); + + let selection = loop { + let selection: usize = + get_number_input("Enter mint number to send from (or select Any)")?; + + if selection == balances_vec.len() { + break None; // "Any" option selected + } + + if let Some((mint_url, _)) = balances_vec.get(selection) { + break Some(mint_url.clone()); + } + + println!("Invalid selection, please try again."); + }; + + selection + }; + let token_amount = Amount::from(get_number_input::(&format!( "Enter value of token in {}", multi_mint_wallet.unit() @@ -214,14 +254,7 @@ pub async fn send( ..Default::default() }; - // Parse allowed and excluded mints from CLI arguments - let allowed_mints: Result, _> = sub_command_args - .allowed_mints - .iter() - .map(|url| MintUrl::from_str(url)) - .collect(); - let allowed_mints = allowed_mints?; - + // Parse excluded mints from CLI arguments let excluded_mints: Result, _> = sub_command_args .excluded_mints .iter() @@ -229,45 +262,44 @@ pub async fn send( .collect(); let excluded_mints = excluded_mints?; - // Create MultiMintSendOptions from CLI arguments - let multi_mint_options = cdk::wallet::multi_mint_wallet::MultiMintSendOptions { - allow_transfer: sub_command_args.allow_transfer, - max_transfer_amount: sub_command_args.max_transfer_amount.map(Amount::from), - allowed_mints, - excluded_mints, - send_options: send_options.clone(), - }; + // Prepare and confirm the send based on mint selection + let token = if let Some(specific_mint) = selected_mint { + // User selected a specific mint + let multi_mint_options = cdk::wallet::multi_mint_wallet::MultiMintSendOptions { + allow_transfer: sub_command_args.allow_transfer, + max_transfer_amount: sub_command_args.max_transfer_amount.map(Amount::from), + allowed_mints: vec![specific_mint.clone()], // Use selected mint as the only allowed mint + excluded_mints, + send_options: send_options.clone(), + }; - // Use the new unified interface - let token = if let Some(mint_url) = &sub_command_args.mint_url { - // User specified a mint, use that specific wallet - let mint_url = cdk::mint_url::MintUrl::from_str(mint_url)?; let prepared = multi_mint_wallet - .prepare_send(mint_url, token_amount, multi_mint_options) + .prepare_send(specific_mint, token_amount, multi_mint_options) .await?; - // Confirm the prepared send (single mint) let memo = send_options.memo.clone(); prepared.confirm(memo).await? } else { - // Let the wallet automatically select the best mint - // First, get balances to find a mint with sufficient funds + // User selected "Any" - find the first mint with sufficient balance let balances = multi_mint_wallet.get_balances().await?; - - // Find a mint with sufficient balance - let mint_url = balances + let best_mint = balances .into_iter() .find(|(_, balance)| *balance >= token_amount) .map(|(mint_url, _)| mint_url) - .ok_or_else(|| { - anyhow::anyhow!("No mint has sufficient balance for the requested amount") - })?; + .ok_or_else(|| anyhow!("No mint has sufficient balance for the requested amount"))?; + + let multi_mint_options = cdk::wallet::multi_mint_wallet::MultiMintSendOptions { + allow_transfer: sub_command_args.allow_transfer, + max_transfer_amount: sub_command_args.max_transfer_amount.map(Amount::from), + allowed_mints: vec![best_mint.clone()], // Use the best mint as the only allowed mint + excluded_mints, + send_options: send_options.clone(), + }; let prepared = multi_mint_wallet - .prepare_send(mint_url, token_amount, multi_mint_options) + .prepare_send(best_mint, token_amount, multi_mint_options) .await?; - // Confirm the prepared send (multi mint) let memo = send_options.memo.clone(); prepared.confirm(memo).await? }; From ddec3bb212d326e0a72003c96dba5eaa3bb0656b Mon Sep 17 00:00:00 2001 From: David Caseria Date: Tue, 30 Sep 2025 04:12:53 -0400 Subject: [PATCH 14/17] Update README with missing crates (#1140) --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index ae01fc79f3..31ff04b14c 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ CDK is a collection of rust crates for [Cashu](https://github.com/cashubtc) wall The project is split up into several crates in the `crates/` directory: * Libraries: + * [**cashu**](./crates/cashu/): Core Cashu protocol implementation. * [**cdk**](./crates/cdk/): Rust implementation of Cashu protocol. * [**cdk-sqlite**](./crates/cdk-sqlite/): SQLite Storage backend. * [**cdk-postgres**](./crates/cdk-postgres/): PostgreSQL Storage backend. @@ -25,6 +26,13 @@ The project is split up into several crates in the `crates/` directory: * [**cdk-lnbits**](./crates/cdk-lnbits/): [LNbits](https://lnbits.com/) Lightning backend for mint. **Note: Only LNBits v1 API is supported.** * [**cdk-ldk-node**](./crates/cdk-ldk-node/): LDK Node Lightning backend for mint. * [**cdk-fake-wallet**](./crates/cdk-fake-wallet/): Fake Lightning backend for mint. To be used only for testing, quotes are automatically filled. + * [**cdk-common**](./crates/cdk-common/): Common utilities and shared code. + * [**cdk-sql-common**](./crates/cdk-sql-common/): Common SQL utilities for storage backends. + * [**cdk-signatory**](./crates/cdk-signatory/): Signing utilities and cryptographic operations. + * [**cdk-payment-processor**](./crates/cdk-payment-processor/): Payment processing functionality. + * [**cdk-prometheus**](./crates/cdk-prometheus/): Prometheus metrics integration. + * [**cdk-ffi**](./crates/cdk-ffi/): Foreign Function Interface bindings for other languages. + * [**cdk-integration-tests**](./crates/cdk-integration-tests/): Integration test suite. * [**cdk-mint-rpc**](./crates/cdk-mint-rpc/): Mint management gRPC server and cli. * Binaries: * [**cdk-cli**](./crates/cdk-cli/): Cashu wallet CLI. From cb2e534f81bdda30d9d59150c603b3c966ddc7bf Mon Sep 17 00:00:00 2001 From: asmo Date: Tue, 30 Sep 2025 15:14:28 +0200 Subject: [PATCH 15/17] feat: added postgres to ffi (#1117) * feat: added postgres to ffi --- crates/cdk-ffi/Cargo.toml | 6 + crates/cdk-ffi/src/database.rs | 406 +----------------- crates/cdk-ffi/src/lib.rs | 2 + crates/cdk-ffi/src/postgres.rs | 401 +++++++++++++++++ crates/cdk-ffi/src/sqlite.rs | 402 +++++++++++++++++ .../tests/ffi_minting_integration.rs | 2 +- crates/cdk-postgres/src/lib.rs | 8 +- .../postgres/20250729111701_keyset_v2_u32.sql | 11 + justfile | 4 +- 9 files changed, 853 insertions(+), 389 deletions(-) create mode 100644 crates/cdk-ffi/src/postgres.rs create mode 100644 crates/cdk-ffi/src/sqlite.rs create mode 100644 crates/cdk-sql-common/src/wallet/migrations/postgres/20250729111701_keyset_v2_u32.sql diff --git a/crates/cdk-ffi/Cargo.toml b/crates/cdk-ffi/Cargo.toml index 80382a084e..b37b47b4ac 100644 --- a/crates/cdk-ffi/Cargo.toml +++ b/crates/cdk-ffi/Cargo.toml @@ -17,6 +17,7 @@ async-trait = { workspace = true } bip39 = { workspace = true } cdk = { workspace = true, default-features = false, features = ["wallet", "auth", "bip353"] } cdk-sqlite = { workspace = true } +cdk-postgres = { workspace = true, optional = true } ctor = "0.2" futures = { workspace = true } once_cell = { workspace = true } @@ -30,6 +31,11 @@ url = { workspace = true } uuid = { workspace = true, features = ["v4"] } +[features] +default = ["postgres"] +# Enable Postgres-backed wallet database support in FFI +postgres = ["cdk-postgres"] + [dev-dependencies] [[bin]] diff --git a/crates/cdk-ffi/src/database.rs b/crates/cdk-ffi/src/database.rs index b1110aed6f..578681b668 100644 --- a/crates/cdk-ffi/src/database.rs +++ b/crates/cdk-ffi/src/database.rs @@ -4,9 +4,10 @@ use std::collections::HashMap; use std::sync::Arc; use cdk::cdk_database::WalletDatabase as CdkWalletDatabase; -use cdk_sqlite::wallet::WalletSqliteDatabase as CdkWalletSqliteDatabase; use crate::error::FfiError; +use crate::postgres::WalletPostgresDatabase; +use crate::sqlite::WalletSqliteDatabase; use crate::types::*; /// FFI-compatible trait for wallet database operations @@ -171,7 +172,6 @@ impl CdkWalletDatabase for WalletDatabaseBridge { ) -> Result<(), Self::Err> { let ffi_mint_url = mint_url.into(); let ffi_mint_info = mint_info.map(Into::into); - self.ffi_db .add_mint(ffi_mint_url, ffi_mint_info) .await @@ -556,394 +556,30 @@ impl CdkWalletDatabase for WalletDatabaseBridge { } } -/// FFI-compatible WalletSqliteDatabase implementation that implements the WalletDatabase trait -#[derive(uniffi::Object)] -pub struct WalletSqliteDatabase { - inner: Arc, -} - -impl WalletSqliteDatabase { - // No additional methods needed beyond the trait implementation +/// FFI-safe wallet database backend selection +#[derive(uniffi::Enum)] +pub enum WalletDbBackend { + Sqlite { + path: String, + }, + #[cfg(feature = "postgres")] + Postgres { + url: String, + }, } +/// Factory helpers returning a CDK wallet database behind the FFI trait #[uniffi::export] -impl WalletSqliteDatabase { - /// Create a new WalletSqliteDatabase with the given work directory - #[uniffi::constructor] - pub fn new(file_path: String) -> Result, FfiError> { - let db = match tokio::runtime::Handle::try_current() { - Ok(handle) => tokio::task::block_in_place(|| { - handle - .block_on(async move { CdkWalletSqliteDatabase::new(file_path.as_str()).await }) - }), - Err(_) => { - // No current runtime, create a new one - tokio::runtime::Runtime::new() - .map_err(|e| FfiError::Database { - msg: format!("Failed to create runtime: {}", e), - })? - .block_on(async move { CdkWalletSqliteDatabase::new(file_path.as_str()).await }) - } +pub fn create_wallet_db(backend: WalletDbBackend) -> Result, FfiError> { + match backend { + WalletDbBackend::Sqlite { path } => { + let sqlite = WalletSqliteDatabase::new(path)?; + Ok(sqlite as Arc) } - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(Arc::new(Self { - inner: Arc::new(db), - })) - } - - /// Create an in-memory database - #[uniffi::constructor] - pub fn new_in_memory() -> Result, FfiError> { - let db = match tokio::runtime::Handle::try_current() { - Ok(handle) => tokio::task::block_in_place(|| { - handle.block_on(async move { cdk_sqlite::wallet::memory::empty().await }) - }), - Err(_) => { - // No current runtime, create a new one - tokio::runtime::Runtime::new() - .map_err(|e| FfiError::Database { - msg: format!("Failed to create runtime: {}", e), - })? - .block_on(async move { cdk_sqlite::wallet::memory::empty().await }) - } + WalletDbBackend::Postgres { url } => { + let pg = WalletPostgresDatabase::new(url)?; + Ok(pg as Arc) } - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(Arc::new(Self { - inner: Arc::new(db), - })) - } -} - -#[uniffi::export(async_runtime = "tokio")] -#[async_trait::async_trait] -impl WalletDatabase for WalletSqliteDatabase { - // Mint Management - async fn add_mint( - &self, - mint_url: MintUrl, - mint_info: Option, - ) -> Result<(), FfiError> { - let cdk_mint_url = mint_url.try_into()?; - let cdk_mint_info = mint_info.map(Into::into); - self.inner - .add_mint(cdk_mint_url, cdk_mint_info) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - async fn remove_mint(&self, mint_url: MintUrl) -> Result<(), FfiError> { - let cdk_mint_url = mint_url.try_into()?; - self.inner - .remove_mint(cdk_mint_url) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - async fn get_mint(&self, mint_url: MintUrl) -> Result, FfiError> { - let cdk_mint_url = mint_url.try_into()?; - let result = self - .inner - .get_mint(cdk_mint_url) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(result.map(Into::into)) - } - - async fn get_mints(&self) -> Result>, FfiError> { - let result = self - .inner - .get_mints() - .await - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(result - .into_iter() - .map(|(k, v)| (k.into(), v.map(Into::into))) - .collect()) - } - - async fn update_mint_url( - &self, - old_mint_url: MintUrl, - new_mint_url: MintUrl, - ) -> Result<(), FfiError> { - let cdk_old_mint_url = old_mint_url.try_into()?; - let cdk_new_mint_url = new_mint_url.try_into()?; - self.inner - .update_mint_url(cdk_old_mint_url, cdk_new_mint_url) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - // Keyset Management - async fn add_mint_keysets( - &self, - mint_url: MintUrl, - keysets: Vec, - ) -> Result<(), FfiError> { - let cdk_mint_url = mint_url.try_into()?; - let cdk_keysets: Vec = keysets.into_iter().map(Into::into).collect(); - self.inner - .add_mint_keysets(cdk_mint_url, cdk_keysets) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - async fn get_mint_keysets( - &self, - mint_url: MintUrl, - ) -> Result>, FfiError> { - let cdk_mint_url = mint_url.try_into()?; - let result = self - .inner - .get_mint_keysets(cdk_mint_url) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(result.map(|keysets| keysets.into_iter().map(Into::into).collect())) - } - - async fn get_keyset_by_id(&self, keyset_id: Id) -> Result, FfiError> { - let cdk_id = keyset_id.into(); - let result = self - .inner - .get_keyset_by_id(&cdk_id) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(result.map(Into::into)) - } - - // Mint Quote Management - async fn add_mint_quote(&self, quote: MintQuote) -> Result<(), FfiError> { - let cdk_quote = quote.try_into()?; - self.inner - .add_mint_quote(cdk_quote) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - async fn get_mint_quote(&self, quote_id: String) -> Result, FfiError> { - let result = self - .inner - .get_mint_quote("e_id) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(result.map(|q| q.into())) - } - - async fn get_mint_quotes(&self) -> Result, FfiError> { - let result = self - .inner - .get_mint_quotes() - .await - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(result.into_iter().map(|q| q.into()).collect()) - } - - async fn remove_mint_quote(&self, quote_id: String) -> Result<(), FfiError> { - self.inner - .remove_mint_quote("e_id) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - // Melt Quote Management - async fn add_melt_quote(&self, quote: MeltQuote) -> Result<(), FfiError> { - let cdk_quote = quote.try_into()?; - self.inner - .add_melt_quote(cdk_quote) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - async fn get_melt_quote(&self, quote_id: String) -> Result, FfiError> { - let result = self - .inner - .get_melt_quote("e_id) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(result.map(|q| q.into())) - } - - async fn get_melt_quotes(&self) -> Result, FfiError> { - let result = self - .inner - .get_melt_quotes() - .await - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(result.into_iter().map(|q| q.into()).collect()) - } - - async fn remove_melt_quote(&self, quote_id: String) -> Result<(), FfiError> { - self.inner - .remove_melt_quote("e_id) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - // Keys Management - async fn add_keys(&self, keyset: KeySet) -> Result<(), FfiError> { - // Convert FFI KeySet to cdk::nuts::KeySet - let cdk_keyset: cdk::nuts::KeySet = keyset.try_into()?; - self.inner - .add_keys(cdk_keyset) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - async fn get_keys(&self, id: Id) -> Result, FfiError> { - let cdk_id = id.into(); - let result = self - .inner - .get_keys(&cdk_id) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(result.map(Into::into)) - } - - async fn remove_keys(&self, id: Id) -> Result<(), FfiError> { - let cdk_id = id.into(); - self.inner - .remove_keys(&cdk_id) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - // Proof Management - async fn update_proofs( - &self, - added: Vec, - removed_ys: Vec, - ) -> Result<(), FfiError> { - // Convert FFI types to CDK types - let cdk_added: Result, FfiError> = added - .into_iter() - .map(|info| { - Ok::(cdk::types::ProofInfo { - proof: info.proof.inner.clone(), - y: info.y.try_into()?, - mint_url: info.mint_url.try_into()?, - state: info.state.into(), - spending_condition: info - .spending_condition - .map(|sc| sc.try_into()) - .transpose()?, - unit: info.unit.into(), - }) - }) - .collect(); - let cdk_added = cdk_added?; - - let cdk_removed_ys: Result, FfiError> = - removed_ys.into_iter().map(|pk| pk.try_into()).collect(); - let cdk_removed_ys = cdk_removed_ys?; - - self.inner - .update_proofs(cdk_added, cdk_removed_ys) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - async fn get_proofs( - &self, - mint_url: Option, - unit: Option, - state: Option>, - spending_conditions: Option>, - ) -> Result, FfiError> { - let cdk_mint_url = mint_url.map(|u| u.try_into()).transpose()?; - let cdk_unit = unit.map(Into::into); - let cdk_state = state.map(|s| s.into_iter().map(Into::into).collect()); - let cdk_spending_conditions: Option> = - spending_conditions - .map(|sc| { - sc.into_iter() - .map(|c| c.try_into()) - .collect::, FfiError>>() - }) - .transpose()?; - - let result = self - .inner - .get_proofs(cdk_mint_url, cdk_unit, cdk_state, cdk_spending_conditions) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - - Ok(result.into_iter().map(Into::into).collect()) - } - - async fn update_proofs_state( - &self, - ys: Vec, - state: ProofState, - ) -> Result<(), FfiError> { - let cdk_ys: Result, FfiError> = - ys.into_iter().map(|pk| pk.try_into()).collect(); - let cdk_ys = cdk_ys?; - let cdk_state = state.into(); - - self.inner - .update_proofs_state(cdk_ys, cdk_state) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - // Keyset Counter Management - async fn increment_keyset_counter(&self, keyset_id: Id, count: u32) -> Result { - let cdk_id = keyset_id.into(); - self.inner - .increment_keyset_counter(&cdk_id, count) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - // Transaction Management - async fn add_transaction(&self, transaction: Transaction) -> Result<(), FfiError> { - // Convert FFI Transaction to CDK Transaction using TryFrom - let cdk_transaction: cdk::wallet::types::Transaction = transaction.try_into()?; - - self.inner - .add_transaction(cdk_transaction) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - async fn get_transaction( - &self, - transaction_id: TransactionId, - ) -> Result, FfiError> { - let cdk_id = transaction_id.try_into()?; - let result = self - .inner - .get_transaction(cdk_id) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(result.map(Into::into)) - } - - async fn list_transactions( - &self, - mint_url: Option, - direction: Option, - unit: Option, - ) -> Result, FfiError> { - let cdk_mint_url = mint_url.map(|u| u.try_into()).transpose()?; - let cdk_direction = direction.map(Into::into); - let cdk_unit = unit.map(Into::into); - - let result = self - .inner - .list_transactions(cdk_mint_url, cdk_direction, cdk_unit) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - - Ok(result.into_iter().map(Into::into).collect()) - } - - async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), FfiError> { - let cdk_id = transaction_id.try_into()?; - self.inner - .remove_transaction(cdk_id) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) } } diff --git a/crates/cdk-ffi/src/lib.rs b/crates/cdk-ffi/src/lib.rs index beb5046fcc..57dbb8b4cd 100644 --- a/crates/cdk-ffi/src/lib.rs +++ b/crates/cdk-ffi/src/lib.rs @@ -7,6 +7,8 @@ pub mod database; pub mod error; pub mod multi_mint_wallet; +pub mod postgres; +pub mod sqlite; pub mod token; pub mod types; pub mod wallet; diff --git a/crates/cdk-ffi/src/postgres.rs b/crates/cdk-ffi/src/postgres.rs new file mode 100644 index 0000000000..f128b53f1c --- /dev/null +++ b/crates/cdk-ffi/src/postgres.rs @@ -0,0 +1,401 @@ +use std::collections::HashMap; +use std::sync::Arc; + +// Bring the CDK wallet database trait into scope so trait methods resolve on the inner DB +use cdk::cdk_database::WalletDatabase as CdkWalletDatabase; +#[cfg(feature = "postgres")] +use cdk_postgres::WalletPgDatabase as CdkWalletPgDatabase; + +use crate::{ + CurrencyUnit, FfiError, Id, KeySet, KeySetInfo, Keys, MeltQuote, MintInfo, MintQuote, MintUrl, + ProofInfo, ProofState, PublicKey, SpendingConditions, Transaction, TransactionDirection, + TransactionId, WalletDatabase, +}; + +#[derive(uniffi::Object)] +pub struct WalletPostgresDatabase { + inner: Arc, +} + +// Keep a long-lived Tokio runtime for Postgres-created resources so that +// background tasks (e.g., tokio-postgres connection drivers spawned during +// construction) are not tied to a short-lived, ad-hoc runtime. +#[cfg(feature = "postgres")] +static PG_RUNTIME: once_cell::sync::OnceCell = + once_cell::sync::OnceCell::new(); + +#[cfg(feature = "postgres")] +fn pg_runtime() -> &'static tokio::runtime::Runtime { + PG_RUNTIME.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_name("cdk-ffi-pg") + .build() + .expect("failed to build pg runtime") + }) +} + +// Implement the local WalletDatabase trait (simple trait path required by uniffi) +#[uniffi::export(async_runtime = "tokio")] +#[async_trait::async_trait] +impl WalletDatabase for WalletPostgresDatabase { + // Forward all trait methods to inner CDK database via the bridge adapter + async fn add_mint( + &self, + mint_url: MintUrl, + mint_info: Option, + ) -> Result<(), FfiError> { + let cdk_mint_url = mint_url.try_into()?; + let cdk_mint_info = mint_info.map(Into::into); + println!("adding new mint"); + self.inner + .add_mint(cdk_mint_url, cdk_mint_info) + .await + .map_err(|e| { + println!("ffi error {:?}", e); + FfiError::Database { msg: e.to_string() } + }) + } + async fn remove_mint(&self, mint_url: MintUrl) -> Result<(), FfiError> { + let cdk_mint_url = mint_url.try_into()?; + self.inner + .remove_mint(cdk_mint_url) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + async fn get_mint(&self, mint_url: MintUrl) -> Result, FfiError> { + let cdk_mint_url = mint_url.try_into()?; + let result = self + .inner + .get_mint(cdk_mint_url) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(Into::into)) + } + async fn get_mints(&self) -> Result>, FfiError> { + let result = self + .inner + .get_mints() + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result + .into_iter() + .map(|(k, v)| (k.into(), v.map(Into::into))) + .collect()) + } + async fn update_mint_url( + &self, + old_mint_url: MintUrl, + new_mint_url: MintUrl, + ) -> Result<(), FfiError> { + let cdk_old_mint_url = old_mint_url.try_into()?; + let cdk_new_mint_url = new_mint_url.try_into()?; + self.inner + .update_mint_url(cdk_old_mint_url, cdk_new_mint_url) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + async fn add_mint_keysets( + &self, + mint_url: MintUrl, + keysets: Vec, + ) -> Result<(), FfiError> { + let cdk_mint_url = mint_url.try_into()?; + let cdk_keysets: Vec = keysets.into_iter().map(Into::into).collect(); + self.inner + .add_mint_keysets(cdk_mint_url, cdk_keysets) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + async fn get_mint_keysets( + &self, + mint_url: MintUrl, + ) -> Result>, FfiError> { + let cdk_mint_url = mint_url.try_into()?; + let result = self + .inner + .get_mint_keysets(cdk_mint_url) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(|keysets| keysets.into_iter().map(Into::into).collect())) + } + + async fn get_keyset_by_id(&self, keyset_id: Id) -> Result, FfiError> { + let cdk_id = keyset_id.into(); + let result = self + .inner + .get_keyset_by_id(&cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(Into::into)) + } + + // Mint Quote Management + async fn add_mint_quote(&self, quote: MintQuote) -> Result<(), FfiError> { + let cdk_quote = quote.try_into()?; + self.inner + .add_mint_quote(cdk_quote) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_mint_quote(&self, quote_id: String) -> Result, FfiError> { + let result = self + .inner + .get_mint_quote("e_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(|q| q.into())) + } + + async fn get_mint_quotes(&self) -> Result, FfiError> { + let result = self + .inner + .get_mint_quotes() + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.into_iter().map(|q| q.into()).collect()) + } + + async fn remove_mint_quote(&self, quote_id: String) -> Result<(), FfiError> { + self.inner + .remove_mint_quote("e_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Melt Quote Management + async fn add_melt_quote(&self, quote: MeltQuote) -> Result<(), FfiError> { + let cdk_quote = quote.try_into()?; + self.inner + .add_melt_quote(cdk_quote) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_melt_quote(&self, quote_id: String) -> Result, FfiError> { + let result = self + .inner + .get_melt_quote("e_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(|q| q.into())) + } + + async fn get_melt_quotes(&self) -> Result, FfiError> { + let result = self + .inner + .get_melt_quotes() + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.into_iter().map(|q| q.into()).collect()) + } + + async fn remove_melt_quote(&self, quote_id: String) -> Result<(), FfiError> { + self.inner + .remove_melt_quote("e_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Keys Management + async fn add_keys(&self, keyset: KeySet) -> Result<(), FfiError> { + // Convert FFI KeySet to cdk::nuts::KeySet + let cdk_keyset: cdk::nuts::KeySet = keyset.try_into()?; + self.inner + .add_keys(cdk_keyset) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_keys(&self, id: Id) -> Result, FfiError> { + let cdk_id = id.into(); + let result = self + .inner + .get_keys(&cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(Into::into)) + } + + async fn remove_keys(&self, id: Id) -> Result<(), FfiError> { + let cdk_id = id.into(); + self.inner + .remove_keys(&cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Proof Management + async fn update_proofs( + &self, + added: Vec, + removed_ys: Vec, + ) -> Result<(), FfiError> { + // Convert FFI types to CDK types + let cdk_added: Result, FfiError> = added + .into_iter() + .map(|info| { + Ok::(cdk::types::ProofInfo { + proof: info.proof.inner.clone(), + y: info.y.try_into()?, + mint_url: info.mint_url.try_into()?, + state: info.state.into(), + spending_condition: info + .spending_condition + .map(|sc| sc.try_into()) + .transpose()?, + unit: info.unit.into(), + }) + }) + .collect(); + let cdk_added = cdk_added?; + + let cdk_removed_ys: Result, FfiError> = + removed_ys.into_iter().map(|pk| pk.try_into()).collect(); + let cdk_removed_ys = cdk_removed_ys?; + + self.inner + .update_proofs(cdk_added, cdk_removed_ys) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_proofs( + &self, + mint_url: Option, + unit: Option, + state: Option>, + spending_conditions: Option>, + ) -> Result, FfiError> { + let cdk_mint_url = mint_url.map(|u| u.try_into()).transpose()?; + let cdk_unit = unit.map(Into::into); + let cdk_state = state.map(|s| s.into_iter().map(Into::into).collect()); + let cdk_spending_conditions: Option> = + spending_conditions + .map(|sc| { + sc.into_iter() + .map(|c| c.try_into()) + .collect::, FfiError>>() + }) + .transpose()?; + + let result = self + .inner + .get_proofs(cdk_mint_url, cdk_unit, cdk_state, cdk_spending_conditions) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + + Ok(result.into_iter().map(Into::into).collect()) + } + + async fn update_proofs_state( + &self, + ys: Vec, + state: ProofState, + ) -> Result<(), FfiError> { + let cdk_ys: Result, FfiError> = + ys.into_iter().map(|pk| pk.try_into()).collect(); + let cdk_ys = cdk_ys?; + let cdk_state = state.into(); + + self.inner + .update_proofs_state(cdk_ys, cdk_state) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Keyset Counter Management + async fn increment_keyset_counter(&self, keyset_id: Id, count: u32) -> Result { + let cdk_id = keyset_id.into(); + self.inner + .increment_keyset_counter(&cdk_id, count) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Transaction Management + async fn add_transaction(&self, transaction: Transaction) -> Result<(), FfiError> { + // Convert FFI Transaction to CDK Transaction using TryFrom + let cdk_transaction: cdk::wallet::types::Transaction = transaction.try_into()?; + + self.inner + .add_transaction(cdk_transaction) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_transaction( + &self, + transaction_id: TransactionId, + ) -> Result, FfiError> { + let cdk_id = transaction_id.try_into()?; + let result = self + .inner + .get_transaction(cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(Into::into)) + } + + async fn list_transactions( + &self, + mint_url: Option, + direction: Option, + unit: Option, + ) -> Result, FfiError> { + let cdk_mint_url = mint_url.map(|u| u.try_into()).transpose()?; + let cdk_direction = direction.map(Into::into); + let cdk_unit = unit.map(Into::into); + + let result = self + .inner + .list_transactions(cdk_mint_url, cdk_direction, cdk_unit) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + + Ok(result.into_iter().map(Into::into).collect()) + } + + async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), FfiError> { + let cdk_id = transaction_id.try_into()?; + self.inner + .remove_transaction(cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } +} + +#[uniffi::export] +impl WalletPostgresDatabase { + /// Create a new Postgres-backed wallet database + /// Requires cdk-ffi to be built with feature "postgres". + /// Example URL: + /// "host=localhost user=test password=test dbname=testdb port=5433 schema=wallet sslmode=prefer" + #[cfg(feature = "postgres")] + #[uniffi::constructor] + pub fn new(url: String) -> Result, FfiError> { + let inner = match tokio::runtime::Handle::try_current() { + Ok(handle) => tokio::task::block_in_place(|| { + handle.block_on( + async move { cdk_postgres::new_wallet_pg_database(url.as_str()).await }, + ) + }), + // Important: use a process-long runtime so background connection tasks stay alive. + Err(_) => pg_runtime() + .block_on(async move { cdk_postgres::new_wallet_pg_database(url.as_str()).await }), + } + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(Arc::new(WalletPostgresDatabase { + inner: Arc::new(inner), + })) + } + + fn clone_as_trait(&self) -> Arc { + // Safety: UniFFI objects are reference counted and Send+Sync via Arc + let obj: Arc = Arc::new(WalletPostgresDatabase { + inner: self.inner.clone(), + }); + obj + } +} diff --git a/crates/cdk-ffi/src/sqlite.rs b/crates/cdk-ffi/src/sqlite.rs new file mode 100644 index 0000000000..8feae6e5b5 --- /dev/null +++ b/crates/cdk-ffi/src/sqlite.rs @@ -0,0 +1,402 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use cdk_sqlite::wallet::WalletSqliteDatabase as CdkWalletSqliteDatabase; + +use crate::{ + CurrencyUnit, FfiError, Id, KeySet, KeySetInfo, Keys, MeltQuote, MintInfo, MintQuote, MintUrl, + ProofInfo, ProofState, PublicKey, SpendingConditions, Transaction, TransactionDirection, + TransactionId, WalletDatabase, +}; + +/// FFI-compatible WalletSqliteDatabase implementation that implements the WalletDatabase trait +#[derive(uniffi::Object)] +pub struct WalletSqliteDatabase { + inner: Arc, +} +use cdk::cdk_database::WalletDatabase as CdkWalletDatabase; + +impl WalletSqliteDatabase { + // No additional methods needed beyond the trait implementation +} + +#[uniffi::export] +impl WalletSqliteDatabase { + /// Create a new WalletSqliteDatabase with the given work directory + #[uniffi::constructor] + pub fn new(file_path: String) -> Result, FfiError> { + let db = match tokio::runtime::Handle::try_current() { + Ok(handle) => tokio::task::block_in_place(|| { + handle + .block_on(async move { CdkWalletSqliteDatabase::new(file_path.as_str()).await }) + }), + Err(_) => { + // No current runtime, create a new one + tokio::runtime::Runtime::new() + .map_err(|e| FfiError::Database { + msg: format!("Failed to create runtime: {}", e), + })? + .block_on(async move { CdkWalletSqliteDatabase::new(file_path.as_str()).await }) + } + } + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(Arc::new(Self { + inner: Arc::new(db), + })) + } + + /// Create an in-memory database + #[uniffi::constructor] + pub fn new_in_memory() -> Result, FfiError> { + let db = match tokio::runtime::Handle::try_current() { + Ok(handle) => tokio::task::block_in_place(|| { + handle.block_on(async move { cdk_sqlite::wallet::memory::empty().await }) + }), + Err(_) => { + // No current runtime, create a new one + tokio::runtime::Runtime::new() + .map_err(|e| FfiError::Database { + msg: format!("Failed to create runtime: {}", e), + })? + .block_on(async move { cdk_sqlite::wallet::memory::empty().await }) + } + } + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(Arc::new(Self { + inner: Arc::new(db), + })) + } +} + +#[uniffi::export(async_runtime = "tokio")] +#[async_trait::async_trait] +impl WalletDatabase for WalletSqliteDatabase { + // Mint Management + async fn add_mint( + &self, + mint_url: MintUrl, + mint_info: Option, + ) -> Result<(), FfiError> { + let cdk_mint_url = mint_url.try_into()?; + let cdk_mint_info = mint_info.map(Into::into); + self.inner + .add_mint(cdk_mint_url, cdk_mint_info) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn remove_mint(&self, mint_url: MintUrl) -> Result<(), FfiError> { + let cdk_mint_url = mint_url.try_into()?; + self.inner + .remove_mint(cdk_mint_url) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_mint(&self, mint_url: MintUrl) -> Result, FfiError> { + let cdk_mint_url = mint_url.try_into()?; + let result = self + .inner + .get_mint(cdk_mint_url) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(Into::into)) + } + + async fn get_mints(&self) -> Result>, FfiError> { + let result = self + .inner + .get_mints() + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result + .into_iter() + .map(|(k, v)| (k.into(), v.map(Into::into))) + .collect()) + } + + async fn update_mint_url( + &self, + old_mint_url: MintUrl, + new_mint_url: MintUrl, + ) -> Result<(), FfiError> { + let cdk_old_mint_url = old_mint_url.try_into()?; + let cdk_new_mint_url = new_mint_url.try_into()?; + self.inner + .update_mint_url(cdk_old_mint_url, cdk_new_mint_url) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Keyset Management + async fn add_mint_keysets( + &self, + mint_url: MintUrl, + keysets: Vec, + ) -> Result<(), FfiError> { + let cdk_mint_url = mint_url.try_into()?; + let cdk_keysets: Vec = keysets.into_iter().map(Into::into).collect(); + self.inner + .add_mint_keysets(cdk_mint_url, cdk_keysets) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_mint_keysets( + &self, + mint_url: MintUrl, + ) -> Result>, FfiError> { + let cdk_mint_url = mint_url.try_into()?; + let result = self + .inner + .get_mint_keysets(cdk_mint_url) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(|keysets| keysets.into_iter().map(Into::into).collect())) + } + + async fn get_keyset_by_id(&self, keyset_id: Id) -> Result, FfiError> { + let cdk_id = keyset_id.into(); + let result = self + .inner + .get_keyset_by_id(&cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(Into::into)) + } + + // Mint Quote Management + async fn add_mint_quote(&self, quote: MintQuote) -> Result<(), FfiError> { + let cdk_quote = quote.try_into()?; + self.inner + .add_mint_quote(cdk_quote) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_mint_quote(&self, quote_id: String) -> Result, FfiError> { + let result = self + .inner + .get_mint_quote("e_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(|q| q.into())) + } + + async fn get_mint_quotes(&self) -> Result, FfiError> { + let result = self + .inner + .get_mint_quotes() + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.into_iter().map(|q| q.into()).collect()) + } + + async fn remove_mint_quote(&self, quote_id: String) -> Result<(), FfiError> { + self.inner + .remove_mint_quote("e_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Melt Quote Management + async fn add_melt_quote(&self, quote: MeltQuote) -> Result<(), FfiError> { + let cdk_quote = quote.try_into()?; + self.inner + .add_melt_quote(cdk_quote) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_melt_quote(&self, quote_id: String) -> Result, FfiError> { + let result = self + .inner + .get_melt_quote("e_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(|q| q.into())) + } + + async fn get_melt_quotes(&self) -> Result, FfiError> { + let result = self + .inner + .get_melt_quotes() + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.into_iter().map(|q| q.into()).collect()) + } + + async fn remove_melt_quote(&self, quote_id: String) -> Result<(), FfiError> { + self.inner + .remove_melt_quote("e_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Keys Management + async fn add_keys(&self, keyset: KeySet) -> Result<(), FfiError> { + // Convert FFI KeySet to cdk::nuts::KeySet + let cdk_keyset: cdk::nuts::KeySet = keyset.try_into()?; + self.inner + .add_keys(cdk_keyset) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_keys(&self, id: Id) -> Result, FfiError> { + let cdk_id = id.into(); + let result = self + .inner + .get_keys(&cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(Into::into)) + } + + async fn remove_keys(&self, id: Id) -> Result<(), FfiError> { + let cdk_id = id.into(); + self.inner + .remove_keys(&cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Proof Management + async fn update_proofs( + &self, + added: Vec, + removed_ys: Vec, + ) -> Result<(), FfiError> { + // Convert FFI types to CDK types + let cdk_added: Result, FfiError> = added + .into_iter() + .map(|info| { + Ok::(cdk::types::ProofInfo { + proof: info.proof.inner.clone(), + y: info.y.try_into()?, + mint_url: info.mint_url.try_into()?, + state: info.state.into(), + spending_condition: info + .spending_condition + .map(|sc| sc.try_into()) + .transpose()?, + unit: info.unit.into(), + }) + }) + .collect(); + let cdk_added = cdk_added?; + + let cdk_removed_ys: Result, FfiError> = + removed_ys.into_iter().map(|pk| pk.try_into()).collect(); + let cdk_removed_ys = cdk_removed_ys?; + + self.inner + .update_proofs(cdk_added, cdk_removed_ys) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_proofs( + &self, + mint_url: Option, + unit: Option, + state: Option>, + spending_conditions: Option>, + ) -> Result, FfiError> { + let cdk_mint_url = mint_url.map(|u| u.try_into()).transpose()?; + let cdk_unit = unit.map(Into::into); + let cdk_state = state.map(|s| s.into_iter().map(Into::into).collect()); + let cdk_spending_conditions: Option> = + spending_conditions + .map(|sc| { + sc.into_iter() + .map(|c| c.try_into()) + .collect::, FfiError>>() + }) + .transpose()?; + + let result = self + .inner + .get_proofs(cdk_mint_url, cdk_unit, cdk_state, cdk_spending_conditions) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + + Ok(result.into_iter().map(Into::into).collect()) + } + + async fn update_proofs_state( + &self, + ys: Vec, + state: ProofState, + ) -> Result<(), FfiError> { + let cdk_ys: Result, FfiError> = + ys.into_iter().map(|pk| pk.try_into()).collect(); + let cdk_ys = cdk_ys?; + let cdk_state = state.into(); + + self.inner + .update_proofs_state(cdk_ys, cdk_state) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Keyset Counter Management + async fn increment_keyset_counter(&self, keyset_id: Id, count: u32) -> Result { + let cdk_id = keyset_id.into(); + self.inner + .increment_keyset_counter(&cdk_id, count) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Transaction Management + async fn add_transaction(&self, transaction: Transaction) -> Result<(), FfiError> { + // Convert FFI Transaction to CDK Transaction using TryFrom + let cdk_transaction: cdk::wallet::types::Transaction = transaction.try_into()?; + + self.inner + .add_transaction(cdk_transaction) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_transaction( + &self, + transaction_id: TransactionId, + ) -> Result, FfiError> { + let cdk_id = transaction_id.try_into()?; + let result = self + .inner + .get_transaction(cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(Into::into)) + } + + async fn list_transactions( + &self, + mint_url: Option, + direction: Option, + unit: Option, + ) -> Result, FfiError> { + let cdk_mint_url = mint_url.map(|u| u.try_into()).transpose()?; + let cdk_direction = direction.map(Into::into); + let cdk_unit = unit.map(Into::into); + + let result = self + .inner + .list_transactions(cdk_mint_url, cdk_direction, cdk_unit) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + + Ok(result.into_iter().map(Into::into).collect()) + } + + async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), FfiError> { + let cdk_id = transaction_id.try_into()?; + self.inner + .remove_transaction(cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } +} diff --git a/crates/cdk-integration-tests/tests/ffi_minting_integration.rs b/crates/cdk-integration-tests/tests/ffi_minting_integration.rs index 8d3b320439..71ade057f7 100644 --- a/crates/cdk-integration-tests/tests/ffi_minting_integration.rs +++ b/crates/cdk-integration-tests/tests/ffi_minting_integration.rs @@ -17,7 +17,7 @@ use std::str::FromStr; use std::time::Duration; use bip39::Mnemonic; -use cdk_ffi::database::WalletSqliteDatabase; +use cdk_ffi::sqlite::WalletSqliteDatabase; use cdk_ffi::types::{Amount, CurrencyUnit, QuoteState, SplitTarget}; use cdk_ffi::wallet::Wallet as FfiWallet; use cdk_ffi::WalletConfig; diff --git a/crates/cdk-postgres/src/lib.rs b/crates/cdk-postgres/src/lib.rs index 0d89c06c62..bea6ac1f0f 100644 --- a/crates/cdk-postgres/src/lib.rs +++ b/crates/cdk-postgres/src/lib.rs @@ -319,9 +319,15 @@ pub type MintPgDatabase = SQLMintDatabase; #[cfg(feature = "auth")] pub type MintPgAuthDatabase = SQLMintAuthDatabase; -/// Mint DB implementation with PostgresSQL +/// Wallet DB implementation with PostgreSQL pub type WalletPgDatabase = SQLWalletDatabase; +/// Convenience free functions (cannot add inherent impls for a foreign type). +/// These mirror the Mint patterns and call through to the generic constructors. +pub async fn new_wallet_pg_database(conn_str: &str) -> Result { + >::new(conn_str).await +} + #[cfg(test)] mod test { use cdk_common::mint_db_test; diff --git a/crates/cdk-sql-common/src/wallet/migrations/postgres/20250729111701_keyset_v2_u32.sql b/crates/cdk-sql-common/src/wallet/migrations/postgres/20250729111701_keyset_v2_u32.sql new file mode 100644 index 0000000000..2192f415e0 --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/migrations/postgres/20250729111701_keyset_v2_u32.sql @@ -0,0 +1,11 @@ +-- Add u32 representation column to key table with unique constraint +ALTER TABLE key ADD COLUMN keyset_u32 INTEGER; + +-- Add unique constraint on the new column +CREATE UNIQUE INDEX IF NOT EXISTS keyset_u32_unique ON key(keyset_u32); + +-- Add u32 representation column to keyset table with unique constraint +ALTER TABLE keyset ADD COLUMN keyset_u32 INTEGER; + +-- Add unique constraint on the new column +CREATE UNIQUE INDEX IF NOT EXISTS keyset_u32_unique_keyset ON keyset(keyset_u32); diff --git a/justfile b/justfile index 0a1f48288a..2ed4316f57 100644 --- a/justfile +++ b/justfile @@ -429,7 +429,7 @@ _ffi-lib-ext: # Build the FFI library ffi-build *ARGS="--release": - cargo build {{ARGS}} --package cdk-ffi + cargo build {{ARGS}} --package cdk-ffi --features postgres # Generate bindings for a specific language ffi-generate LANGUAGE *ARGS="--release": ffi-build @@ -460,7 +460,7 @@ ffi-generate LANGUAGE *ARGS="--release": ffi-build BUILD_TYPE="release" else BUILD_TYPE="debug" - cargo build --package cdk-ffi + cargo build --package cdk-ffi --features postgres fi LIB_EXT=$(just _ffi-lib-ext) From 166c96ff8822d9bb3c49fc056c880652c508f93f Mon Sep 17 00:00:00 2001 From: stefanbitcr Date: Tue, 30 Sep 2025 15:49:52 +0200 Subject: [PATCH 16/17] Fix htlc witness deserialization (#1138) * Add prior signatures if they exist --- crates/cashu/src/nuts/nut00/mod.rs | 30 +++++++++++++++++++++++++++--- crates/cashu/src/nuts/nut14/mod.rs | 10 ++++++++-- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/crates/cashu/src/nuts/nut00/mod.rs b/crates/cashu/src/nuts/nut00/mod.rs index bc30a8ec69..a3b3a42485 100644 --- a/crates/cashu/src/nuts/nut00/mod.rs +++ b/crates/cashu/src/nuts/nut00/mod.rs @@ -281,12 +281,12 @@ impl PartialOrd for BlindSignature { #[serde(untagged)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] pub enum Witness { - /// P2PK Witness - #[serde(with = "serde_p2pk_witness")] - P2PKWitness(P2PKWitness), /// HTLC Witness #[serde(with = "serde_htlc_witness")] HTLCWitness(HTLCWitness), + /// P2PK Witness + #[serde(with = "serde_p2pk_witness")] + P2PKWitness(P2PKWitness), } impl From for Witness { @@ -1042,4 +1042,28 @@ mod tests { assert_eq!(method, deserialized); } } + + #[test] + fn test_witness_serialization() { + let htlc_witness = HTLCWitness { + preimage: "preimage".to_string(), + signatures: Some(vec!["sig1".to_string()]), + }; + let witness = Witness::HTLCWitness(htlc_witness); + + let serialized = serde_json::to_string(&witness).unwrap(); + let deserialized: Witness = serde_json::from_str(&serialized).unwrap(); + + assert!(matches!(deserialized, Witness::HTLCWitness(_))); + + let p2pk_witness = P2PKWitness { + signatures: vec!["sig1".to_string(), "sig2".to_string()], + }; + let witness = Witness::P2PKWitness(p2pk_witness); + + let serialized = serde_json::to_string(&witness).unwrap(); + let deserialized: Witness = serde_json::from_str(&serialized).unwrap(); + + assert!(matches!(deserialized, Witness::P2PKWitness(_))); + } } diff --git a/crates/cashu/src/nuts/nut14/mod.rs b/crates/cashu/src/nuts/nut14/mod.rs index fd52d9023e..e647e7e08d 100644 --- a/crates/cashu/src/nuts/nut14/mod.rs +++ b/crates/cashu/src/nuts/nut14/mod.rs @@ -55,7 +55,7 @@ pub enum Error { #[derive(Default, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] pub struct HTLCWitness { - /// Primage + /// Preimage pub preimage: String, /// Signatures #[serde(skip_serializing_if = "Option::is_none")] @@ -139,9 +139,15 @@ impl Proof { /// Add Preimage #[inline] pub fn add_preimage(&mut self, preimage: String) { + let signatures = self + .witness + .as_ref() + .map(|w| w.signatures()) + .unwrap_or_default(); + self.witness = Some(Witness::HTLCWitness(HTLCWitness { preimage, - signatures: None, + signatures, })) } } From 9794c7523274ae476cf5f3a4aced7574d1b53697 Mon Sep 17 00:00:00 2001 From: Cesar Rodas Date: Tue, 30 Sep 2025 20:31:04 -0300 Subject: [PATCH 17/17] Proposed changes --- crates/cdk-sql-common/src/wallet/mod.rs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/cdk-sql-common/src/wallet/mod.rs b/crates/cdk-sql-common/src/wallet/mod.rs index ddf0df03a4..6437161364 100644 --- a/crates/cdk-sql-common/src/wallet/mod.rs +++ b/crates/cdk-sql-common/src/wallet/mod.rs @@ -840,13 +840,17 @@ ON CONFLICT(id) DO UPDATE SET &self, mint_url: Option, unit: Option, - state: Option>, + states: Option>, ) -> Result { - let start_time = std::time::Instant::now(); let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; let mut query_str = "SELECT COALESCE(SUM(amount), 0) as total FROM proof".to_string(); let mut where_clauses = Vec::new(); + let states = states + .unwrap_or_default() + .into_iter() + .map(|x| x.to_string()) + .collect::>(); if mint_url.is_some() { where_clauses.push("mint_url = :mint_url"); @@ -854,10 +858,8 @@ ON CONFLICT(id) DO UPDATE SET if unit.is_some() { where_clauses.push("unit = :unit"); } - if let Some(ref states) = state { - if !states.is_empty() { - where_clauses.push("state IN (:states)"); - } + if !states.is_empty() { + where_clauses.push("state IN (:states)"); } if !where_clauses.is_empty() { @@ -873,10 +875,9 @@ ON CONFLICT(id) DO UPDATE SET if let Some(ref unit) = unit { q = q.bind("unit", unit.to_string()); } - if let Some(ref states) = state { - if !states.is_empty() { - q = q.bind_vec("states", states.iter().map(|s| s.to_string()).collect()); - } + + if !states.is_empty() { + q = q.bind_vec("states", states); } let balance = q @@ -896,7 +897,6 @@ ON CONFLICT(id) DO UPDATE SET .transpose()? .unwrap_or(0); - let elapsed = start_time.elapsed(); Ok(balance) }