Bladeren bron

Merge remote-tracking branch 'origin/main' into optimize_get_balance

Cesar Rodas 1 maand geleden
bovenliggende
commit
1c407dbb9d
72 gewijzigde bestanden met toevoegingen van 3400 en 1042 verwijderingen
  1. 4 3
      .github/workflows/ci.yml
  2. 2 1
      .typos.toml
  3. 8 0
      README.md
  4. 110 43
      crates/cashu/src/amount.rs
  5. 4 0
      crates/cashu/src/nuts/auth/nut21.rs
  6. 33 5
      crates/cashu/src/nuts/nut00/mod.rs
  7. 242 2
      crates/cashu/src/nuts/nut00/token.rs
  8. 6 3
      crates/cashu/src/nuts/nut13.rs
  9. 8 2
      crates/cashu/src/nuts/nut14/mod.rs
  10. 4 0
      crates/cdk-axum/Cargo.toml
  11. 4 0
      crates/cdk-axum/src/cache/backend/mod.rs
  12. 96 0
      crates/cdk-axum/src/cache/backend/redis.rs
  13. 34 0
      crates/cdk-axum/src/cache/config.rs
  14. 18 1
      crates/cdk-axum/src/cache/mod.rs
  15. 15 2
      crates/cdk-axum/src/router_handlers.rs
  16. 76 67
      crates/cdk-cli/src/sub_commands/melt.rs
  17. 67 35
      crates/cdk-cli/src/sub_commands/send.rs
  18. 19 11
      crates/cdk-common/src/database/mint/mod.rs
  19. 197 1
      crates/cdk-common/src/database/mint/test/mint.rs
  20. 5 1
      crates/cdk-common/src/database/mint/test/mod.rs
  21. 172 11
      crates/cdk-fake-wallet/src/lib.rs
  22. 6 0
      crates/cdk-ffi/Cargo.toml
  23. 46 425
      crates/cdk-ffi/src/database.rs
  24. 3 0
      crates/cdk-ffi/src/lib.rs
  25. 1 0
      crates/cdk-ffi/src/multi_mint_wallet.rs
  26. 418 0
      crates/cdk-ffi/src/postgres.rs
  27. 418 0
      crates/cdk-ffi/src/sqlite.rs
  28. 158 0
      crates/cdk-ffi/src/token.rs
  29. 1 92
      crates/cdk-ffi/src/types.rs
  30. 8 4
      crates/cdk-ffi/src/wallet.rs
  31. 2 0
      crates/cdk-ffi/uniffi.toml
  32. 1 0
      crates/cdk-integration-tests/src/bin/start_fake_auth_mint.rs
  33. 8 1
      crates/cdk-integration-tests/tests/bolt12.rs
  34. 122 30
      crates/cdk-integration-tests/tests/fake_wallet.rs
  35. 2 2
      crates/cdk-integration-tests/tests/ffi_minting_integration.rs
  36. 8 2
      crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs
  37. 117 23
      crates/cdk-integration-tests/tests/integration_tests_pure.rs
  38. 8 2
      crates/cdk-integration-tests/tests/regtest.rs
  39. 3 13
      crates/cdk-lnbits/src/lib.rs
  40. 1 0
      crates/cdk-mintd/Cargo.toml
  41. 4 1
      crates/cdk-mintd/example.config.toml
  42. 3 0
      crates/cdk-mintd/src/config.rs
  43. 10 0
      crates/cdk-mintd/src/env_vars/auth.rs
  44. 6 0
      crates/cdk-mintd/src/lib.rs
  45. 12 21
      crates/cdk-payment-processor/src/proto/client.rs
  46. 9 4
      crates/cdk-postgres/src/lib.rs
  47. 3 0
      crates/cdk-signatory/src/proto/convert.rs
  48. 1 0
      crates/cdk-signatory/src/proto/signatory.proto
  49. 4 1
      crates/cdk-signatory/src/signatory.rs
  50. 23 0
      crates/cdk-sql-common/src/mint/migrations/postgres/20250924215800_migrate_blinded_messages_to_blind_signatures.sql
  51. 40 0
      crates/cdk-sql-common/src/mint/migrations/sqlite/20250924215800_migrate_blinded_messages_to_blind_signatures.sql
  52. 199 67
      crates/cdk-sql-common/src/mint/mod.rs
  53. 11 0
      crates/cdk-sql-common/src/wallet/migrations/postgres/20250729111701_keyset_v2_u32.sql
  54. 152 0
      crates/cdk/src/mint/blinded_message_writer.rs
  55. 0 33
      crates/cdk/src/mint/issue/mod.rs
  56. 62 27
      crates/cdk/src/mint/melt.rs
  57. 4 3
      crates/cdk/src/mint/mod.rs
  58. 48 4
      crates/cdk/src/mint/swap.rs
  59. 26 3
      crates/cdk/src/wallet/auth/auth_wallet.rs
  60. 7 1
      crates/cdk/src/wallet/issue/issue_bolt11.rs
  61. 6 1
      crates/cdk/src/wallet/issue/issue_bolt12.rs
  62. 21 6
      crates/cdk/src/wallet/keysets.rs
  63. 1 1
      crates/cdk/src/wallet/melt/melt_bolt11.rs
  64. 1 1
      crates/cdk/src/wallet/mint_connector/http_client.rs
  65. 23 17
      crates/cdk/src/wallet/mod.rs
  66. 1 10
      crates/cdk/src/wallet/multi_mint_wallet.rs
  67. 148 40
      crates/cdk/src/wallet/proofs.rs
  68. 12 7
      crates/cdk/src/wallet/send.rs
  69. 55 2
      crates/cdk/src/wallet/subscription/ws.rs
  70. 26 7
      crates/cdk/src/wallet/swap.rs
  71. 25 1
      docker-compose.yaml
  72. 2 2
      justfile

+ 4 - 3
.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,

+ 2 - 1
.typos.toml

@@ -6,5 +6,6 @@ extend-ignore-re = [
     "casshuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9",
     "autheticator",
     "Gam",
-    "flate2"
+    "flate2",
+    "lnbc[A-Za-z0-9-_]+"
 ]

+ 8 - 0
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.

+ 110 - 43
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<u64>,
+}
+
+impl From<(u64, Vec<u64>)> for FeeAndAmounts {
+    fn from(value: (u64, Vec<u64>)) -> 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<Id, FeeAndAmounts>;
+
 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<Self> {
-        let sats = self.0;
-        (0_u64..64)
+    pub fn split(&self, fee_and_amounts: &FeeAndAmounts) -> Vec<Self> {
+        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<Vec<Self>, Error> {
+    pub fn split_targeted(
+        &self,
+        target: &SplitTarget,
+        fee_and_amounts: &FeeAndAmounts,
+    ) -> Result<Vec<Self>, 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<Vec<Self>, Error> {
-        let without_fee_amounts = self.split();
-        let total_fee_ppk = fee_ppk
+    pub fn split_with_fee(&self, fee_and_amounts: &FeeAndAmounts) -> Result<Vec<Self>, 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<Amount, Error> {
         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<i64> {
@@ -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::<Vec<_>>()).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<Amount> = [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<Amount> = [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::<Vec<_>>()).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::<Vec<_>>()).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::<Vec<_>>()).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::<Vec<_>>()).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::<Vec<_>>()).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::<Vec<_>>()).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::<Vec<_>>()).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::<Vec<_>>()).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())
     }

+ 4 - 0
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

+ 33 - 5
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;
@@ -279,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<P2PKWitness> for Witness {
@@ -746,8 +748,9 @@ impl PreMintSecrets {
         keyset_id: Id,
         amount: Amount,
         amount_split_target: &SplitTarget,
+        fee_and_amounts: &FeeAndAmounts,
     ) -> Result<Self, Error> {
-        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<Self, Error> {
-        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());
 
@@ -1038,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(_)));
+    }
 }

+ 242 - 2
crates/cashu/src/nuts/nut00/token.rs

@@ -2,18 +2,20 @@
 //!
 //! <https://github.com/cashubtc/nuts/blob/main/00.md>
 
-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<HashSet<SpendingConditions>, 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<HashSet<PublicKey>, Error> {
+        let mut keys: HashSet<PublicKey> = 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<HashSet<PublicKey>, Error> {
+        let mut keys: HashSet<PublicKey> = 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<HashSet<sha256::Hash>, Error> {
+        let mut hashes: HashSet<sha256::Hash> = 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<BTreeSet<u64>, Error> {
+        let mut set: BTreeSet<u64> = 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);
+    }
 }

+ 6 - 3
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<Self, Error> {
         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::<Vec<_>>()).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() {

+ 8 - 2
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,
         }))
     }
 }

+ 4 - 0
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"] }

+ 4 - 0
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};

+ 96 - 0
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<Vec<u8>>,
+    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<String>,
+
+    /// 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<u8>) -> 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<Vec<u8>> {
+        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<u8>) {
+        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
+            });
+    }
+}

+ 34 - 0
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<Self> {
         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,
+                    });
+                }
             }
         }
 

+ 18 - 1
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<config::Config> 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)),
+                )
+            }
         }
     }
 }

+ 15 - 2
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<MintState>,
     ws: WebSocketUpgrade,
-) -> impl IntoResponse {
-    ws.on_upgrade(|ws| main_websocket(ws, state))
+) -> Result<impl IntoResponse, Response> {
+    #[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.

+ 76 - 67
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, &quote.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(

+ 67 - 35
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<u64>,
-    /// Specific mints allowed for transfers (can be specified multiple times)
-    #[arg(long, action = clap::ArgAction::Append)]
-    allowed_mints: Vec<String>,
+
     /// Specific mints to exclude from transfers (can be specified multiple times)
     #[arg(long, action = clap::ArgAction::Append)]
     excluded_mints: Vec<String>,
@@ -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::<u64>(&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<Vec<MintUrl>, _> = 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<Vec<MintUrl>, _> = 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?
     };

+ 19 - 11
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"
         )));
     }
 
@@ -134,15 +132,27 @@ pub trait QuotesTransaction<'a> {
     /// Mint Quotes Database Error
     type Err: Into<Error> + From<Error>;
 
-    /// 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,
@@ -172,8 +182,7 @@ pub trait QuotesTransaction<'a> {
         quote_id: &QuoteId,
         amount_issued: Amount,
     ) -> Result<Amount, Self::Err>;
-    /// 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,
@@ -198,8 +207,7 @@ pub trait QuotesTransaction<'a> {
         new_state: MeltQuoteState,
         payment_proof: Option<String>,
     ) -> 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,

+ 197 - 1
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: DB)
+where
+    DB: Database<Error> + KeysDatabase<Err = Error> + MintSignaturesDatabase<Err = Error>,
+{
+    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(&quote.id, inputs_amount, inputs_fee)
+        .await
+        .unwrap();
+    tx.add_blinded_messages(Some(&quote.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(&quote.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: DB)
+where
+    DB: Database<Error> + KeysDatabase<Err = Error> + MintSignaturesDatabase<Err = Error>,
+{
+    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(&quote2.id, inputs_amount, inputs_fee)
+        .await
+        .unwrap();
+    let result = tx
+        .add_blinded_messages(Some(&quote2.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: DB)
+where
+    DB: Database<Error> + KeysDatabase<Err = Error>,
+{
+    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(&quote.id, inputs_amount, inputs_fee)
+        .await
+        .unwrap();
+    assert!(tx
+        .add_blinded_messages(Some(&quote.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(&quote.id, inputs_amount, inputs_fee)
+        .await
+        .unwrap();
+    let result = tx
+        .add_blinded_messages(Some(&quote.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: DB)
+where
+    DB: Database<Error> + KeysDatabase<Err = Error>,
+{
+    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(&quote.id, inputs_amount, inputs_fee)
+        .await
+        .unwrap();
+    tx1.add_blinded_messages(Some(&quote.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(&quote.id)
+        .await
+        .unwrap()
+        .unwrap();
+    tx2.delete_melt_request(&quote.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(&quote.id)
+        .await
+        .unwrap();
+    assert!(retrieved.is_none());
+    tx3.commit().await.unwrap();
+}

+ 5 - 1
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),+ $(,)?) => {

+ 172 - 11
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<Mutex<Option<(MempoolPricesResponse, Instant)>>>,
+}
+
+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<f64, Error> {
+        // 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<f64, Error> {
+        let url = "https://mempool.space/api/v1/prices";
+        let response = reqwest::get(url)
+            .await
+            .map_err(|_| Error::UnknownInvoiceAmount)?
+            .json::<MempoolPricesResponse>()
+            .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<f64, Error> {
+        match currency {
+            CurrencyUnit::Usd => Ok(rates.usd),
+            CurrencyUnit::Eur => Ok(rates.eur),
+            _ => Err(Error::UnknownInvoiceAmount),
+        }
+    }
+
+    fn fallback_rate(currency: &CurrencyUnit) -> Result<f64, Error> {
+        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<Amount, Error> {
+    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<RwLock<HashMap<PaymentIdentifier, Vec<WaitPaymentResponse>>>>,
     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();

+ 6 - 0
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]]

+ 46 - 425
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
@@ -22,6 +23,14 @@ pub trait WalletDatabase: Send + Sync {
         mint_info: Option<MintInfo>,
     ) -> Result<(), FfiError>;
 
+    /// Get balance efficiently using SQL aggregation
+    async fn get_balance(
+        &self,
+        mint_url: Option<MintUrl>,
+        unit: Option<CurrencyUnit>,
+        state: Option<Vec<ProofState>>,
+    ) -> Result<u64, FfiError>;
+
     /// Remove Mint from storage
     async fn remove_mint(&self, mint_url: MintUrl) -> Result<(), FfiError>;
 
@@ -108,14 +117,6 @@ pub trait WalletDatabase: Send + Sync {
         spending_conditions: Option<Vec<SpendingConditions>>,
     ) -> Result<Vec<ProofInfo>, FfiError>;
 
-    /// Get balance efficiently using SQL aggregation
-    async fn get_balance(
-        &self,
-        mint_url: Option<MintUrl>,
-        unit: Option<CurrencyUnit>,
-        state: Option<Vec<ProofState>>,
-    ) -> Result<u64, FfiError>;
-
     /// Update proofs state in storage
     async fn update_proofs_state(
         &self,
@@ -171,6 +172,23 @@ impl std::fmt::Debug for WalletDatabaseBridge {
 impl CdkWalletDatabase for WalletDatabaseBridge {
     type Err = cdk::cdk_database::Error;
 
+    /// Get balance
+    async fn get_balance(
+        &self,
+        mint_url: Option<cdk::mint_url::MintUrl>,
+        unit: Option<cdk::nuts::CurrencyUnit>,
+        state: Option<Vec<cdk::nuts::State>>,
+    ) -> Result<u64, Self::Err> {
+        let ffi_mint_url = mint_url.map(Into::into);
+        let ffi_unit = unit.map(Into::into);
+        let ffi_state = state.map(|s| s.into_iter().map(Into::into).collect());
+
+        self.ffi_db
+            .get_balance(ffi_mint_url, ffi_unit, ffi_state)
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
+    }
+
     // Mint Management
     async fn add_mint(
         &self,
@@ -179,7 +197,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
@@ -473,22 +490,6 @@ impl CdkWalletDatabase for WalletDatabaseBridge {
         cdk_result
     }
 
-    async fn get_balance(
-        &self,
-        mint_url: Option<cdk::mint_url::MintUrl>,
-        unit: Option<cdk::nuts::CurrencyUnit>,
-        state: Option<Vec<cdk::nuts::State>>,
-    ) -> Result<u64, Self::Err> {
-        let ffi_mint_url = mint_url.map(Into::into);
-        let ffi_unit = unit.map(Into::into);
-        let ffi_state = state.map(|s| s.into_iter().map(Into::into).collect());
-
-        self.ffi_db
-            .get_balance(ffi_mint_url, ffi_unit, ffi_state)
-            .await
-            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
-    }
-
     async fn update_proofs_state(
         &self,
         ys: Vec<cdk::nuts::PublicKey>,
@@ -580,410 +581,30 @@ impl CdkWalletDatabase for WalletDatabaseBridge {
     }
 }
 
-/// FFI-compatible WalletSqliteDatabase implementation that implements the WalletDatabase trait
-#[derive(uniffi::Object)]
-pub struct WalletSqliteDatabase {
-    inner: Arc<CdkWalletSqliteDatabase>,
-}
-
-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<Arc<Self>, 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<Arc<dyn WalletDatabase>, FfiError> {
+    match backend {
+        WalletDbBackend::Sqlite { path } => {
+            let sqlite = WalletSqliteDatabase::new(path)?;
+            Ok(sqlite as Arc<dyn WalletDatabase>)
         }
-        .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<Arc<Self>, 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<dyn WalletDatabase>)
         }
-        .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<MintInfo>,
-    ) -> 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<Option<MintInfo>, 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<HashMap<MintUrl, Option<MintInfo>>, 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<KeySetInfo>,
-    ) -> Result<(), FfiError> {
-        let cdk_mint_url = mint_url.try_into()?;
-        let cdk_keysets: Vec<cdk::nuts::KeySetInfo> = 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<Option<Vec<KeySetInfo>>, 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<Option<KeySetInfo>, 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<Option<MintQuote>, FfiError> {
-        let result = self
-            .inner
-            .get_mint_quote(&quote_id)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Ok(result.map(|q| q.into()))
-    }
-
-    async fn get_mint_quotes(&self) -> Result<Vec<MintQuote>, 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(&quote_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<Option<MeltQuote>, FfiError> {
-        let result = self
-            .inner
-            .get_melt_quote(&quote_id)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Ok(result.map(|q| q.into()))
-    }
-
-    async fn get_melt_quotes(&self) -> Result<Vec<MeltQuote>, 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(&quote_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<Option<Keys>, 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<ProofInfo>,
-        removed_ys: Vec<PublicKey>,
-    ) -> Result<(), FfiError> {
-        // Convert FFI types to CDK types
-        let cdk_added: Result<Vec<cdk::types::ProofInfo>, FfiError> = added
-            .into_iter()
-            .map(|info| {
-                Ok::<cdk::types::ProofInfo, FfiError>(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<Vec<cdk::nuts::PublicKey>, 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<MintUrl>,
-        unit: Option<CurrencyUnit>,
-        state: Option<Vec<ProofState>>,
-        spending_conditions: Option<Vec<SpendingConditions>>,
-    ) -> Result<Vec<ProofInfo>, 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<Vec<cdk::nuts::SpendingConditions>> =
-            spending_conditions
-                .map(|sc| {
-                    sc.into_iter()
-                        .map(|c| c.try_into())
-                        .collect::<Result<Vec<_>, 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 get_balance(
-        &self,
-        mint_url: Option<MintUrl>,
-        unit: Option<CurrencyUnit>,
-        state: Option<Vec<ProofState>>,
-    ) -> Result<u64, 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());
-
-        self.inner
-            .get_balance(cdk_mint_url, cdk_unit, cdk_state)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
-    }
-
-    async fn update_proofs_state(
-        &self,
-        ys: Vec<PublicKey>,
-        state: ProofState,
-    ) -> Result<(), FfiError> {
-        let cdk_ys: Result<Vec<cdk::nuts::PublicKey>, 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<u32, FfiError> {
-        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<Option<Transaction>, 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<MintUrl>,
-        direction: Option<TransactionDirection>,
-        unit: Option<CurrencyUnit>,
-    ) -> Result<Vec<Transaction>, 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() })
     }
 }
 

+ 3 - 0
crates/cdk-ffi/src/lib.rs

@@ -7,6 +7,9 @@
 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;
 

+ 1 - 0
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

+ 418 - 0
crates/cdk-ffi/src/postgres.rs

@@ -0,0 +1,418 @@
+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<CdkWalletPgDatabase>,
+}
+
+// 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<tokio::runtime::Runtime> =
+    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<MintInfo>,
+    ) -> 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 get_balance(
+        &self,
+        mint_url: Option<MintUrl>,
+        unit: Option<CurrencyUnit>,
+        state: Option<Vec<ProofState>>,
+    ) -> Result<u64, 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());
+
+        self.inner
+            .get_balance(cdk_mint_url, cdk_unit, cdk_state)
+            .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<Option<MintInfo>, 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<HashMap<MintUrl, Option<MintInfo>>, 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<KeySetInfo>,
+    ) -> Result<(), FfiError> {
+        let cdk_mint_url = mint_url.try_into()?;
+        let cdk_keysets: Vec<cdk::nuts::KeySetInfo> = 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<Option<Vec<KeySetInfo>>, 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<Option<KeySetInfo>, 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<Option<MintQuote>, FfiError> {
+        let result = self
+            .inner
+            .get_mint_quote(&quote_id)
+            .await
+            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
+        Ok(result.map(|q| q.into()))
+    }
+
+    async fn get_mint_quotes(&self) -> Result<Vec<MintQuote>, 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(&quote_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<Option<MeltQuote>, FfiError> {
+        let result = self
+            .inner
+            .get_melt_quote(&quote_id)
+            .await
+            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
+        Ok(result.map(|q| q.into()))
+    }
+
+    async fn get_melt_quotes(&self) -> Result<Vec<MeltQuote>, 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(&quote_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<Option<Keys>, 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<ProofInfo>,
+        removed_ys: Vec<PublicKey>,
+    ) -> Result<(), FfiError> {
+        // Convert FFI types to CDK types
+        let cdk_added: Result<Vec<cdk::types::ProofInfo>, FfiError> = added
+            .into_iter()
+            .map(|info| {
+                Ok::<cdk::types::ProofInfo, FfiError>(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<Vec<cdk::nuts::PublicKey>, 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<MintUrl>,
+        unit: Option<CurrencyUnit>,
+        state: Option<Vec<ProofState>>,
+        spending_conditions: Option<Vec<SpendingConditions>>,
+    ) -> Result<Vec<ProofInfo>, 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<Vec<cdk::nuts::SpendingConditions>> =
+            spending_conditions
+                .map(|sc| {
+                    sc.into_iter()
+                        .map(|c| c.try_into())
+                        .collect::<Result<Vec<_>, 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<PublicKey>,
+        state: ProofState,
+    ) -> Result<(), FfiError> {
+        let cdk_ys: Result<Vec<cdk::nuts::PublicKey>, 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<u32, FfiError> {
+        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<Option<Transaction>, 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<MintUrl>,
+        direction: Option<TransactionDirection>,
+        unit: Option<CurrencyUnit>,
+    ) -> Result<Vec<Transaction>, 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<Arc<Self>, 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<dyn WalletDatabase> {
+        // Safety: UniFFI objects are reference counted and Send+Sync via Arc
+        let obj: Arc<dyn WalletDatabase> = Arc::new(WalletPostgresDatabase {
+            inner: self.inner.clone(),
+        });
+        obj
+    }
+}

+ 418 - 0
crates/cdk-ffi/src/sqlite.rs

@@ -0,0 +1,418 @@
+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<CdkWalletSqliteDatabase>,
+}
+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<Arc<Self>, 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<Arc<Self>, 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<MintInfo>,
+    ) -> 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 get_balance(
+        &self,
+        mint_url: Option<MintUrl>,
+        unit: Option<CurrencyUnit>,
+        state: Option<Vec<ProofState>>,
+    ) -> Result<u64, 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());
+
+        self.inner
+            .get_balance(cdk_mint_url, cdk_unit, cdk_state)
+            .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<Option<MintInfo>, 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<HashMap<MintUrl, Option<MintInfo>>, 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<KeySetInfo>,
+    ) -> Result<(), FfiError> {
+        let cdk_mint_url = mint_url.try_into()?;
+        let cdk_keysets: Vec<cdk::nuts::KeySetInfo> = 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<Option<Vec<KeySetInfo>>, 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<Option<KeySetInfo>, 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<Option<MintQuote>, FfiError> {
+        let result = self
+            .inner
+            .get_mint_quote(&quote_id)
+            .await
+            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
+        Ok(result.map(|q| q.into()))
+    }
+
+    async fn get_mint_quotes(&self) -> Result<Vec<MintQuote>, 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(&quote_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<Option<MeltQuote>, FfiError> {
+        let result = self
+            .inner
+            .get_melt_quote(&quote_id)
+            .await
+            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
+        Ok(result.map(|q| q.into()))
+    }
+
+    async fn get_melt_quotes(&self) -> Result<Vec<MeltQuote>, 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(&quote_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<Option<Keys>, 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<ProofInfo>,
+        removed_ys: Vec<PublicKey>,
+    ) -> Result<(), FfiError> {
+        // Convert FFI types to CDK types
+        let cdk_added: Result<Vec<cdk::types::ProofInfo>, FfiError> = added
+            .into_iter()
+            .map(|info| {
+                Ok::<cdk::types::ProofInfo, FfiError>(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<Vec<cdk::nuts::PublicKey>, 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<MintUrl>,
+        unit: Option<CurrencyUnit>,
+        state: Option<Vec<ProofState>>,
+        spending_conditions: Option<Vec<SpendingConditions>>,
+    ) -> Result<Vec<ProofInfo>, 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<Vec<cdk::nuts::SpendingConditions>> =
+            spending_conditions
+                .map(|sc| {
+                    sc.into_iter()
+                        .map(|c| c.try_into())
+                        .collect::<Result<Vec<_>, 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<PublicKey>,
+        state: ProofState,
+    ) -> Result<(), FfiError> {
+        let cdk_ys: Result<Vec<cdk::nuts::PublicKey>, 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<u32, FfiError> {
+        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<Option<Transaction>, 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<MintUrl>,
+        direction: Option<TransactionDirection>,
+        unit: Option<CurrencyUnit>,
+    ) -> Result<Vec<Transaction>, 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() })
+    }
+}

+ 158 - 0
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<Self, Self::Err> {
+        let token = cdk::nuts::Token::from_str(s)
+            .map_err(|e| FfiError::InvalidToken { msg: e.to_string() })?;
+        Ok(Token { inner: token })
+    }
+}
+
+impl From<cdk::nuts::Token> for Token {
+    fn from(token: cdk::nuts::Token) -> Self {
+        Self { inner: token }
+    }
+}
+
+impl From<Token> 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<Token, FfiError> {
+        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<Amount, FfiError> {
+        Ok(self.inner.value()?.into())
+    }
+
+    /// Get the memo from the token
+    pub fn memo(&self) -> Option<String> {
+        self.inner.memo().clone()
+    }
+
+    /// Get the currency unit
+    pub fn unit(&self) -> Option<CurrencyUnit> {
+        self.inner.unit().map(Into::into)
+    }
+
+    /// Get the mint URL
+    pub fn mint_url(&self) -> Result<MintUrl, FfiError> {
+        Ok(self.inner.mint_url()?.into())
+    }
+
+    /// Get proofs from the token (simplified - no keyset filtering for now)
+    pub fn proofs_simple(&self) -> Result<Proofs, FfiError> {
+        // 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<Vec<u8>, 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<Token, FfiError> {
+        encoded_token.parse()
+    }
+
+    /// Return unique spending conditions across all proofs in this token
+    pub fn spending_conditions(&self) -> Vec<crate::types::SpendingConditions> {
+        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<String> {
+        let set = self
+            .inner
+            .p2pk_pubkeys()
+            .map(|keys| {
+                keys.into_iter()
+                    .map(|k| k.to_string())
+                    .collect::<BTreeSet<_>>()
+            })
+            .unwrap_or_default();
+        set.into_iter().collect()
+    }
+
+    /// Return all refund pubkeys from P2PK spending conditions
+    pub fn p2pk_refund_pubkeys(&self) -> Vec<String> {
+        let set = self
+            .inner
+            .p2pk_refund_pubkeys()
+            .map(|keys| {
+                keys.into_iter()
+                    .map(|k| k.to_string())
+                    .collect::<BTreeSet<_>>()
+            })
+            .unwrap_or_default();
+        set.into_iter().collect()
+    }
+
+    /// Return all HTLC hashes from spending conditions
+    pub fn htlc_hashes(&self) -> Vec<String> {
+        let set = self
+            .inner
+            .htlc_hashes()
+            .map(|hashes| {
+                hashes
+                    .into_iter()
+                    .map(|h| h.to_string())
+                    .collect::<BTreeSet<_>>()
+            })
+            .unwrap_or_default();
+        set.into_iter().collect()
+    }
+
+    /// Return all locktimes from spending conditions (sorted ascending)
+    pub fn locktimes(&self) -> Vec<u64> {
+        self.inner
+            .locktimes()
+            .map(|s| s.into_iter().collect())
+            .unwrap_or_default()
+    }
+}

+ 1 - 92
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<ProofState> 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<Self, Self::Err> {
-        let token = cdk::nuts::Token::from_str(s)
-            .map_err(|e| FfiError::InvalidToken { msg: e.to_string() })?;
-        Ok(Token { inner: token })
-    }
-}
-
-impl From<cdk::nuts::Token> for Token {
-    fn from(token: cdk::nuts::Token) -> Self {
-        Self { inner: token }
-    }
-}
-
-impl From<Token> 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<Token, FfiError> {
-        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<Amount, FfiError> {
-        Ok(self.inner.value()?.into())
-    }
-
-    /// Get the memo from the token
-    pub fn memo(&self) -> Option<String> {
-        self.inner.memo().clone()
-    }
-
-    /// Get the currency unit
-    pub fn unit(&self) -> Option<CurrencyUnit> {
-        self.inner.unit().map(Into::into)
-    }
-
-    /// Get the mint URL
-    pub fn mint_url(&self) -> Result<MintUrl, FfiError> {
-        Ok(self.inner.mint_url()?.into())
-    }
-
-    /// Get proofs from the token (simplified - no keyset filtering for now)
-    pub fn proofs_simple(&self) -> Result<Proofs, FfiError> {
-        // 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<Vec<u8>, 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<Token, FfiError> {
-        encoded_token.parse()
-    }
-}
-
 /// FFI-compatible SendMemo
 #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
 pub struct SendMemo {

+ 8 - 4
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
@@ -372,8 +373,11 @@ impl Wallet {
     pub async fn get_keyset_fees_by_id(&self, keyset_id: String) -> Result<u64, FfiError> {
         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 +401,8 @@ impl Wallet {
     ) -> Result<Amount, FfiError> {
         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))
     }
 }

+ 2 - 0
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

+ 1 - 0
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

+ 8 - 1
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::<Vec<_>>())).into();
+
+    let pre_mint = PreMintSecrets::random(
+        active_keyset_id,
+        500.into(),
+        &SplitTarget::None,
+        &fee_and_amounts,
+    )?;
 
     let quote_info = wallet
         .localstore

+ 122 - 30
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::<Vec<_>>())).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::<Vec<_>>())).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::<Vec<_>>())).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::<Vec<_>>())).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::<Vec<_>>())).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::<Vec<_>>())).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::<Vec<_>>())).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::<Vec<_>>())).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::<Vec<_>>())).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::<Vec<_>>())).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::<Vec<_>>())).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::<Vec<_>>())).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::<Vec<_>>())).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();
 

+ 2 - 2
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;
@@ -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));

+ 8 - 2
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::<Vec<_>>())).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);
 

+ 117 - 23
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::<Vec<_>>())).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::<Vec<_>>())).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::<Vec<_>>())).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::<Vec<_>>())).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::<Vec<_>>())).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::<Vec<_>>())).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::<Vec<_>>())).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

+ 8 - 2
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::<Vec<_>>())).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,

+ 3 - 13
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<PaymentQuoteResponse, Self::Err> {
-        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<CreateIncomingPaymentResponse, Self::Err> {
-        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();

+ 1 - 0
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"]
 

+ 4 - 1
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.

+ 3 - 0
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 {

+ 10 - 0
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
     }
 }

+ 6 - 0
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,

+ 12 - 21
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()

+ 9 - 4
crates/cdk-postgres/src/lib.rs

@@ -319,9 +319,15 @@ pub type MintPgDatabase = SQLMintDatabase<PgConnectionPool>;
 #[cfg(feature = "auth")]
 pub type MintPgAuthDatabase = SQLMintAuthDatabase<PgConnectionPool>;
 
-/// Mint DB implementation with PostgresSQL
+/// Wallet DB implementation with PostgreSQL
 pub type WalletPgDatabase = SQLWalletDatabase<PgConnectionPool>;
 
+/// 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<WalletPgDatabase, Error> {
+    <SQLWalletDatabase<PgConnectionPool>>::new(conn_str).await
+}
+
 #[cfg(test)]
 mod test {
     use cdk_common::mint_db_test;
@@ -335,10 +341,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);

+ 3 - 0
crates/cdk-signatory/src/proto/convert.rs

@@ -60,6 +60,7 @@ impl TryInto<crate::signatory::SignatoryKeySet> for KeySet {
                     .map(|(amount, pk)| PublicKey::from_slice(&pk).map(|pk| (amount.into(), pk)))
                     .collect::<Result<BTreeMap<Amount, _>, _>>()?,
             ),
+            amounts: self.amounts,
             final_expiry: self.final_expiry,
         })
     }
@@ -80,6 +81,7 @@ impl From<crate::signatory::SignatoryKeySet> for KeySet {
                     .collect(),
             }),
             final_expiry: keyset.final_expiry,
+            amounts: keyset.amounts,
             version: Default::default(),
         }
     }
@@ -361,6 +363,7 @@ impl From<cdk_common::KeySetInfo> for KeySet {
             input_fee_ppk: value.input_fee_ppk,
             keys: Default::default(),
             final_expiry: value.final_expiry,
+            amounts: vec![],
             version: Default::default(),
         }
     }

+ 1 - 0
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 {

+ 4 - 1
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<u64>,
     /// 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<SignatoryKeySet> 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,
         }

+ 23 - 0
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;

+ 40 - 0
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;

+ 199 - 67
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(())
     }
 
@@ -878,14 +944,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 +1078,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<Option<MintQuote>, Self::Err> {
         let payments = get_mint_quote_payments(&self.inner, quote_id).await?;
         let issuance = get_mint_quote_issuance(&self.inner, quote_id).await?;
@@ -1582,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::<Result<HashMap<_, _>, 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::<Vec<_>>()
+        );
+
+        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(())
@@ -1629,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)
@@ -1682,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())
@@ -1728,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())
@@ -1756,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())

+ 11 - 0
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);

+ 152 - 0
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<dyn MintTransaction<'a, database::Error> + 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<DynMintDatabase>,
+    added_blinded_secrets: Option<HashSet<PublicKey>>,
+}
+
+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<QuoteId>,
+        blinded_messages: &[BlindedMessage],
+    ) -> Result<Vec<PublicKey>, 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<PublicKey> = 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<PublicKey> =
+            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<PublicKey>,
+) -> 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<PublicKey> =
+            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);
+                }
+            });
+        }
+    }
+}

+ 0 - 33
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

+ 62 - 27
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),
@@ -628,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?;
@@ -930,7 +943,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::<Vec<_>>()).into()
+                    });
+
+                let mut amounts = change_target.split(&fee_and_amounts);
 
                 if outputs.len().lt(&amounts.len()) {
                     tracing::debug!(
@@ -972,6 +1002,9 @@ impl Mint {
                 change = Some(change_sigs);
 
                 proof_writer.commit();
+
+                tx.delete_melt_request(&quote.id).await?;
+
                 tx.commit().await?;
             } else {
                 tracing::info!(
@@ -981,11 +1014,13 @@ impl Mint {
                     total_spent
                 );
                 proof_writer.commit();
+                tx.delete_melt_request(&quote.id).await?;
                 tx.commit().await?;
             }
         } else {
             tracing::debug!("No change required for melt {}", quote.id);
             proof_writer.commit();
+            tx.delete_melt_request(&quote.id).await?;
             tx.commit().await?;
         }
 

+ 4 - 3
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;
@@ -887,9 +888,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);

+ 48 - 4
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);

+ 26 - 3
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::<HashMap<_, _>>();
 
-        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::<Vec<_>>(),
+        )
+            .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(),

+ 7 - 1
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,
                 )?
             }
         };

+ 6 - 1
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,
                 )?
             }
         };

+ 21 - 6
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<HashMap<Id, u64>, Error> {
+    pub async fn get_keyset_fees_and_amounts(&self) -> Result<KeysetFeeAndAmounts, Error> {
         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::<Vec<_>>(),
+                )
+                    .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<u64, Error> {
-        self.get_keyset_fees()
+    pub async fn get_keyset_fees_and_amounts_by_id(
+        &self,
+        keyset_id: Id,
+    ) -> Result<FeeAndAmounts, Error> {
+        self.get_keyset_fees_and_amounts()
             .await?
             .get(&keyset_id)
             .cloned()

+ 1 - 1
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,

+ 1 - 1
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,

+ 23 - 17
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<Vec<Amount>, Error> {
+    pub async fn amounts_needed_for_state_target(
+        &self,
+        fee_and_amounts: &FeeAndAmounts,
+    ) -> Result<Vec<Amount>, Error> {
         let unspent_proofs = self.get_unspent_proofs().await?;
 
-        let amounts_count: HashMap<usize, usize> =
+        let amounts_count: HashMap<u64, u64> =
             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<usize> = (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<SplitTarget, Error> {
-        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();
 

+ 1 - 10
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?
                 }
             }
         }

+ 148 - 40
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<Id>,
-        keyset_fees: &HashMap<Id, u64>,
+        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<Id>,
-        keyset_fees: &HashMap<Id, u64>,
+        fees_and_keyset_amounts: &KeysetFeeAndAmounts,
         include_fees: bool,
     ) -> Result<Proofs, Error> {
         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<Proof> = 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<Id>,
-        keyset_fees: &HashMap<Id, u64>,
+        fees_and_keyset_amounts: &KeysetFeeAndAmounts,
     ) -> Result<Proofs, Error> {
         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::<Vec<_>>()).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::<Vec<_>>()).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::<Vec<_>>()).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::<Vec<_>>()).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::<Vec<_>>()).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::<Vec<_>>()).into(),
+        );
         let proofs = (0..1024).map(|_| proof(1)).collect::<Vec<_>>();
-        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::<Vec<_>>()).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::<Vec<_>>()).into(),
+        );
         let proofs = (0..32)
             .flat_map(|i| (0..5).map(|_| proof(1 << i)).collect::<Vec<_>>())
             .collect::<Vec<_>>();
         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());
     }

+ 12 - 7
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<PreparedSend, Error> {
         // 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

+ 55 - 2
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::<SubId, mpsc::Sender<_>>::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;

+ 26 - 7
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();

+ 25 - 1
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:

+ 2 - 2
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)