Procházet zdrojové kódy

Merge branch 'main' into go-ffi

# Conflicts:
#	crates/cdk-ffi/uniffi.toml
asmo před 5 měsíci
rodič
revize
9b5c76716b
100 změnil soubory, kde provedl 8824 přidání a 4256 odebrání
  1. 27 0
      .github/workflows/autoclose.yml
  2. 32 11
      .github/workflows/ci.yml
  3. 2 1
      .typos.toml
  4. 7 1
      Cargo.toml
  5. 10 2
      README.md
  6. 2 2
      crates/cashu/Cargo.toml
  7. 110 43
      crates/cashu/src/amount.rs
  8. 0 1
      crates/cashu/src/lib.rs
  9. 4 0
      crates/cashu/src/nuts/auth/nut21.rs
  10. 33 5
      crates/cashu/src/nuts/nut00/mod.rs
  11. 242 2
      crates/cashu/src/nuts/nut00/token.rs
  12. 35 0
      crates/cashu/src/nuts/nut06.rs
  13. 6 3
      crates/cashu/src/nuts/nut13.rs
  14. 8 2
      crates/cashu/src/nuts/nut14/mod.rs
  15. 21 0
      crates/cashu/src/nuts/nut15.rs
  16. 19 31
      crates/cashu/src/nuts/nut17/mod.rs
  17. 4 1
      crates/cashu/src/nuts/nut17/ws.rs
  18. 4 0
      crates/cdk-axum/Cargo.toml
  19. 4 0
      crates/cdk-axum/src/cache/backend/mod.rs
  20. 96 0
      crates/cdk-axum/src/cache/backend/redis.rs
  21. 34 0
      crates/cdk-axum/src/cache/config.rs
  22. 18 1
      crates/cdk-axum/src/cache/mod.rs
  23. 15 2
      crates/cdk-axum/src/router_handlers.rs
  24. 4 3
      crates/cdk-axum/src/ws/mod.rs
  25. 4 6
      crates/cdk-axum/src/ws/subscribe.rs
  26. 2 5
      crates/cdk-cli/Cargo.toml
  27. 343 74
      crates/cdk-cli/README.md
  28. 42 7
      crates/cdk-cli/src/main.rs
  29. 76 67
      crates/cdk-cli/src/sub_commands/melt.rs
  30. 67 35
      crates/cdk-cli/src/sub_commands/send.rs
  31. 11 0
      crates/cdk-common/Cargo.toml
  32. 29 0
      crates/cdk-common/benches/transaction_id_benchmark.rs
  33. 19 11
      crates/cdk-common/src/database/mint/mod.rs
  34. 197 1
      crates/cdk-common/src/database/mint/test/mint.rs
  35. 5 1
      crates/cdk-common/src/database/mint/test/mod.rs
  36. 7 0
      crates/cdk-common/src/database/wallet.rs
  37. 2 0
      crates/cdk-common/src/lib.rs
  38. 44 0
      crates/cdk-common/src/pub_sub/error.rs
  39. 0 161
      crates/cdk-common/src/pub_sub/index.rs
  40. 165 62
      crates/cdk-common/src/pub_sub/mod.rs
  41. 185 0
      crates/cdk-common/src/pub_sub/pubsub.rs
  42. 885 0
      crates/cdk-common/src/pub_sub/remote_consumer.rs
  43. 159 0
      crates/cdk-common/src/pub_sub/subscriber.rs
  44. 80 0
      crates/cdk-common/src/pub_sub/types.rs
  45. 91 74
      crates/cdk-common/src/subscription.rs
  46. 8 1
      crates/cdk-common/src/wallet.rs
  47. 3 1
      crates/cdk-common/src/ws.rs
  48. 172 11
      crates/cdk-fake-wallet/src/lib.rs
  49. 7 3
      crates/cdk-ffi/Cargo.toml
  50. 45 385
      crates/cdk-ffi/src/database.rs
  51. 3 0
      crates/cdk-ffi/src/lib.rs
  52. 41 0
      crates/cdk-ffi/src/multi_mint_wallet.rs
  53. 417 0
      crates/cdk-ffi/src/postgres.rs
  54. 418 0
      crates/cdk-ffi/src/sqlite.rs
  55. 158 0
      crates/cdk-ffi/src/token.rs
  56. 0 2974
      crates/cdk-ffi/src/types.rs
  57. 166 0
      crates/cdk-ffi/src/types/amount.rs
  58. 258 0
      crates/cdk-ffi/src/types/keys.rs
  59. 1012 0
      crates/cdk-ffi/src/types/mint.rs
  60. 24 0
      crates/cdk-ffi/src/types/mod.rs
  61. 509 0
      crates/cdk-ffi/src/types/proof.rs
  62. 430 0
      crates/cdk-ffi/src/types/quote.rs
  63. 175 0
      crates/cdk-ffi/src/types/subscription.rs
  64. 255 0
      crates/cdk-ffi/src/types/transaction.rs
  65. 471 0
      crates/cdk-ffi/src/types/wallet.rs
  66. 9 5
      crates/cdk-ffi/src/wallet.rs
  67. 2 0
      crates/cdk-ffi/uniffi.toml
  68. 1 0
      crates/cdk-integration-tests/src/bin/start_fake_auth_mint.rs
  69. 8 1
      crates/cdk-integration-tests/tests/bolt12.rs
  70. 122 30
      crates/cdk-integration-tests/tests/fake_wallet.rs
  71. 2 2
      crates/cdk-integration-tests/tests/ffi_minting_integration.rs
  72. 25 4
      crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs
  73. 127 37
      crates/cdk-integration-tests/tests/integration_tests_pure.rs
  74. 10 4
      crates/cdk-integration-tests/tests/regtest.rs
  75. 3 13
      crates/cdk-lnbits/src/lib.rs
  76. 1 0
      crates/cdk-mintd/Cargo.toml
  77. 1 1
      crates/cdk-mintd/README.md
  78. 5 3
      crates/cdk-mintd/example.config.toml
  79. 3 0
      crates/cdk-mintd/src/config.rs
  80. 10 0
      crates/cdk-mintd/src/env_vars/auth.rs
  81. 6 0
      crates/cdk-mintd/src/lib.rs
  82. 12 21
      crates/cdk-payment-processor/src/proto/client.rs
  83. 9 4
      crates/cdk-postgres/src/lib.rs
  84. 3 0
      crates/cdk-redb/src/error.rs
  85. 35 1
      crates/cdk-redb/src/wallet/mod.rs
  86. 10 8
      crates/cdk-signatory/src/proto/convert.rs
  87. 4 1
      crates/cdk-signatory/src/signatory.rs
  88. 23 0
      crates/cdk-sql-common/src/mint/migrations/postgres/20250924215800_migrate_blinded_messages_to_blind_signatures.sql
  89. 40 0
      crates/cdk-sql-common/src/mint/migrations/sqlite/20250924215800_migrate_blinded_messages_to_blind_signatures.sql
  90. 199 67
      crates/cdk-sql-common/src/mint/mod.rs
  91. 11 0
      crates/cdk-sql-common/src/wallet/migrations/postgres/20250729111701_keyset_v2_u32.sql
  92. 3 0
      crates/cdk-sql-common/src/wallet/migrations/postgres/20251005120000_add_payment_info_to_transactions.sql
  93. 3 0
      crates/cdk-sql-common/src/wallet/migrations/sqlite/20251005120000_add_payment_info_to_transactions.sql
  94. 87 9
      crates/cdk-sql-common/src/wallet/mod.rs
  95. 9 0
      crates/cdk-sqlite/src/common.rs
  96. 25 12
      crates/cdk/Cargo.toml
  97. 127 0
      crates/cdk/src/event.rs
  98. 13 8
      crates/cdk/src/lib.rs
  99. 152 0
      crates/cdk/src/mint/blinded_message_writer.rs
  100. 2 35
      crates/cdk/src/mint/issue/mod.rs

+ 27 - 0
.github/workflows/autoclose.yml

@@ -0,0 +1,27 @@
+name: Close inactive issues and PRs
+on:
+  schedule:
+    - cron: "30 1 * * *"
+
+jobs:
+  close-issues:
+    runs-on: ubuntu-latest
+    permissions:
+      issues: write
+      pull-requests: write
+    steps:
+      - uses: actions/stale@v10
+        with:
+          days-before-issue-stale: 60
+          days-before-issue-close: 21
+          stale-issue-label: "stale"
+          stale-issue-message: "This issue is stale because it has been open for 60 days with no activity."
+          close-issue-message: "This issue was closed because it has been inactive for 21 days since being marked as stale."
+          days-before-pr-stale: 60
+          days-before-pr-close: 21
+          stale-pr-label: "stale"
+          stale-pr-message: "This PR is stale because it has been open for 60 days with no activity."
+          close-pr-message: "This PR was closed because it has been inactive for 21 days since being marked as stale."
+          exempt-issue-labels: "keep-open"
+          exempt-pr-labels: "keep-open"
+          repo-token: ${{ secrets.GITHUB_TOKEN }}

+ 32 - 11
.github/workflows/ci.yml

@@ -43,6 +43,7 @@ jobs:
     timeout-minutes: 30
     needs: pre-commit-checks
     strategy:
+      fail-fast: true
       matrix:
         build-args:
           [
@@ -72,6 +73,7 @@ jobs:
     timeout-minutes: 30
     needs: pre-commit-checks
     strategy:
+      fail-fast: true
       matrix:
         build-args:
           [
@@ -103,7 +105,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 +129,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,
@@ -164,8 +167,9 @@ jobs:
     name: "Integration regtest tests"
     runs-on: ubuntu-latest
     timeout-minutes: 30
-    needs: [pre-commit-checks, clippy, pure-itest, fake-mint-itest]
+    needs: pre-commit-checks
     strategy:
+      fail-fast: true
       matrix:
         build-args:
           [
@@ -204,8 +208,9 @@ jobs:
     name: "Integration fake mint tests"
     runs-on: ubuntu-latest
     timeout-minutes: 30
-    needs: [pre-commit-checks, clippy]
+    needs: pre-commit-checks
     strategy:
+      fail-fast: true
       matrix:
         build-args:
           [
@@ -245,8 +250,9 @@ jobs:
     name: "Integration fake wallet tests"
     runs-on: ubuntu-latest
     timeout-minutes: 30
-    needs: [pre-commit-checks, clippy]
+    needs: pre-commit-checks
     strategy:
+      fail-fast: true
       matrix:
         database:
           [
@@ -287,8 +293,9 @@ jobs:
     name: "Payment processor tests"
     runs-on: ubuntu-latest
     timeout-minutes: 30
-    needs: [pre-commit-checks, clippy, pure-itest, fake-mint-itest, regtest-itest]
+    needs: pre-commit-checks
     strategy:
+      fail-fast: true
       matrix:
         ln:
           [
@@ -324,8 +331,9 @@ jobs:
     name: "MSRV build"
     runs-on: ubuntu-latest
     timeout-minutes: 30
-    needs: [pre-commit-checks, clippy]
+    needs: pre-commit-checks
     strategy:
+      fail-fast: true
       matrix:
         build-args:
           [
@@ -337,7 +345,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,
@@ -366,8 +374,9 @@ jobs:
     name: Check WASM
     runs-on: ubuntu-latest
     timeout-minutes: 30
-    needs: [pre-commit-checks, clippy]
+    needs: pre-commit-checks
     strategy:
+      fail-fast: true
       matrix:
         rust:
           - stable
@@ -398,8 +407,9 @@ jobs:
     name: Check WASM
     runs-on: ubuntu-latest
     timeout-minutes: 30
-    needs: [pre-commit-checks, clippy, msrv-build]
+    needs: pre-commit-checks
     strategy:
+      fail-fast: true
       matrix:
         rust:
           - msrv
@@ -429,8 +439,9 @@ jobs:
     name: "Integration fake mint auth tests"
     runs-on: ubuntu-latest
     timeout-minutes: 30
-    needs: [pre-commit-checks, clippy, pure-itest, fake-mint-itest]
+    needs: pre-commit-checks
     strategy:
+      fail-fast: true
       matrix:
         database:
           [
@@ -479,6 +490,16 @@ jobs:
     steps:
       - name: checkout
         uses: actions/checkout@v4
+      - name: Free Disk Space (Ubuntu)
+        uses: jlumbroso/free-disk-space@main
+        with:
+          tool-cache: false
+          android: true
+          dotnet: true
+          haskell: true
+          large-packages: true
+          docker-images: true
+          swap-storage: true
       - name: Install Nix
         uses: DeterminateSystems/nix-installer-action@v17
       - name: Nix Cache

+ 2 - 1
.typos.toml

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

+ 7 - 1
Cargo.toml

@@ -66,11 +66,12 @@ clap = { version = "4.5.31", features = ["derive"] }
 ciborium = { version = "0.2.2", default-features = false, features = ["std"] }
 cbor-diag = "0.1.12"
 config = { version = "0.15.11", features = ["toml"] }
+criterion = "0.6.0"
 futures = { version = "0.3.28", default-features = false, features = ["async-await"] }
 lightning-invoice = { version = "0.33.0", features = ["serde", "std"] }
 lightning = { version = "0.1.2", default-features = false, features = ["std"]}
 ldk-node = "0.6.2"
-serde = { version = "1", features = ["derive"] }
+serde = { version = "1", features = ["derive", "rc"] }
 serde_json = "1"
 thiserror = { version = "2" }
 tokio = { version = "1", default-features = false, features = ["rt", "macros", "test-util", "sync"] }
@@ -111,6 +112,11 @@ strum = "0.27.1"
 strum_macros = "0.27.1"
 rustls = { version = "0.23.27", default-features = false, features = ["ring"] }
 prometheus = { version = "0.13.4", features = ["process"], default-features = false }
+nostr-sdk = { version = "0.43.0", default-features = false, features = [
+    "nip04",
+    "nip44",
+    "nip59"
+]}
 
 
 

+ 10 - 2
README.md

@@ -1,7 +1,7 @@
-> **Warning**
+> [!Warning]
 > This project is in early development, it does however work with real sats! Always use amounts you don't mind losing.
 
-[![crates.io](https://img.shields.io/crates/v/cdk.svg)](https://crates.io/crates/cdk) [![Documentation](https://docs.rs/cdk/badge.svg)](https://docs.rs/cdk)
+[![crates.io](https://img.shields.io/crates/v/cdk.svg)](https://crates.io/crates/cdk) [![Documentation](https://docs.rs/cdk/badge.svg)](https://docs.rs/cdk) [![License](https://img.shields.io/github/license/cashubtc/cdk)](https://github.com/cashubtc/cdk/blob/main/LICENSE)
 
 # Cashu Development Kit
 
@@ -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.

+ 2 - 2
crates/cashu/Cargo.toml

@@ -13,13 +13,13 @@ readme = "README.md"
 [features]
 default = ["mint", "wallet", "auth"]
 swagger = ["dep:utoipa"]
-mint = ["dep:uuid"]
+mint = []
 wallet = []
 auth = ["dep:strum", "dep:strum_macros", "dep:regex"]
 bench = []
 
 [dependencies]
-uuid = { workspace = true, optional = true }
+uuid.workspace = true
 bitcoin.workspace = true
 cbor-diag.workspace = true
 ciborium.workspace = true

+ 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())
     }

+ 0 - 1
crates/cashu/src/lib.rs

@@ -16,7 +16,6 @@ pub use self::mint_url::MintUrl;
 pub use self::nuts::*;
 pub use self::util::SECP256K1;
 
-#[cfg(feature = "mint")]
 pub mod quote_id;
 
 #[doc(hidden)]

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

+ 35 - 0
crates/cashu/src/nuts/nut06.rs

@@ -313,6 +313,7 @@ pub struct Nuts {
     /// NUT15 Settings
     #[serde(default)]
     #[serde(rename = "15")]
+    #[serde(skip_serializing_if = "nut15::Settings::is_empty")]
     pub nut15: nut15::Settings,
     /// NUT17 Settings
     #[serde(default)]
@@ -676,4 +677,38 @@ mod tests {
 
         assert_eq!(info, mint_info);
     }
+
+    #[test]
+    fn test_nut15_not_serialized_when_empty() {
+        // Test with default (empty) NUT15
+        let mint_info = MintInfo {
+            name: Some("Test Mint".to_string()),
+            nuts: Nuts::default(),
+            ..Default::default()
+        };
+
+        let json = serde_json::to_string(&mint_info).unwrap();
+        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
+
+        // NUT15 should not be present in the nuts object when methods is empty
+        assert!(parsed["nuts"]["15"].is_null());
+
+        // Test with non-empty NUT15
+        let mint_info_with_nut15 = MintInfo {
+            name: Some("Test Mint".to_string()),
+            nuts: Nuts::default().nut15(vec![MppMethodSettings {
+                method: crate::PaymentMethod::Bolt11,
+                unit: crate::CurrencyUnit::Sat,
+            }]),
+            ..Default::default()
+        };
+
+        let json = serde_json::to_string(&mint_info_with_nut15).unwrap();
+        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
+
+        // NUT15 should be present when methods is not empty
+        assert!(!parsed["nuts"]["15"].is_null());
+        assert!(parsed["nuts"]["15"]["methods"].is_array());
+        assert_eq!(parsed["nuts"]["15"]["methods"].as_array().unwrap().len(), 1);
+    }
 }

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

+ 21 - 0
crates/cashu/src/nuts/nut15.rs

@@ -34,6 +34,13 @@ pub struct Settings {
     pub methods: Vec<MppMethodSettings>,
 }
 
+impl Settings {
+    /// Check if methods is empty
+    pub fn is_empty(&self) -> bool {
+        self.methods.is_empty()
+    }
+}
+
 // Custom deserialization to handle both array and object formats
 impl<'de> Deserialize<'de> for Settings {
     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
@@ -89,4 +96,18 @@ mod tests {
         let json = serde_json::to_string(&settings).unwrap();
         assert_eq!(json, r#"{"methods":[{"method":"bolt11","unit":"sat"}]}"#);
     }
+
+    #[test]
+    fn test_nut15_settings_empty() {
+        let settings = Settings { methods: vec![] };
+        assert!(settings.is_empty());
+
+        let settings_with_data = Settings {
+            methods: vec![MppMethodSettings {
+                method: PaymentMethod::Bolt11,
+                unit: CurrencyUnit::Sat,
+            }],
+        };
+        assert!(!settings_with_data.is_empty());
+    }
 }

+ 19 - 31
crates/cashu/src/nuts/nut17/mod.rs

@@ -2,13 +2,11 @@
 use serde::de::DeserializeOwned;
 use serde::{Deserialize, Serialize};
 
-#[cfg(feature = "mint")]
 use super::PublicKey;
 use crate::nuts::{
     CurrencyUnit, MeltQuoteBolt11Response, MintQuoteBolt11Response, PaymentMethod, ProofState,
 };
-#[cfg(feature = "mint")]
-use crate::quote_id::{QuoteId, QuoteIdError};
+use crate::quote_id::QuoteIdError;
 use crate::MintQuoteBolt12Response;
 
 pub mod ws;
@@ -109,7 +107,10 @@ pub enum WsCommand {
     ProofState,
 }
 
-impl<T> From<MintQuoteBolt12Response<T>> for NotificationPayload<T> {
+impl<T> From<MintQuoteBolt12Response<T>> for NotificationPayload<T>
+where
+    T: Clone,
+{
     fn from(mint_quote: MintQuoteBolt12Response<T>) -> NotificationPayload<T> {
         NotificationPayload::MintQuoteBolt12Response(mint_quote)
     }
@@ -119,7 +120,10 @@ impl<T> From<MintQuoteBolt12Response<T>> for NotificationPayload<T> {
 #[serde(bound = "T: Serialize + DeserializeOwned")]
 #[serde(untagged)]
 /// Subscription response
-pub enum NotificationPayload<T> {
+pub enum NotificationPayload<T>
+where
+    T: Clone,
+{
     /// Proof State
     ProofState(ProofState),
     /// Melt Quote Bolt11 Response
@@ -130,38 +134,23 @@ pub enum NotificationPayload<T> {
     MintQuoteBolt12Response(MintQuoteBolt12Response<T>),
 }
 
-impl<T> From<ProofState> for NotificationPayload<T> {
-    fn from(proof_state: ProofState) -> NotificationPayload<T> {
-        NotificationPayload::ProofState(proof_state)
-    }
-}
-
-impl<T> From<MeltQuoteBolt11Response<T>> for NotificationPayload<T> {
-    fn from(melt_quote: MeltQuoteBolt11Response<T>) -> NotificationPayload<T> {
-        NotificationPayload::MeltQuoteBolt11Response(melt_quote)
-    }
-}
-
-impl<T> From<MintQuoteBolt11Response<T>> for NotificationPayload<T> {
-    fn from(mint_quote: MintQuoteBolt11Response<T>) -> NotificationPayload<T> {
-        NotificationPayload::MintQuoteBolt11Response(mint_quote)
-    }
-}
-
-#[cfg(feature = "mint")]
-#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Hash, Serialize)]
+#[serde(bound = "T: Serialize + DeserializeOwned")]
 /// A parsed notification
-pub enum Notification {
+pub enum NotificationId<T>
+where
+    T: Clone,
+{
     /// ProofState id is a Pubkey
     ProofState(PublicKey),
     /// MeltQuote id is an QuoteId
-    MeltQuoteBolt11(QuoteId),
+    MeltQuoteBolt11(T),
     /// MintQuote id is an QuoteId
-    MintQuoteBolt11(QuoteId),
+    MintQuoteBolt11(T),
     /// MintQuote id is an QuoteId
-    MintQuoteBolt12(QuoteId),
+    MintQuoteBolt12(T),
     /// MintQuote id is an QuoteId
-    MeltQuoteBolt12(QuoteId),
+    MeltQuoteBolt12(T),
 }
 
 /// Kind
@@ -187,7 +176,6 @@ impl<I> AsRef<I> for Params<I> {
 /// Parsing error
 #[derive(thiserror::Error, Debug)]
 pub enum Error {
-    #[cfg(feature = "mint")]
     #[error("Uuid Error: {0}")]
     /// Uuid Error
     QuoteId(#[from] QuoteIdError),

+ 4 - 1
crates/cashu/src/nuts/nut17/ws.rs

@@ -36,7 +36,10 @@ pub struct WsUnsubscribeResponse<I> {
 /// subscription
 #[derive(Debug, Clone, Serialize, Deserialize)]
 #[serde(bound = "T: Serialize + DeserializeOwned, I: Serialize + DeserializeOwned")]
-pub struct NotificationInner<T, I> {
+pub struct NotificationInner<T, I>
+where
+    T: Clone,
+{
     /// The subscription ID
     #[serde(rename = "subId")]
     pub sub_id: I,

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

+ 4 - 3
crates/cdk-axum/src/ws/mod.rs

@@ -1,9 +1,10 @@
 use std::collections::HashMap;
+use std::sync::Arc;
 
 use axum::extract::ws::{CloseFrame, Message, WebSocket};
 use cdk::mint::QuoteId;
 use cdk::nuts::nut17::NotificationPayload;
-use cdk::pub_sub::SubId;
+use cdk::subscription::SubId;
 use cdk::ws::{
     notification_to_ws_message, NotificationInner, WsErrorBody, WsMessageOrResponse,
     WsMethodRequest, WsRequest,
@@ -36,8 +37,8 @@ pub use error::WsError;
 
 pub struct WsContext {
     state: MintState,
-    subscriptions: HashMap<SubId, tokio::task::JoinHandle<()>>,
-    publisher: mpsc::Sender<(SubId, NotificationPayload<QuoteId>)>,
+    subscriptions: HashMap<Arc<SubId>, tokio::task::JoinHandle<()>>,
+    publisher: mpsc::Sender<(Arc<SubId>, NotificationPayload<QuoteId>)>,
 }
 
 /// Main function for websocket connections

+ 4 - 6
crates/cdk-axum/src/ws/subscribe.rs

@@ -1,4 +1,4 @@
-use cdk::subscription::{IndexableParams, Params};
+use cdk::subscription::Params;
 use cdk::ws::{WsResponseResult, WsSubscribeResponse};
 
 use super::{WsContext, WsError};
@@ -15,22 +15,20 @@ pub(crate) async fn handle(
         return Err(WsError::InvalidParams);
     }
 
-    let params: IndexableParams = params.into();
-
     let mut subscription = context
         .state
         .mint
         .pubsub_manager()
-        .try_subscribe(params)
-        .await
+        .subscribe(params)
         .map_err(|_| WsError::ParseError)?;
 
     let publisher = context.publisher.clone();
+    let sub_id_for_sender = sub_id.clone();
     context.subscriptions.insert(
         sub_id.clone(),
         tokio::spawn(async move {
             while let Some(response) = subscription.recv().await {
-                let _ = publisher.send(response).await;
+                let _ = publisher.try_send((sub_id_for_sender.clone(), response.into_inner()));
             }
         }),
     );

+ 2 - 5
crates/cdk-cli/Cargo.toml

@@ -15,6 +15,7 @@ default = []
 sqlcipher = ["cdk-sqlite/sqlcipher"]
 # MSRV is not tracked with redb enabled
 redb = ["dep:cdk-redb"]
+tor = ["cdk/tor"]
 
 [dependencies]
 anyhow.workspace = true
@@ -30,11 +31,7 @@ tokio.workspace = true
 tracing.workspace = true
 tracing-subscriber.workspace = true
 home.workspace = true
-nostr-sdk = { version = "0.41.0", default-features = false, features = [
-    "nip04",
-    "nip44",
-    "nip59"
-]}
+nostr-sdk = { workspace = true }
 reqwest.workspace = true
 url.workspace = true
 serde_with.workspace = true

+ 343 - 74
crates/cdk-cli/README.md

@@ -11,11 +11,15 @@ A command-line Cashu wallet implementation built with the Cashu Development Kit
 
 ## Features
 
-- **Multiple Mint Support**: Connect to and manage multiple Cashu mints
+- **Multiple Mint Support**: Connect to and manage multiple Cashu mints simultaneously
 - **Token Operations**: Mint, melt, send, and receive Cashu tokens
-- **Wallet Management**: Create and manage multiple wallets
-- **Lightning Integration**: Pay Lightning invoices and receive payments
-- **Token Storage**: Secure local storage of tokens and mint configurations
+- **Lightning Integration**: Pay Lightning invoices (BOLT11, BOLT12, BIP353) and receive payments
+- **Payment Requests**: Create and pay payment requests with various conditions (P2PK, HTLC)
+- **Token Transfer**: Transfer tokens between different mints
+- **Multi-Currency Support**: Support for different currency units (sat, usd, eur, etc.)
+- **Database Options**: SQLite or Redb backend with optional encryption (SQLCipher)
+- **Tor Support**: Built-in Tor transport support (when compiled with feature)
+- **Secure Storage**: Local storage of tokens, mint configurations, and seed
 
 ## Installation
 
@@ -30,141 +34,379 @@ cargo build --bin cdk-cli --release
 # Binary will be at ./target/release/cdk-cli
 ```
 
+### Build with Optional Features
+```bash
+# With Tor support
+cargo build --bin cdk-cli --release --features tor
+
+# With SQLCipher encryption
+cargo build --bin cdk-cli --release --features sqlcipher
+
+# With Redb database
+cargo build --bin cdk-cli --release --features redb
+```
+
 ## Quick Start
 
-### 1. Add a Mint
+### 1. Check Your Balance
 ```bash
-# Add a mint (use a real mint URL or start your own with cdk-mintd)
-cdk-cli wallet add-mint http://127.0.0.1:8085
+# View your current balance across all mints
+cdk-cli balance
 ```
 
 ### 2. Mint Tokens
 ```bash
-# Create a mint quote for 100 sats
-cdk-cli wallet mint-quote 100
+# Create and mint tokens from a mint (amount in sats)
+cdk-cli mint http://127.0.0.1:8085 100
+
+# Or with a description
+cdk-cli mint http://127.0.0.1:8085 100 "My first mint"
 
-# Pay the Lightning invoice shown, then mint the tokens
-cdk-cli wallet mint <quote_id>
+# The command will display a Lightning invoice to pay
+# After payment, tokens are automatically minted
 ```
 
 ### 3. Send Tokens
 ```bash
-# Send 50 sats as a token
-cdk-cli wallet send 50
+# Send tokens (you'll be prompted for amount and mint selection interactively)
+cdk-cli send
+
+# Or specify options directly
+cdk-cli send --mint-url http://127.0.0.1:8085 --memo "Payment for coffee"
 ```
 
 ### 4. Receive Tokens
 ```bash
 # Receive a token from someone else
-cdk-cli wallet receive <cashu_token>
+cdk-cli receive <cashu_token>
+
+# Receive from untrusted mint with transfer to trusted mint
+cdk-cli receive <cashu_token> --allow-untrusted --transfer-to http://127.0.0.1:8085
 ```
 
-### 5. Check Balance
+## Global Options
+
+The CLI supports several global options that apply to all commands:
+
 ```bash
-# View your current balance
-cdk-cli wallet balance
+# Use a specific database engine
+cdk-cli --engine sqlite balance
+cdk-cli --engine redb balance
+
+# Set a custom work directory
+cdk-cli --work-dir ~/my-wallet balance
+
+# Set logging level
+cdk-cli --log-level info balance
+
+# Use a specific currency unit
+cdk-cli --unit usd balance
+
+# Use NIP-98 Wallet Signing Proxy
+cdk-cli --proxy https://proxy.example.com balance
+
+# Disable Tor (when built with Tor feature, it's on by default)
+cdk-cli --tor off balance
 ```
 
-## Basic Usage
+## Commands Reference
+
+### Balance Operations
 
-### Wallet Operations
 ```bash
-# List all wallets
-cdk-cli wallet list
+# Check balance across all mints
+cdk-cli balance
+```
 
-# Create a new wallet
-cdk-cli wallet new --name my-wallet
+### Minting Tokens
 
-# Set default wallet
-cdk-cli wallet set-default my-wallet
+```bash
+# Mint tokens with a Lightning invoice
+cdk-cli mint <MINT_URL> <AMOUNT>
 
-# Show wallet info
-cdk-cli wallet info
+# With options
+cdk-cli mint http://127.0.0.1:8085 1000 \
+  --method bolt11
+
+# Using an existing quote
+cdk-cli mint http://127.0.0.1:8085 --quote-id <quote_id>
+
+# Claim pending mint quotes that have been paid
+cdk-cli mint-pending
 ```
 
-### Mint Management
+### Sending & Receiving Tokens
+
+```bash
+# Send tokens (interactive)
+cdk-cli send
+
+# Send with specific options
+cdk-cli send \
+  --memo "Coffee payment" \
+  --mint-url http://127.0.0.1:8085 \
+  --include-fee \
+  --offline
+
+# Send with P2PK lock
+cdk-cli send --pubkey <public_key> --required-sigs 1
+
+# Send with HTLC (Hash Time Locked Contract)
+cdk-cli send --hash <hash> --locktime <unix_timestamp>
+
+# Send as V3 token
+cdk-cli send --v3
+
+# Send with automatic transfer from other mints if needed
+cdk-cli send --allow-transfer --max-transfer-amount 1000
+
+# Receive tokens
+cdk-cli receive <cashu_token>
+
+# Receive with signing key (for P2PK)
+cdk-cli receive <cashu_token> --signing-key <private_key>
+
+# Receive with HTLC preimage
+cdk-cli receive <cashu_token> --preimage <preimage>
+
+# Receive via Nostr
+cdk-cli receive --nostr-key <nostr_key> --relay wss://relay.example.com
+```
+
+### Lightning Payments
+
+```bash
+# Pay a Lightning invoice (interactive - will prompt for invoice)
+cdk-cli melt
+
+# Specify mint and payment method
+cdk-cli melt --mint-url http://127.0.0.1:8085 --method bolt11
+
+# Pay BOLT12 offer
+cdk-cli melt --method bolt12
+
+# Pay BIP353 address
+cdk-cli melt --method bip353
+
+# Multi-path payment
+cdk-cli melt --mpp
+```
+
+### Payment Requests
+
+```bash
+# Create a payment request (interactive via Nostr)
+cdk-cli create-request
+
+# Create with specific amount
+cdk-cli create-request --amount 1000 "Invoice for services"
+
+# Create with P2PK condition
+cdk-cli create-request --amount 500 \
+  --pubkey <pubkey1> \
+  --pubkey <pubkey2> \
+  --num-sigs 2
+
+# Create with HTLC
+cdk-cli create-request --amount 1000 --hash <hash>
+# Or use preimage instead
+cdk-cli create-request --amount 1000 --preimage <preimage>
+
+# Create with HTTP transport
+cdk-cli create-request --amount 1000 \
+  --transport http \
+  --http-url https://myserver.com/payment
+
+# Create without transport (just print the request)
+cdk-cli create-request --amount 1000 --transport none
+
+# Pay a payment request
+cdk-cli pay-request <payment_request>
+
+# Decode a payment request
+cdk-cli decode-request <payment_request>
+```
+
+### Token Transfer Between Mints
+
 ```bash
-# List connected mints
-cdk-cli wallet list-mints
+# Transfer tokens between mints (interactive)
+cdk-cli transfer
+
+# Transfer specific amount
+cdk-cli transfer \
+  --source-mint http://mint1.example.com \
+  --target-mint http://mint2.example.com \
+  --amount 1000
+
+# Transfer full balance from one mint to another
+cdk-cli transfer \
+  --source-mint http://mint1.example.com \
+  --target-mint http://mint2.example.com \
+  --full-balance
+```
 
-# Remove a mint
-cdk-cli wallet remove-mint <mint_url>
+### Mint Information & Management
 
+```bash
 # Get mint information
-cdk-cli wallet mint-info <mint_url>
+cdk-cli mint-info <MINT_URL>
+
+# Update mint URL (if mint has migrated)
+cdk-cli update-mint-url <OLD_URL> <NEW_URL>
+
+# List proofs from mint
+cdk-cli list-mint-proofs
 ```
 
-### Payment Operations
+### Token & Proof Management
+
 ```bash
-# Pay a Lightning invoice
-cdk-cli wallet pay-invoice <lightning_invoice>
+# Decode a Cashu token
+cdk-cli decode-token <cashu_token>
+
+# Check pending proofs and reclaim if no longer pending
+cdk-cli check-pending
 
-# Create melt quote for an invoice
-cdk-cli wallet melt-quote <lightning_invoice>
+# Burn spent tokens (cleanup)
+cdk-cli burn
 
-# Execute the melt
-cdk-cli wallet melt <quote_id>
+# Restore proofs from seed for a specific mint
+cdk-cli restore <MINT_URL>
 ```
 
-### Token Management
+### Advanced Features
+
+#### Blind Authentication (NUT-14)
+
 ```bash
-# List all tokens
-cdk-cli wallet list-tokens
+# Mint blind authentication proofs
+cdk-cli mint-blind-auth <MINT_URL> --amount <AMOUNT>
+```
 
-# Check token states
-cdk-cli wallet check-tokens
+#### CAT (Cashu Authentication Tokens)
+
+```bash
+# Login with username/password
+cdk-cli cat-login --username <username> --password <password>
 
-# Restore wallet from seed
-cdk-cli wallet restore --seed <seed_words>
+# Login with device code flow (OAuth-style)
+cdk-cli cat-device-login
 ```
 
 ## Configuration
 
+### Storage Location
+
 The CLI stores its configuration and wallet data in:
-- **Linux/macOS**: `~/.config/cdk-cli/`
-- **Windows**: `%APPDATA%\cdk-cli\`
+- **Linux/macOS**: `~/.cdk-cli/`
+- **Windows**: `%USERPROFILE%\.cdk-cli\`
+
+You can override this with the `--work-dir` option.
+
+### Database Options
+
+The CLI supports multiple database backends:
+
+#### SQLite (default)
+```bash
+cdk-cli --engine sqlite balance
+```
+
+#### SQLCipher (encrypted SQLite)
+```bash
+# Requires building with --features sqlcipher
+cdk-cli --engine sqlite --password mypassword balance
+```
+
+#### Redb
+```bash
+# Requires building with --features redb
+cdk-cli --engine redb balance
+```
+
+### Seed Management
+
+The wallet seed is automatically generated and stored in `<work-dir>/seed` on first run. This seed is used to derive all keys and can be used to restore your wallet.
+
+**Important**: Back up your seed file securely. Anyone with access to the seed can spend your tokens.
 
 ## Examples
 
 ### Complete Workflow Example
+
 ```bash
 # 1. Start a test mint (in another terminal)
 cdk-mintd
 
-# 2. Add the mint
-cdk-cli wallet add-mint http://127.0.0.1:8085
+# 2. Mint some tokens
+cdk-cli mint http://127.0.0.1:8085 1000 "Initial mint"
+# Pay the displayed Lightning invoice
+
+# 3. Check balance
+cdk-cli balance
 
-# 3. Create a mint quote
-cdk-cli wallet mint-quote 1000
+# 4. Send some tokens
+cdk-cli send
+# Follow interactive prompts
 
-# 4. Pay the Lightning invoice (if using real Lightning backend)
-# or wait a few seconds if using fake wallet
+# 5. The recipient can receive with:
+cdk-cli receive <cashu_token_string>
+
+# 6. Pay a Lightning invoice
+cdk-cli melt
+# Follow prompts to enter invoice
+```
 
-# 5. Mint the tokens
-cdk-cli wallet mint <quote_id>
+### Multi-Mint Setup
+
+```bash
+# Mint from multiple mints
+cdk-cli mint http://mint1.example.com 5000
+cdk-cli mint http://mint2.example.com 3000
+
+# Check balance (shows breakdown by mint)
+cdk-cli balance
+
+# Transfer between mints
+cdk-cli transfer \
+  --source-mint http://mint1.example.com \
+  --target-mint http://mint2.example.com \
+  --amount 2000
+```
 
-# 6. Check balance
-cdk-cli wallet balance
+### Payment Request Workflow
 
-# 7. Send some tokens
-cdk-cli wallet send 100
+```bash
+# Recipient creates a payment request
+cdk-cli create-request --amount 1000 "Payment for services"
+# Copy the payment request string
+
+# Sender pays the request
+cdk-cli pay-request <payment_request_string>
+```
+
+### P2PK (Pay to Public Key) Usage
+
+```bash
+# Send tokens locked to a public key
+cdk-cli send --pubkey <recipient_pubkey> --required-sigs 1
 
-# 8. The recipient can receive with:
-cdk-cli wallet receive <cashu_token_string>
+# Recipient receives with their private key
+cdk-cli receive <cashu_token> --signing-key <private_key>
 ```
 
-### Working with Multiple Wallets
+### HTLC (Hash Time Locked Contract) Usage
+
 ```bash
-# Create wallets for different purposes
-cdk-cli wallet new --name savings
-cdk-cli wallet new --name daily
+# Create a preimage and hash (externally)
+# hash = SHA256(preimage)
 
-# Switch between wallets
-cdk-cli wallet set-default savings
-cdk-cli wallet balance
+# Send with HTLC
+cdk-cli send --hash <hash> --locktime 1700000000
 
-cdk-cli wallet set-default daily
-cdk-cli wallet balance
+# Recipient receives with preimage
+cdk-cli receive <cashu_token> --preimage <preimage>
 ```
 
 ## Help and Documentation
@@ -174,8 +416,35 @@ cdk-cli wallet balance
 cdk-cli --help
 
 # Help for specific commands
-cdk-cli wallet --help
-cdk-cli wallet mint-quote --help
+cdk-cli mint --help
+cdk-cli send --help
+cdk-cli receive --help
+cdk-cli create-request --help
+```
+
+## Troubleshooting
+
+### Pending Tokens
+If you have pending tokens (sent but not received, or mint quotes paid but not claimed):
+
+```bash
+# Check and reclaim pending proofs
+cdk-cli check-pending
+
+# Claim paid mint quotes
+cdk-cli mint-pending
+```
+
+### Cleaning Up
+```bash
+# Remove spent tokens from database
+cdk-cli burn
+```
+
+### Restore from Seed
+```bash
+# Restore proofs from a specific mint
+cdk-cli restore <MINT_URL>
 ```
 
 ## License

+ 42 - 7
crates/cdk-cli/src/main.rs

@@ -13,6 +13,8 @@ use cdk::wallet::MultiMintWallet;
 #[cfg(feature = "redb")]
 use cdk_redb::WalletRedbDatabase;
 use cdk_sqlite::WalletSqliteDatabase;
+#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
+use clap::ValueEnum;
 use clap::{Parser, Subcommand};
 use tracing::Level;
 use tracing_subscriber::EnvFilter;
@@ -27,11 +29,15 @@ const DEFAULT_WORK_DIR: &str = ".cdk-cli";
 const CARGO_PKG_VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION");
 
 /// Simple CLI application to interact with cashu
+#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
+#[derive(Copy, Clone, Debug, ValueEnum)]
+enum TorToggle {
+    On,
+    Off,
+}
+
 #[derive(Parser)]
-#[command(name = "cdk-cli")]
-#[command(author = "thesimplekid <tsk@thesimplekid.com>")]
-#[command(version = CARGO_PKG_VERSION.unwrap_or("Unknown"))]
-#[command(author, version, about, long_about = None)]
+#[command(name = "cdk-cli", author = "thesimplekid <tsk@thesimplekid.com>", version = CARGO_PKG_VERSION.unwrap_or("Unknown"), about, long_about = None)]
 struct Cli {
     /// Database engine to use (sqlite/redb)
     #[arg(short, long, default_value = "sqlite")]
@@ -52,6 +58,11 @@ struct Cli {
     /// Currency unit to use for the wallet
     #[arg(short, long, default_value = "sat")]
     unit: String,
+    /// Use Tor transport (only when built with --features tor). Defaults to 'on' when feature is enabled.
+    #[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
+    #[arg(long = "tor", value_enum, default_value_t = TorToggle::On)]
+    transport: TorToggle,
+    /// Subcommand to run
     #[command(subcommand)]
     command: Commands,
 }
@@ -120,7 +131,10 @@ async fn main() -> Result<()> {
         }
     };
 
-    fs::create_dir_all(&work_dir)?;
+    // Create work directory if it doesn't exist
+    if !work_dir.exists() {
+        fs::create_dir_all(&work_dir)?;
+    }
 
     let localstore: Arc<dyn WalletDatabase<Err = cdk_database::Error> + Send + Sync> =
         match args.engine.as_str() {
@@ -181,7 +195,6 @@ async fn main() -> Result<()> {
     // The constructor will automatically load wallets for this currency unit
     let multi_mint_wallet = match &args.proxy {
         Some(proxy_url) => {
-            // Create MultiMintWallet with proxy configuration
             MultiMintWallet::new_with_proxy(
                 localstore.clone(),
                 seed,
@@ -190,7 +203,29 @@ async fn main() -> Result<()> {
             )
             .await?
         }
-        None => MultiMintWallet::new(localstore.clone(), seed, currency_unit.clone()).await?,
+        None => {
+            #[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
+            {
+                match args.transport {
+                    TorToggle::On => {
+                        MultiMintWallet::new_with_tor(
+                            localstore.clone(),
+                            seed,
+                            currency_unit.clone(),
+                        )
+                        .await?
+                    }
+                    TorToggle::Off => {
+                        MultiMintWallet::new(localstore.clone(), seed, currency_unit.clone())
+                            .await?
+                    }
+                }
+            }
+            #[cfg(not(all(feature = "tor", not(target_arch = "wasm32"))))]
+            {
+                MultiMintWallet::new(localstore.clone(), seed, currency_unit.clone()).await?
+            }
+        }
     };
 
     match &args.command {

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

+ 11 - 0
crates/cdk-common/Cargo.toml

@@ -40,10 +40,21 @@ anyhow.workspace = true
 serde_json.workspace = true
 serde_with.workspace = true
 web-time.workspace = true
+tokio.workspace = true
+parking_lot = "0.12.5"
 
 [target.'cfg(target_arch = "wasm32")'.dependencies]
 uuid = { workspace = true, features = ["js"], optional = true }
+getrandom = { version = "0.2", features = ["js"] }
+wasm-bindgen = "0.2"
+wasm-bindgen-futures = "0.4"
 
 [dev-dependencies]
 rand.workspace = true
 bip39.workspace = true
+wasm-bindgen-test = "0.3"
+criterion.workspace = true
+
+[[bench]]
+name = "transaction_id_benchmark"
+harness = false

+ 29 - 0
crates/cdk-common/benches/transaction_id_benchmark.rs

@@ -0,0 +1,29 @@
+use cashu::nuts::nut01::SecretKey;
+use cashu::PublicKey;
+use cdk_common::wallet::TransactionId;
+use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion};
+
+fn generate_public_keys(count: usize) -> Vec<PublicKey> {
+    (0..count)
+        .map(|_| SecretKey::generate().public_key())
+        .collect()
+}
+
+fn bench_transaction_id(c: &mut Criterion) {
+    let mut group = c.benchmark_group("TransactionId::new");
+
+    let sizes = vec![1, 10, 50, 100, 500];
+
+    for size in sizes {
+        let public_keys = generate_public_keys(size);
+
+        group.bench_with_input(BenchmarkId::from_parameter(size), &size, |b, _| {
+            b.iter(|| TransactionId::new(public_keys.clone()));
+        });
+    }
+
+    group.finish();
+}
+
+criterion_group!(benches, bench_transaction_id);
+criterion_main!(benches);

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

+ 7 - 0
crates/cdk-common/src/database/wallet.rs

@@ -96,6 +96,13 @@ pub trait Database: Debug {
         state: Option<Vec<State>>,
         spending_conditions: Option<Vec<SpendingConditions>>,
     ) -> Result<Vec<ProofInfo>, Self::Err>;
+    /// Get balance
+    async fn get_balance(
+        &self,
+        mint_url: Option<MintUrl>,
+        unit: Option<CurrencyUnit>,
+        state: Option<Vec<State>>,
+    ) -> Result<u64, Self::Err>;
     /// Update proofs state in storage
     async fn update_proofs_state(&self, ys: Vec<PublicKey>, state: State) -> Result<(), Self::Err>;
 

+ 2 - 0
crates/cdk-common/src/lib.rs

@@ -33,3 +33,5 @@ pub use cashu::nuts::{self, *};
 pub use cashu::quote_id::{self, *};
 pub use cashu::{dhke, ensure_cdk, mint_url, secret, util, SECP256K1};
 pub use error::Error;
+/// Re-export parking_lot for reuse
+pub use parking_lot;

+ 44 - 0
crates/cdk-common/src/pub_sub/error.rs

@@ -0,0 +1,44 @@
+//! Error types for the pub-sub module.
+
+use tokio::sync::mpsc::error::TrySendError;
+
+#[derive(thiserror::Error, Debug)]
+/// Error
+pub enum Error {
+    /// No subscription found
+    #[error("Subscription not found")]
+    NoSubscription,
+
+    /// Parsing error
+    #[error("Parsing Error {0}")]
+    ParsingError(String),
+
+    /// Internal error
+    #[error("Internal")]
+    Internal(Box<dyn std::error::Error + Send + Sync>),
+
+    /// Internal error
+    #[error("Internal error {0}")]
+    InternalStr(String),
+
+    /// Not supported
+    #[error("Not supported")]
+    NotSupported,
+
+    /// Channel is full
+    #[error("Channel is full")]
+    ChannelFull,
+
+    /// Channel is closed
+    #[error("Channel is close")]
+    ChannelClosed,
+}
+
+impl<T> From<TrySendError<T>> for Error {
+    fn from(value: TrySendError<T>) -> Self {
+        match value {
+            TrySendError::Closed(_) => Error::ChannelClosed,
+            TrySendError::Full(_) => Error::ChannelFull,
+        }
+    }
+}

+ 0 - 161
crates/cdk-common/src/pub_sub/index.rs

@@ -1,161 +0,0 @@
-//! WS Index
-
-use std::fmt::Debug;
-use std::ops::Deref;
-use std::sync::atomic::{AtomicUsize, Ordering};
-
-use super::SubId;
-
-/// Indexable trait
-pub trait Indexable {
-    /// The type of the index, it is unknown and it is up to the Manager's
-    /// generic type
-    type Type: PartialOrd + Ord + Send + Sync + Debug;
-
-    /// To indexes
-    fn to_indexes(&self) -> Vec<Index<Self::Type>>;
-}
-
-#[derive(Debug, Ord, PartialOrd, PartialEq, Eq, Clone)]
-/// Index
-///
-/// The Index is a sorted structure that is used to quickly find matches
-///
-/// The counter is used to make sure each Index is unique, even if the prefix
-/// are the same, and also to make sure that earlier indexes matches first
-pub struct Index<T>
-where
-    T: PartialOrd + Ord + Send + Sync + Debug,
-{
-    prefix: T,
-    counter: SubscriptionGlobalId,
-    id: super::SubId,
-}
-
-impl<T> From<&Index<T>> for super::SubId
-where
-    T: PartialOrd + Ord + Send + Sync + Debug,
-{
-    fn from(val: &Index<T>) -> Self {
-        val.id.clone()
-    }
-}
-
-impl<T> Deref for Index<T>
-where
-    T: PartialOrd + Ord + Send + Sync + Debug,
-{
-    type Target = T;
-
-    fn deref(&self) -> &Self::Target {
-        &self.prefix
-    }
-}
-
-impl<T> Index<T>
-where
-    T: PartialOrd + Ord + Send + Sync + Debug,
-{
-    /// Compare the
-    pub fn cmp_prefix(&self, other: &Index<T>) -> std::cmp::Ordering {
-        self.prefix.cmp(&other.prefix)
-    }
-
-    /// Returns a globally unique id for the Index
-    pub fn unique_id(&self) -> usize {
-        self.counter.0
-    }
-}
-
-impl<T> From<(T, SubId, SubscriptionGlobalId)> for Index<T>
-where
-    T: PartialOrd + Ord + Send + Sync + Debug,
-{
-    fn from((prefix, id, counter): (T, SubId, SubscriptionGlobalId)) -> Self {
-        Self {
-            prefix,
-            id,
-            counter,
-        }
-    }
-}
-
-impl<T> From<(T, SubId)> for Index<T>
-where
-    T: PartialOrd + Ord + Send + Sync + Debug,
-{
-    fn from((prefix, id): (T, SubId)) -> Self {
-        Self {
-            prefix,
-            id,
-            counter: Default::default(),
-        }
-    }
-}
-
-impl<T> From<T> for Index<T>
-where
-    T: PartialOrd + Ord + Send + Sync + Debug,
-{
-    fn from(prefix: T) -> Self {
-        Self {
-            prefix,
-            id: Default::default(),
-            counter: SubscriptionGlobalId(0),
-        }
-    }
-}
-
-static COUNTER: AtomicUsize = AtomicUsize::new(0);
-
-/// Dummy type
-///
-/// This is only use so each Index is unique, with the same prefix.
-///
-/// The prefix is used to leverage the BTree to find things quickly, but each
-/// entry/key must be unique, so we use this dummy type to make sure each Index
-/// is unique.
-///
-/// Unique is also used to make sure that the indexes are sorted by creation order
-#[derive(Debug, Ord, PartialOrd, PartialEq, Eq, Clone, Copy)]
-pub struct SubscriptionGlobalId(usize);
-
-impl Default for SubscriptionGlobalId {
-    fn default() -> Self {
-        Self(COUNTER.fetch_add(1, Ordering::Relaxed))
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn test_index_from_tuple() {
-        let sub_id = SubId::from("test_sub_id");
-        let prefix = "test_prefix";
-        let index: Index<&str> = Index::from((prefix, sub_id.clone()));
-        assert_eq!(index.prefix, "test_prefix");
-        assert_eq!(index.id, sub_id);
-    }
-
-    #[test]
-    fn test_index_cmp_prefix() {
-        let sub_id = SubId::from("test_sub_id");
-        let index1: Index<&str> = Index::from(("a", sub_id.clone()));
-        let index2: Index<&str> = Index::from(("b", sub_id.clone()));
-        assert_eq!(index1.cmp_prefix(&index2), std::cmp::Ordering::Less);
-    }
-
-    #[test]
-    fn test_sub_id_from_str() {
-        let sub_id = SubId::from("test_sub_id");
-        assert_eq!(sub_id.0, "test_sub_id");
-    }
-
-    #[test]
-    fn test_sub_id_deref() {
-        let sub_id = SubId::from("test_sub_id");
-        assert_eq!(&*sub_id, "test_sub_id");
-    }
-}

+ 165 - 62
crates/cdk-common/src/pub_sub/mod.rs

@@ -1,77 +1,180 @@
-//! Publish–subscribe pattern.
+//! Publish/Subscribe core
 //!
-//! This is a generic implementation for
-//! [NUT-17](<https://github.com/cashubtc/nuts/blob/main/17.md>) with a type
-//! agnostic Publish-subscribe manager.
+//! This module defines the transport-agnostic pub/sub primitives used by both
+//! mint and wallet components. The design prioritizes:
 //!
-//! The manager has a method for subscribers to subscribe to events with a
-//! generic type that must be converted to a vector of indexes.
+//! - **Request coalescing**: multiple local subscribers to the same remote topic
+//!   result in a single upstream subscription, with local fan‑out.
+//! - **Latest-on-subscribe** (NUT-17): on (re)subscription, the most recent event
+//!   is fetched and delivered before streaming new ones.
+//! - **Backpressure-aware delivery**: bounded channels + drop policies prevent
+//!   a slow consumer from stalling the whole pipeline.
+//! - **Resilience**: automatic reconnect with exponential backoff; WebSocket
+//!   streaming when available, HTTP long-poll fallback otherwise.
 //!
-//! Events are also generic that should implement the `Indexable` trait.
-use std::fmt::Debug;
-use std::ops::Deref;
-use std::str::FromStr;
-
-use serde::{Deserialize, Serialize};
-
-pub mod index;
-
-/// Default size of the remove channel
-pub const DEFAULT_REMOVE_SIZE: usize = 10_000;
-
-/// Default channel size for subscription buffering
-pub const DEFAULT_CHANNEL_SIZE: usize = 10;
-
-#[async_trait::async_trait]
-/// On New Subscription trait
-///
-/// This trait is optional and it is used to notify the application when a new
-/// subscription is created. This is useful when the application needs to send
-/// the initial state to the subscriber upon subscription
-pub trait OnNewSubscription {
-    /// Index type
-    type Index;
-    /// Subscription event type
-    type Event;
-
-    /// Called when a new subscription is created
-    async fn on_new_subscription(
-        &self,
-        request: &[&Self::Index],
-    ) -> Result<Vec<Self::Event>, String>;
-}
+//! Terms used throughout the module:
+//! - **Event**: a domain object that maps to one or more `Topic`s via `Event::get_topics`.
+//! - **Topic**: an index/type that defines storage and matching semantics.
+//! - **SubscriptionRequest**: a domain-specific filter that can be converted into
+//!   low-level transport messages (e.g., WebSocket subscribe frames).
+//! - **Spec**: type bundle tying `Event`, `Topic`, `SubscriptionId`, and serialization.
+
+mod error;
+mod pubsub;
+pub mod remote_consumer;
+mod subscriber;
+mod types;
+
+pub use self::error::Error;
+pub use self::pubsub::Pubsub;
+pub use self::subscriber::{Subscriber, SubscriptionRequest};
+pub use self::types::*;
+
+#[cfg(test)]
+mod test {
+    use std::collections::HashMap;
+    use std::sync::{Arc, RwLock};
+
+    use serde::{Deserialize, Serialize};
 
-/// Subscription Id wrapper
-///
-/// This is the place to add some sane default (like a max length) to the
-/// subscription ID
-#[derive(Debug, Clone, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
-pub struct SubId(String);
+    use super::subscriber::SubscriptionRequest;
+    use super::{Error, Event, Pubsub, Spec, Subscriber};
 
-impl From<&str> for SubId {
-    fn from(s: &str) -> Self {
-        Self(s.to_string())
+    #[derive(Clone, Debug, Serialize, Eq, PartialEq, Deserialize)]
+    pub struct Message {
+        pub foo: u64,
+        pub bar: u64,
     }
-}
 
-impl From<String> for SubId {
-    fn from(s: String) -> Self {
-        Self(s)
+    #[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
+    pub enum IndexTest {
+        Foo(u64),
+        Bar(u64),
     }
-}
 
-impl FromStr for SubId {
-    type Err = ();
+    impl Event for Message {
+        type Topic = IndexTest;
 
-    fn from_str(s: &str) -> Result<Self, Self::Err> {
-        Ok(Self(s.to_string()))
+        fn get_topics(&self) -> Vec<Self::Topic> {
+            vec![IndexTest::Foo(self.foo), IndexTest::Bar(self.bar)]
+        }
     }
-}
 
-impl Deref for SubId {
-    type Target = String;
+    pub struct CustomPubSub {
+        pub storage: Arc<RwLock<HashMap<IndexTest, Message>>>,
+    }
+
+    #[async_trait::async_trait]
+    impl Spec for CustomPubSub {
+        type Topic = IndexTest;
+
+        type Event = Message;
+
+        type SubscriptionId = String;
+
+        type Context = ();
+
+        fn new_instance(_context: Self::Context) -> Arc<Self>
+        where
+            Self: Sized,
+        {
+            Arc::new(Self {
+                storage: Default::default(),
+            })
+        }
+
+        async fn fetch_events(
+            self: &Arc<Self>,
+            topics: Vec<<Self::Event as Event>::Topic>,
+            reply_to: Subscriber<Self>,
+        ) where
+            Self: Sized,
+        {
+            let storage = self.storage.read().unwrap();
+
+            for index in topics {
+                if let Some(value) = storage.get(&index) {
+                    let _ = reply_to.send(value.clone());
+                }
+            }
+        }
+    }
+
+    #[derive(Debug, Clone)]
+    pub enum SubscriptionReq {
+        Foo(u64),
+        Bar(u64),
+    }
+
+    impl SubscriptionRequest for SubscriptionReq {
+        type Topic = IndexTest;
+
+        type SubscriptionId = String;
+
+        fn try_get_topics(&self) -> Result<Vec<Self::Topic>, Error> {
+            Ok(vec![match self {
+                SubscriptionReq::Bar(n) => IndexTest::Bar(*n),
+                SubscriptionReq::Foo(n) => IndexTest::Foo(*n),
+            }])
+        }
+
+        fn subscription_name(&self) -> Arc<Self::SubscriptionId> {
+            Arc::new("test".to_owned())
+        }
+    }
+
+    #[tokio::test]
+    async fn delivery_twice_realtime() {
+        let pubsub = Pubsub::new(CustomPubSub::new_instance(()));
+
+        assert_eq!(pubsub.active_subscribers(), 0);
+
+        let mut subscriber = pubsub.subscribe(SubscriptionReq::Foo(2)).unwrap();
+
+        assert_eq!(pubsub.active_subscribers(), 1);
+
+        let _ = pubsub.publish_now(Message { foo: 2, bar: 1 });
+        let _ = pubsub.publish_now(Message { foo: 2, bar: 2 });
+
+        assert_eq!(subscriber.recv().await.map(|x| x.bar), Some(1));
+        assert_eq!(subscriber.recv().await.map(|x| x.bar), Some(2));
+        assert!(subscriber.try_recv().is_none());
+
+        drop(subscriber);
+
+        assert_eq!(pubsub.active_subscribers(), 0);
+    }
+
+    #[tokio::test]
+    async fn read_from_storage() {
+        let x = CustomPubSub::new_instance(());
+        let storage = x.storage.clone();
+
+        let pubsub = Pubsub::new(x);
+
+        {
+            // set previous value
+            let mut s = storage.write().unwrap();
+            s.insert(IndexTest::Bar(2), Message { foo: 3, bar: 2 });
+        }
+
+        let mut subscriber = pubsub.subscribe(SubscriptionReq::Bar(2)).unwrap();
+
+        // Just should receive the latest
+        assert_eq!(subscriber.recv().await.map(|x| x.foo), Some(3));
+
+        // realtime delivery test
+        let _ = pubsub.publish_now(Message { foo: 1, bar: 2 });
+        assert_eq!(subscriber.recv().await.map(|x| x.foo), Some(1));
+
+        {
+            // set previous value
+            let mut s = storage.write().unwrap();
+            s.insert(IndexTest::Bar(2), Message { foo: 1, bar: 2 });
+        }
 
-    fn deref(&self) -> &Self::Target {
-        &self.0
+        // new subscription should only get the latest state (it is up to the Topic trait)
+        let mut y = pubsub.subscribe(SubscriptionReq::Bar(2)).unwrap();
+        assert_eq!(y.recv().await.map(|x| x.foo), Some(1));
     }
 }

+ 185 - 0
crates/cdk-common/src/pub_sub/pubsub.rs

@@ -0,0 +1,185 @@
+//! Pub-sub producer
+
+use std::cmp::Ordering;
+use std::collections::{BTreeMap, HashSet};
+use std::sync::atomic::AtomicUsize;
+use std::sync::Arc;
+
+use parking_lot::RwLock;
+use tokio::sync::mpsc;
+
+use super::subscriber::{ActiveSubscription, SubscriptionRequest};
+use super::{Error, Event, Spec, Subscriber};
+
+/// Default channel size for subscription buffering
+pub const DEFAULT_CHANNEL_SIZE: usize = 10_000;
+
+/// Subscriber Receiver
+pub type SubReceiver<S> = mpsc::Receiver<(Arc<<S as Spec>::SubscriptionId>, <S as Spec>::Event)>;
+
+/// Internal Index Tree
+pub type TopicTree<T> = Arc<
+    RwLock<
+        BTreeMap<
+            // Index with a subscription unique ID
+            (<T as Spec>::Topic, usize),
+            Subscriber<T>,
+        >,
+    >,
+>;
+
+/// Manager
+pub struct Pubsub<S>
+where
+    S: Spec + 'static,
+{
+    inner: Arc<S>,
+    listeners_topics: TopicTree<S>,
+    unique_subscription_counter: AtomicUsize,
+    active_subscribers: Arc<AtomicUsize>,
+}
+
+impl<S> Pubsub<S>
+where
+    S: Spec + 'static,
+{
+    /// Create a new instance
+    pub fn new(inner: Arc<S>) -> Self {
+        Self {
+            inner,
+            listeners_topics: Default::default(),
+            unique_subscription_counter: 0.into(),
+            active_subscribers: Arc::new(0.into()),
+        }
+    }
+
+    /// Total number of active subscribers, it is not the number of active topics being subscribed
+    pub fn active_subscribers(&self) -> usize {
+        self.active_subscribers
+            .load(std::sync::atomic::Ordering::Relaxed)
+    }
+
+    /// Publish an event to all listenrs
+    #[inline(always)]
+    fn publish_internal(event: S::Event, listeners_index: &TopicTree<S>) -> Result<(), Error> {
+        let index_storage = listeners_index.read();
+
+        let mut sent = HashSet::new();
+        for topic in event.get_topics() {
+            for ((subscription_index, unique_id), sender) in
+                index_storage.range((topic.clone(), 0)..)
+            {
+                if subscription_index.cmp(&topic) != Ordering::Equal {
+                    break;
+                }
+                if sent.contains(&unique_id) {
+                    continue;
+                }
+                sent.insert(unique_id);
+                sender.send(event.clone());
+            }
+        }
+
+        Ok(())
+    }
+
+    /// Broadcast an event to all listeners
+    #[inline(always)]
+    pub fn publish<E>(&self, event: E)
+    where
+        E: Into<S::Event>,
+    {
+        let topics = self.listeners_topics.clone();
+        let event = event.into();
+
+        #[cfg(not(target_arch = "wasm32"))]
+        tokio::spawn(async move {
+            let _ = Self::publish_internal(event, &topics);
+        });
+
+        #[cfg(target_arch = "wasm32")]
+        wasm_bindgen_futures::spawn_local(async move {
+            let _ = Self::publish_internal(event, &topics);
+        });
+    }
+
+    /// Broadcast an event to all listeners right away, blocking the current thread
+    ///
+    /// This function takes an Arc to the storage struct, the event_id, the kind
+    /// and the vent to broadcast
+    #[inline(always)]
+    pub fn publish_now<E>(&self, event: E) -> Result<(), Error>
+    where
+        E: Into<S::Event>,
+    {
+        let event = event.into();
+        Self::publish_internal(event, &self.listeners_topics)
+    }
+
+    /// Subscribe proving custom sender/receiver mpsc
+    #[inline(always)]
+    pub fn subscribe_with<I>(
+        &self,
+        request: I,
+        sender: &mpsc::Sender<(Arc<I::SubscriptionId>, S::Event)>,
+        receiver: Option<SubReceiver<S>>,
+    ) -> Result<ActiveSubscription<S>, Error>
+    where
+        I: SubscriptionRequest<
+            Topic = <S::Event as Event>::Topic,
+            SubscriptionId = S::SubscriptionId,
+        >,
+    {
+        let subscription_name = request.subscription_name();
+        let sender = Subscriber::new(subscription_name.clone(), sender);
+        let mut index_storage = self.listeners_topics.write();
+        let subscription_internal_id = self
+            .unique_subscription_counter
+            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+
+        self.active_subscribers
+            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+
+        let subscribed_to = request.try_get_topics()?;
+
+        for index in subscribed_to.iter() {
+            index_storage.insert((index.clone(), subscription_internal_id), sender.clone());
+        }
+        drop(index_storage);
+
+        let inner = self.inner.clone();
+        let subscribed_to_for_spawn = subscribed_to.clone();
+
+        #[cfg(not(target_arch = "wasm32"))]
+        tokio::spawn(async move {
+            // TODO: Ignore topics broadcasted from fetch_events _if_ any real time has been broadcasted already.
+            inner.fetch_events(subscribed_to_for_spawn, sender).await;
+        });
+
+        #[cfg(target_arch = "wasm32")]
+        wasm_bindgen_futures::spawn_local(async move {
+            inner.fetch_events(subscribed_to_for_spawn, sender).await;
+        });
+
+        Ok(ActiveSubscription::new(
+            subscription_internal_id,
+            subscription_name,
+            self.active_subscribers.clone(),
+            self.listeners_topics.clone(),
+            subscribed_to,
+            receiver,
+        ))
+    }
+
+    /// Subscribe
+    pub fn subscribe<I>(&self, request: I) -> Result<ActiveSubscription<S>, Error>
+    where
+        I: SubscriptionRequest<
+            Topic = <S::Event as Event>::Topic,
+            SubscriptionId = S::SubscriptionId,
+        >,
+    {
+        let (sender, receiver) = mpsc::channel(DEFAULT_CHANNEL_SIZE);
+        self.subscribe_with(request, &sender, Some(receiver))
+    }
+}

+ 885 - 0
crates/cdk-common/src/pub_sub/remote_consumer.rs

@@ -0,0 +1,885 @@
+//! Pub-sub consumer
+//!
+//! Consumers are designed to connect to a producer, through a transport, and subscribe to events.
+use std::collections::{HashMap, VecDeque};
+use std::sync::atomic::AtomicBool;
+use std::sync::Arc;
+use std::time::Duration;
+
+use parking_lot::RwLock;
+use tokio::sync::mpsc;
+use tokio::time::{sleep, Instant};
+
+use super::subscriber::{ActiveSubscription, SubscriptionRequest};
+use super::{Error, Event, Pubsub, Spec};
+
+const STREAM_CONNECTION_BACKOFF: Duration = Duration::from_millis(2_000);
+
+const STREAM_CONNECTION_MAX_BACKOFF: Duration = Duration::from_millis(30_000);
+
+const INTERNAL_POLL_SIZE: usize = 1_000;
+
+const POLL_SLEEP: Duration = Duration::from_millis(2_000);
+
+#[cfg(target_arch = "wasm32")]
+use wasm_bindgen_futures;
+
+struct UniqueSubscription<S>
+where
+    S: Spec,
+{
+    name: S::SubscriptionId,
+    total_subscribers: usize,
+}
+
+type UniqueSubscriptions<S> = RwLock<HashMap<<S as Spec>::Topic, UniqueSubscription<S>>>;
+
+type ActiveSubscriptions<S> =
+    RwLock<HashMap<Arc<<S as Spec>::SubscriptionId>, Vec<<S as Spec>::Topic>>>;
+
+type CacheEvent<S> = HashMap<<<S as Spec>::Event as Event>::Topic, <S as Spec>::Event>;
+
+/// Subscription consumer
+pub struct Consumer<T>
+where
+    T: Transport + 'static,
+{
+    transport: T,
+    inner_pubsub: Arc<Pubsub<T::Spec>>,
+    remote_subscriptions: UniqueSubscriptions<T::Spec>,
+    subscriptions: ActiveSubscriptions<T::Spec>,
+    stream_ctrl: RwLock<Option<mpsc::Sender<StreamCtrl<T::Spec>>>>,
+    still_running: AtomicBool,
+    prefer_polling: bool,
+    /// Cached events
+    ///
+    /// The cached events are useful to share events. The cache is automatically evicted it is
+    /// disconnected from the remote source, meaning the cache is only active while there is an
+    /// active subscription to the remote source, and it remembers the latest event.
+    cached_events: Arc<RwLock<CacheEvent<T::Spec>>>,
+}
+
+/// Remote consumer
+pub struct RemoteActiveConsumer<T>
+where
+    T: Transport + 'static,
+{
+    inner: ActiveSubscription<T::Spec>,
+    previous_messages: VecDeque<<T::Spec as Spec>::Event>,
+    consumer: Arc<Consumer<T>>,
+}
+
+impl<T> RemoteActiveConsumer<T>
+where
+    T: Transport + 'static,
+{
+    /// Receives the next event
+    pub async fn recv(&mut self) -> Option<<T::Spec as Spec>::Event> {
+        if let Some(event) = self.previous_messages.pop_front() {
+            Some(event)
+        } else {
+            self.inner.recv().await
+        }
+    }
+
+    /// Try receive an event or return Noen right away
+    pub fn try_recv(&mut self) -> Option<<T::Spec as Spec>::Event> {
+        if let Some(event) = self.previous_messages.pop_front() {
+            Some(event)
+        } else {
+            self.inner.try_recv()
+        }
+    }
+
+    /// Get the subscription name
+    pub fn name(&self) -> &<T::Spec as Spec>::SubscriptionId {
+        self.inner.name()
+    }
+}
+
+impl<T> Drop for RemoteActiveConsumer<T>
+where
+    T: Transport + 'static,
+{
+    fn drop(&mut self) {
+        let _ = self.consumer.unsubscribe(self.name().clone());
+    }
+}
+
+/// Struct to relay events from Poll and Streams from the external subscription to the local
+/// subscribers
+pub struct InternalRelay<S>
+where
+    S: Spec + 'static,
+{
+    inner: Arc<Pubsub<S>>,
+    cached_events: Arc<RwLock<CacheEvent<S>>>,
+}
+
+impl<S> InternalRelay<S>
+where
+    S: Spec + 'static,
+{
+    /// Relay a remote event locally
+    pub fn send<X>(&self, event: X)
+    where
+        X: Into<S::Event>,
+    {
+        let event = event.into();
+        let mut cached_events = self.cached_events.write();
+
+        for topic in event.get_topics() {
+            cached_events.insert(topic, event.clone());
+        }
+
+        self.inner.publish(event);
+    }
+}
+
+impl<T> Consumer<T>
+where
+    T: Transport + 'static,
+{
+    /// Creates a new instance
+    pub fn new(
+        transport: T,
+        prefer_polling: bool,
+        context: <T::Spec as Spec>::Context,
+    ) -> Arc<Self> {
+        let this = Arc::new(Self {
+            transport,
+            prefer_polling,
+            inner_pubsub: Arc::new(Pubsub::new(T::Spec::new_instance(context))),
+            subscriptions: Default::default(),
+            remote_subscriptions: Default::default(),
+            stream_ctrl: RwLock::new(None),
+            cached_events: Default::default(),
+            still_running: true.into(),
+        });
+
+        #[cfg(not(target_arch = "wasm32"))]
+        tokio::spawn(Self::stream(this.clone()));
+
+        #[cfg(target_arch = "wasm32")]
+        wasm_bindgen_futures::spawn_local(Self::stream(this.clone()));
+
+        this
+    }
+
+    async fn stream(instance: Arc<Self>) {
+        let mut stream_supported = true;
+        let mut poll_supported = true;
+
+        let mut backoff = STREAM_CONNECTION_BACKOFF;
+        let mut retry_at = None;
+
+        loop {
+            if (!stream_supported && !poll_supported)
+                || !instance
+                    .still_running
+                    .load(std::sync::atomic::Ordering::Relaxed)
+            {
+                break;
+            }
+
+            if instance.remote_subscriptions.read().is_empty() {
+                sleep(Duration::from_millis(100)).await;
+                continue;
+            }
+
+            if stream_supported
+                && !instance.prefer_polling
+                && retry_at
+                    .map(|retry_at| retry_at < Instant::now())
+                    .unwrap_or(true)
+            {
+                let (sender, receiver) = mpsc::channel(INTERNAL_POLL_SIZE);
+
+                {
+                    *instance.stream_ctrl.write() = Some(sender);
+                }
+
+                let current_subscriptions = {
+                    instance
+                        .remote_subscriptions
+                        .read()
+                        .iter()
+                        .map(|(key, name)| (name.name.clone(), key.clone()))
+                        .collect::<Vec<_>>()
+                };
+
+                if let Err(err) = instance
+                    .transport
+                    .stream(
+                        receiver,
+                        current_subscriptions,
+                        InternalRelay {
+                            inner: instance.inner_pubsub.clone(),
+                            cached_events: instance.cached_events.clone(),
+                        },
+                    )
+                    .await
+                {
+                    retry_at = Some(Instant::now() + backoff);
+                    backoff =
+                        (backoff + STREAM_CONNECTION_BACKOFF).min(STREAM_CONNECTION_MAX_BACKOFF);
+
+                    if matches!(err, Error::NotSupported) {
+                        stream_supported = false;
+                    }
+                    tracing::error!("Long connection failed with error {:?}", err);
+                } else {
+                    backoff = STREAM_CONNECTION_BACKOFF;
+                }
+
+                // remove sender to stream, as there is no stream
+                let _ = instance.stream_ctrl.write().take();
+            }
+
+            if poll_supported {
+                let current_subscriptions = {
+                    instance
+                        .remote_subscriptions
+                        .read()
+                        .iter()
+                        .map(|(key, name)| (name.name.clone(), key.clone()))
+                        .collect::<Vec<_>>()
+                };
+
+                if let Err(err) = instance
+                    .transport
+                    .poll(
+                        current_subscriptions,
+                        InternalRelay {
+                            inner: instance.inner_pubsub.clone(),
+                            cached_events: instance.cached_events.clone(),
+                        },
+                    )
+                    .await
+                {
+                    if matches!(err, Error::NotSupported) {
+                        poll_supported = false;
+                    }
+                    tracing::error!("Polling failed with error {:?}", err);
+                }
+
+                sleep(POLL_SLEEP).await;
+            }
+        }
+    }
+
+    /// Unsubscribe from a topic, this is called automatically when RemoteActiveSubscription<T> goes
+    /// out of scope
+    fn unsubscribe(
+        self: &Arc<Self>,
+        subscription_name: <T::Spec as Spec>::SubscriptionId,
+    ) -> Result<(), Error> {
+        let topics = self
+            .subscriptions
+            .write()
+            .remove(&subscription_name)
+            .ok_or(Error::NoSubscription)?;
+
+        let mut remote_subscriptions = self.remote_subscriptions.write();
+
+        for topic in topics {
+            let mut remote_subscription =
+                if let Some(remote_subscription) = remote_subscriptions.remove(&topic) {
+                    remote_subscription
+                } else {
+                    continue;
+                };
+
+            remote_subscription.total_subscribers = remote_subscription
+                .total_subscribers
+                .checked_sub(1)
+                .unwrap_or_default();
+
+            if remote_subscription.total_subscribers == 0 {
+                let mut cached_events = self.cached_events.write();
+
+                cached_events.remove(&topic);
+
+                self.message_to_stream(StreamCtrl::Unsubscribe(remote_subscription.name.clone()))?;
+            } else {
+                remote_subscriptions.insert(topic, remote_subscription);
+            }
+        }
+
+        if remote_subscriptions.is_empty() {
+            self.message_to_stream(StreamCtrl::Stop)?;
+        }
+
+        Ok(())
+    }
+
+    #[inline(always)]
+    fn message_to_stream(&self, message: StreamCtrl<T::Spec>) -> Result<(), Error> {
+        let to_stream = self.stream_ctrl.read();
+
+        if let Some(to_stream) = to_stream.as_ref() {
+            Ok(to_stream.try_send(message)?)
+        } else {
+            Ok(())
+        }
+    }
+
+    /// Creates a subscription
+    ///
+    /// The subscriptions have two parts:
+    ///
+    /// 1. Will create the subscription to the remote Pubsub service, Any events will be moved to
+    ///    the internal pubsub
+    ///
+    /// 2. The internal subscription to the inner Pubsub. Because all subscriptions are going the
+    ///    transport, once events matches subscriptions, the inner_pubsub will receive the message and
+    ///    broadcasat the event.
+    pub fn subscribe<I>(self: &Arc<Self>, request: I) -> Result<RemoteActiveConsumer<T>, Error>
+    where
+        I: SubscriptionRequest<
+            Topic = <T::Spec as Spec>::Topic,
+            SubscriptionId = <T::Spec as Spec>::SubscriptionId,
+        >,
+    {
+        let subscription_name = request.subscription_name();
+        let topics = request.try_get_topics()?;
+
+        let mut remote_subscriptions = self.remote_subscriptions.write();
+        let mut subscriptions = self.subscriptions.write();
+
+        if subscriptions.get(&subscription_name).is_some() {
+            return Err(Error::NoSubscription);
+        }
+
+        let mut previous_messages = Vec::new();
+        let cached_events = self.cached_events.read();
+
+        for topic in topics.iter() {
+            if let Some(subscription) = remote_subscriptions.get_mut(topic) {
+                subscription.total_subscribers += 1;
+
+                if let Some(v) = cached_events.get(topic).cloned() {
+                    previous_messages.push(v);
+                }
+            } else {
+                let internal_sub_name = self.transport.new_name();
+                remote_subscriptions.insert(
+                    topic.clone(),
+                    UniqueSubscription {
+                        total_subscribers: 1,
+                        name: internal_sub_name.clone(),
+                    },
+                );
+
+                // new subscription is created, so the connection worker should be notified
+                self.message_to_stream(StreamCtrl::Subscribe((internal_sub_name, topic.clone())))?;
+            }
+        }
+
+        subscriptions.insert(subscription_name, topics);
+        drop(subscriptions);
+
+        Ok(RemoteActiveConsumer {
+            inner: self.inner_pubsub.subscribe(request)?,
+            previous_messages: previous_messages.into(),
+            consumer: self.clone(),
+        })
+    }
+}
+
+impl<T> Drop for Consumer<T>
+where
+    T: Transport + 'static,
+{
+    fn drop(&mut self) {
+        self.still_running
+            .store(false, std::sync::atomic::Ordering::Release);
+        if let Some(to_stream) = self.stream_ctrl.read().as_ref() {
+            let _ = to_stream.try_send(StreamCtrl::Stop).inspect_err(|err| {
+                tracing::error!("Failed to send message LongPoll::Stop due to {err:?}")
+            });
+        }
+    }
+}
+
+/// Subscribe Message
+pub type SubscribeMessage<S> = (<S as Spec>::SubscriptionId, <S as Spec>::Topic);
+
+/// Messages sent from the [`Consumer`] to the [`Transport`] background loop.
+pub enum StreamCtrl<S>
+where
+    S: Spec + 'static,
+{
+    /// Add a subscription
+    Subscribe(SubscribeMessage<S>),
+    /// Desuscribe
+    Unsubscribe(S::SubscriptionId),
+    /// Exit the loop
+    Stop,
+}
+
+impl<S> Clone for StreamCtrl<S>
+where
+    S: Spec + 'static,
+{
+    fn clone(&self) -> Self {
+        match self {
+            Self::Subscribe(s) => Self::Subscribe(s.clone()),
+            Self::Unsubscribe(u) => Self::Unsubscribe(u.clone()),
+            Self::Stop => Self::Stop,
+        }
+    }
+}
+
+/// Transport abstracts how the consumer talks to the remote pubsub.
+///
+/// Implement this on your HTTP/WebSocket client. The transport is responsible for:
+/// - creating unique subscription names,
+/// - keeping a long connection via `stream` **or** performing on-demand `poll`,
+/// - forwarding remote events to `InternalRelay`.
+///
+/// ```ignore
+/// struct WsTransport { /* ... */ }
+/// #[async_trait::async_trait]
+/// impl Transport for WsTransport {
+///     type Topic = MyTopic;
+///     fn new_name(&self) -> <Self::Topic as Topic>::SubscriptionName { 0 }
+///     async fn stream(/* ... */) -> Result<(), Error> { Ok(()) }
+///     async fn poll(/* ... */) -> Result<(), Error> { Ok(()) }
+/// }
+/// ```
+#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
+#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
+pub trait Transport: Send + Sync {
+    /// Spec
+    type Spec: Spec;
+
+    /// Create a new subscription name
+    fn new_name(&self) -> <Self::Spec as Spec>::SubscriptionId;
+
+    /// Opens a persistent connection and continuously streams events.
+    /// For protocols that support server push (e.g. WebSocket, SSE).
+    async fn stream(
+        &self,
+        subscribe_changes: mpsc::Receiver<StreamCtrl<Self::Spec>>,
+        topics: Vec<SubscribeMessage<Self::Spec>>,
+        reply_to: InternalRelay<Self::Spec>,
+    ) -> Result<(), Error>;
+
+    /// Performs a one-shot fetch of any currently available events.
+    /// Called repeatedly by the consumer when streaming is not available.
+    async fn poll(
+        &self,
+        topics: Vec<SubscribeMessage<Self::Spec>>,
+        reply_to: InternalRelay<Self::Spec>,
+    ) -> Result<(), Error>;
+}
+
+#[cfg(test)]
+mod tests {
+    use std::sync::atomic::{AtomicUsize, Ordering};
+    use std::sync::Arc;
+
+    use tokio::sync::{mpsc, Mutex};
+    use tokio::time::{timeout, Duration};
+
+    use super::{
+        InternalRelay, RemoteActiveConsumer, StreamCtrl, SubscribeMessage, Transport,
+        INTERNAL_POLL_SIZE,
+    };
+    use crate::pub_sub::remote_consumer::Consumer;
+    use crate::pub_sub::test::{CustomPubSub, IndexTest, Message};
+    use crate::pub_sub::{Error, Spec, SubscriptionRequest};
+
+    // ===== Test Event/Topic types =====
+
+    #[derive(Clone, Debug)]
+    enum SubscriptionReq {
+        Foo(String, u64),
+        Bar(String, u64),
+    }
+
+    impl SubscriptionRequest for SubscriptionReq {
+        type Topic = IndexTest;
+
+        type SubscriptionId = String;
+
+        fn try_get_topics(&self) -> Result<Vec<Self::Topic>, Error> {
+            Ok(vec![match self {
+                SubscriptionReq::Foo(_, n) => IndexTest::Foo(*n),
+                SubscriptionReq::Bar(_, n) => IndexTest::Bar(*n),
+            }])
+        }
+
+        fn subscription_name(&self) -> Arc<Self::SubscriptionId> {
+            Arc::new(match self {
+                SubscriptionReq::Foo(n, _) => n.to_string(),
+                SubscriptionReq::Bar(n, _) => n.to_string(),
+            })
+        }
+    }
+
+    // ===== A controllable in-memory Transport used by tests =====
+
+    /// TestTransport relays messages from a broadcast channel to the Consumer via `InternalRelay`.
+    /// It also forwards Subscribe/Unsubscribe/Stop signals to an observer channel so tests can assert them.
+    struct TestTransport {
+        name_ctr: AtomicUsize,
+        // We forward all transport-loop control messages here so tests can observe them.
+        observe_ctrl_tx: mpsc::Sender<StreamCtrl<CustomPubSub>>,
+        // Whether stream / poll are supported.
+        support_long: bool,
+        support_poll: bool,
+        rx: Mutex<mpsc::Receiver<Message>>,
+    }
+
+    impl TestTransport {
+        fn new(
+            support_long: bool,
+            support_poll: bool,
+        ) -> (
+            Self,
+            mpsc::Sender<Message>,
+            mpsc::Receiver<StreamCtrl<CustomPubSub>>,
+        ) {
+            let (events_tx, rx) = mpsc::channel::<Message>(INTERNAL_POLL_SIZE);
+            let (observe_ctrl_tx, observe_ctrl_rx) =
+                mpsc::channel::<StreamCtrl<_>>(INTERNAL_POLL_SIZE);
+
+            let t = TestTransport {
+                name_ctr: AtomicUsize::new(1),
+                rx: Mutex::new(rx),
+                observe_ctrl_tx,
+                support_long,
+                support_poll,
+            };
+
+            (t, events_tx, observe_ctrl_rx)
+        }
+    }
+
+    #[async_trait::async_trait]
+    impl Transport for TestTransport {
+        type Spec = CustomPubSub;
+
+        fn new_name(&self) -> <Self::Spec as Spec>::SubscriptionId {
+            format!("sub-{}", self.name_ctr.fetch_add(1, Ordering::Relaxed))
+        }
+
+        async fn stream(
+            &self,
+            mut subscribe_changes: mpsc::Receiver<StreamCtrl<Self::Spec>>,
+            topics: Vec<SubscribeMessage<Self::Spec>>,
+            reply_to: InternalRelay<Self::Spec>,
+        ) -> Result<(), Error> {
+            if !self.support_long {
+                return Err(Error::NotSupported);
+            }
+
+            // Each invocation creates a fresh broadcast receiver
+            let mut rx = self.rx.lock().await;
+            let observe = self.observe_ctrl_tx.clone();
+
+            for topic in topics {
+                observe.try_send(StreamCtrl::Subscribe(topic)).unwrap();
+            }
+
+            loop {
+                tokio::select! {
+                    // Forward any control (Subscribe/Unsubscribe/Stop) messages so the test can assert them.
+                    Some(ctrl) = subscribe_changes.recv() => {
+                        observe.try_send(ctrl.clone()).unwrap();
+                        if matches!(ctrl, StreamCtrl::Stop) {
+                            break;
+                        }
+                    }
+                    // Relay external events into the inner pubsub
+                    Some(msg) = rx.recv() => {
+                        reply_to.send(msg);
+                    }
+                }
+            }
+
+            Ok(())
+        }
+
+        async fn poll(
+            &self,
+            _topics: Vec<SubscribeMessage<Self::Spec>>,
+            reply_to: InternalRelay<Self::Spec>,
+        ) -> Result<(), Error> {
+            if !self.support_poll {
+                return Err(Error::NotSupported);
+            }
+
+            // On each poll call, drain anything currently pending and return.
+            // (The Consumer calls this repeatedly; first call happens immediately.)
+            let mut rx = self.rx.lock().await;
+            // Non-blocking drain pass: try a few times without sleeping to keep tests snappy
+            for _ in 0..32 {
+                match rx.try_recv() {
+                    Ok(msg) => reply_to.send(msg),
+                    Err(mpsc::error::TryRecvError::Empty) => continue,
+                    Err(mpsc::error::TryRecvError::Disconnected) => break,
+                }
+            }
+            Ok(())
+        }
+    }
+
+    // ===== Helpers =====
+
+    async fn recv_next<T: Transport>(
+        sub: &mut RemoteActiveConsumer<T>,
+        dur_ms: u64,
+    ) -> Option<<T::Spec as Spec>::Event> {
+        timeout(Duration::from_millis(dur_ms), sub.recv())
+            .await
+            .ok()
+            .flatten()
+    }
+
+    async fn expect_ctrl(
+        rx: &mut mpsc::Receiver<StreamCtrl<CustomPubSub>>,
+        dur_ms: u64,
+        pred: impl Fn(&StreamCtrl<CustomPubSub>) -> bool,
+    ) -> StreamCtrl<CustomPubSub> {
+        timeout(Duration::from_millis(dur_ms), async {
+            loop {
+                if let Some(msg) = rx.recv().await {
+                    if pred(&msg) {
+                        break msg;
+                    }
+                }
+            }
+        })
+        .await
+        .expect("timed out waiting for control message")
+    }
+
+    // ===== Tests =====
+
+    #[tokio::test]
+    async fn stream_delivery_and_unsubscribe_on_drop() {
+        // stream supported, poll supported (doesn't matter; prefer long)
+        let (transport, events_tx, mut ctrl_rx) = TestTransport::new(true, true);
+
+        // prefer_polling = false so connection loop will try stream first.
+        let consumer = Consumer::new(transport, false, ());
+
+        // Subscribe to Foo(7)
+        let mut sub = consumer
+            .subscribe(SubscriptionReq::Foo("t".to_owned(), 7))
+            .expect("subscribe ok");
+
+        // We should see a Subscribe(name, topic) forwarded to transport
+        let ctrl = expect_ctrl(
+            &mut ctrl_rx,
+            1000,
+            |m| matches!(m, StreamCtrl::Subscribe((_, idx)) if *idx == IndexTest::Foo(7)),
+        )
+        .await;
+        match ctrl {
+            StreamCtrl::Subscribe((name, idx)) => {
+                assert_ne!(name, "t".to_owned());
+                assert_eq!(idx, IndexTest::Foo(7));
+            }
+            _ => unreachable!(),
+        }
+
+        // Send an event that matches Foo(7)
+        events_tx.send(Message { foo: 7, bar: 1 }).await.unwrap();
+        let got = recv_next::<TestTransport>(&mut sub, 1000)
+            .await
+            .expect("got event");
+        assert_eq!(got, Message { foo: 7, bar: 1 });
+
+        // Dropping the RemoteActiveConsumer should trigger an Unsubscribe(name)
+        drop(sub);
+        let _ctrl = expect_ctrl(&mut ctrl_rx, 1000, |m| {
+            matches!(m, StreamCtrl::Unsubscribe(_))
+        })
+        .await;
+
+        // Drop the Consumer -> Stop is sent so the transport loop exits cleanly
+        drop(consumer);
+        let _ = expect_ctrl(&mut ctrl_rx, 1000, |m| matches!(m, StreamCtrl::Stop)).await;
+    }
+
+    #[tokio::test]
+    async fn test_cache_and_invalation() {
+        // stream supported, poll supported (doesn't matter; prefer long)
+        let (transport, events_tx, mut ctrl_rx) = TestTransport::new(true, true);
+
+        // prefer_polling = false so connection loop will try stream first.
+        let consumer = Consumer::new(transport, false, ());
+
+        // Subscribe to Foo(7)
+        let mut sub_1 = consumer
+            .subscribe(SubscriptionReq::Foo("t".to_owned(), 7))
+            .expect("subscribe ok");
+
+        // We should see a Subscribe(name, topic) forwarded to transport
+        let ctrl = expect_ctrl(
+            &mut ctrl_rx,
+            1000,
+            |m| matches!(m, StreamCtrl::Subscribe((_, idx)) if *idx == IndexTest::Foo(7)),
+        )
+        .await;
+        match ctrl {
+            StreamCtrl::Subscribe((name, idx)) => {
+                assert_ne!(name, "t1".to_owned());
+                assert_eq!(idx, IndexTest::Foo(7));
+            }
+            _ => unreachable!(),
+        }
+
+        // Send an event that matches Foo(7)
+        events_tx.send(Message { foo: 7, bar: 1 }).await.unwrap();
+        let got = recv_next::<TestTransport>(&mut sub_1, 1000)
+            .await
+            .expect("got event");
+        assert_eq!(got, Message { foo: 7, bar: 1 });
+
+        // Subscribe to Foo(7), should receive the latest message and future messages
+        let mut sub_2 = consumer
+            .subscribe(SubscriptionReq::Foo("t2".to_owned(), 7))
+            .expect("subscribe ok");
+
+        let got = recv_next::<TestTransport>(&mut sub_2, 1000)
+            .await
+            .expect("got event");
+        assert_eq!(got, Message { foo: 7, bar: 1 });
+
+        // Dropping the RemoteActiveConsumer but not unsubscribe, since sub_2 is still active
+        drop(sub_1);
+
+        // Subscribe to Foo(7), should receive the latest message and future messages
+        let mut sub_3 = consumer
+            .subscribe(SubscriptionReq::Foo("t3".to_owned(), 7))
+            .expect("subscribe ok");
+
+        // receive cache message
+        let got = recv_next::<TestTransport>(&mut sub_3, 1000)
+            .await
+            .expect("got event");
+        assert_eq!(got, Message { foo: 7, bar: 1 });
+
+        // Send an event that matches Foo(7)
+        events_tx.send(Message { foo: 7, bar: 2 }).await.unwrap();
+
+        // receive new message
+        let got = recv_next::<TestTransport>(&mut sub_2, 1000)
+            .await
+            .expect("got event");
+        assert_eq!(got, Message { foo: 7, bar: 2 });
+
+        let got = recv_next::<TestTransport>(&mut sub_3, 1000)
+            .await
+            .expect("got event");
+        assert_eq!(got, Message { foo: 7, bar: 2 });
+
+        drop(sub_2);
+        drop(sub_3);
+
+        let _ctrl = expect_ctrl(&mut ctrl_rx, 1000, |m| {
+            matches!(m, StreamCtrl::Unsubscribe(_))
+        })
+        .await;
+
+        // The cache should be dropped, so no new messages
+        let mut sub_4 = consumer
+            .subscribe(SubscriptionReq::Foo("t4".to_owned(), 7))
+            .expect("subscribe ok");
+
+        assert!(
+            recv_next::<TestTransport>(&mut sub_4, 1000).await.is_none(),
+            "Should have not receive any update"
+        );
+
+        drop(sub_4);
+
+        // Drop the Consumer -> Stop is sent so the transport loop exits cleanly
+        let _ = expect_ctrl(&mut ctrl_rx, 2000, |m| matches!(m, StreamCtrl::Stop)).await;
+    }
+
+    #[tokio::test]
+    async fn falls_back_to_poll_when_stream_not_supported() {
+        // stream NOT supported, poll supported
+        let (transport, events_tx, _) = TestTransport::new(false, true);
+        // prefer_polling = true nudges the connection loop to poll first, but even if it
+        // tried stream, our transport returns NotSupported and the loop will use poll.
+        let consumer = Consumer::new(transport, true, ());
+
+        // Subscribe to Bar(5)
+        let mut sub = consumer
+            .subscribe(SubscriptionReq::Bar("t".to_owned(), 5))
+            .expect("subscribe ok");
+
+        // Inject an event; the poll path should relay it on the first poll iteration
+        events_tx.send(Message { foo: 9, bar: 5 }).await.unwrap();
+        let got = recv_next::<TestTransport>(&mut sub, 1500)
+            .await
+            .expect("event relayed via polling");
+        assert_eq!(got, Message { foo: 9, bar: 5 });
+    }
+
+    #[tokio::test]
+    async fn multiple_subscribers_share_single_remote_subscription() {
+        // This validates the "coalescing" behavior in Consumer::subscribe where multiple local
+        // subscribers to the same Topic should only create one remote subscription.
+        let (transport, events_tx, mut ctrl_rx) = TestTransport::new(true, true);
+        let consumer = Consumer::new(transport, false, ());
+
+        // Two local subscriptions to the SAME topic/name pair (different names)
+        let mut a = consumer
+            .subscribe(SubscriptionReq::Foo("t".to_owned(), 1))
+            .expect("subscribe A");
+        let _ = expect_ctrl(
+            &mut ctrl_rx,
+            1000,
+            |m| matches!(m, StreamCtrl::Subscribe((_, idx)) if  *idx == IndexTest::Foo(1)),
+        )
+        .await;
+
+        let mut b = consumer
+            .subscribe(SubscriptionReq::Foo("b".to_owned(), 1))
+            .expect("subscribe B");
+
+        // No second Subscribe should be forwarded for the same topic (coalesced).
+        // Give a little time; if one appears, we'll fail explicitly.
+        if let Ok(Some(StreamCtrl::Subscribe((_, idx)))) =
+            timeout(Duration::from_millis(400), ctrl_rx.recv()).await
+        {
+            assert_ne!(idx, IndexTest::Foo(1), "should not resubscribe same topic");
+        }
+
+        // Send one event and ensure BOTH local subscribers receive it.
+        events_tx.send(Message { foo: 1, bar: 42 }).await.unwrap();
+        let got_a = recv_next::<TestTransport>(&mut a, 1000)
+            .await
+            .expect("A got");
+        let got_b = recv_next::<TestTransport>(&mut b, 1000)
+            .await
+            .expect("B got");
+        assert_eq!(got_a, Message { foo: 1, bar: 42 });
+        assert_eq!(got_b, Message { foo: 1, bar: 42 });
+
+        // Drop B: no Unsubscribe should be sent yet (still one local subscriber).
+        drop(b);
+        if let Ok(Some(StreamCtrl::Unsubscribe(_))) =
+            timeout(Duration::from_millis(400), ctrl_rx.recv()).await
+        {
+            panic!("Should NOT unsubscribe while another local subscriber exists");
+        }
+
+        // Drop A: now remote unsubscribe should occur.
+        drop(a);
+        let _ = expect_ctrl(&mut ctrl_rx, 1000, |m| {
+            matches!(m, StreamCtrl::Unsubscribe(_))
+        })
+        .await;
+
+        let _ = expect_ctrl(&mut ctrl_rx, 1000, |m| matches!(m, StreamCtrl::Stop)).await;
+    }
+}

+ 159 - 0
crates/cdk-common/src/pub_sub/subscriber.rs

@@ -0,0 +1,159 @@
+//! Active subscription
+use std::fmt::Debug;
+use std::sync::atomic::AtomicUsize;
+use std::sync::{Arc, Mutex};
+
+use tokio::sync::mpsc;
+
+use super::pubsub::{SubReceiver, TopicTree};
+use super::{Error, Spec};
+
+/// Subscription request
+pub trait SubscriptionRequest {
+    /// Topics
+    type Topic;
+
+    /// Subscription Id
+    type SubscriptionId;
+
+    /// Try to get topics from the request
+    fn try_get_topics(&self) -> Result<Vec<Self::Topic>, Error>;
+
+    /// Get the subscription name
+    fn subscription_name(&self) -> Arc<Self::SubscriptionId>;
+}
+
+/// Active Subscription
+pub struct ActiveSubscription<S>
+where
+    S: Spec + 'static,
+{
+    id: usize,
+    name: Arc<S::SubscriptionId>,
+    active_subscribers: Arc<AtomicUsize>,
+    topics: TopicTree<S>,
+    subscribed_to: Vec<S::Topic>,
+    receiver: Option<SubReceiver<S>>,
+}
+
+impl<S> ActiveSubscription<S>
+where
+    S: Spec + 'static,
+{
+    /// Creates a new instance
+    pub fn new(
+        id: usize,
+        name: Arc<S::SubscriptionId>,
+        active_subscribers: Arc<AtomicUsize>,
+        topics: TopicTree<S>,
+        subscribed_to: Vec<S::Topic>,
+        receiver: Option<SubReceiver<S>>,
+    ) -> Self {
+        Self {
+            id,
+            name,
+            active_subscribers,
+            subscribed_to,
+            topics,
+            receiver,
+        }
+    }
+
+    /// Receives the next event
+    pub async fn recv(&mut self) -> Option<S::Event> {
+        self.receiver.as_mut()?.recv().await.map(|(_, event)| event)
+    }
+
+    /// Try receive an event or return Noen right away
+    pub fn try_recv(&mut self) -> Option<S::Event> {
+        self.receiver
+            .as_mut()?
+            .try_recv()
+            .ok()
+            .map(|(_, event)| event)
+    }
+
+    /// Get the subscription name
+    pub fn name(&self) -> &S::SubscriptionId {
+        &self.name
+    }
+}
+
+impl<S> Drop for ActiveSubscription<S>
+where
+    S: Spec + 'static,
+{
+    fn drop(&mut self) {
+        // remove the listener
+        let mut topics = self.topics.write();
+        for index in self.subscribed_to.drain(..) {
+            topics.remove(&(index, self.id));
+        }
+
+        // decrement the number of active subscribers
+        self.active_subscribers
+            .fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
+    }
+}
+
+/// Lightweight sink used by producers to send events to subscribers.
+///
+/// You usually do not construct a `Subscriber` directly — it is provided to you in
+/// the [`Spec::fetch_events`] callback so you can backfill a new subscription.
+#[derive(Debug)]
+pub struct Subscriber<S>
+where
+    S: Spec + 'static,
+{
+    subscription: Arc<S::SubscriptionId>,
+    inner: mpsc::Sender<(Arc<S::SubscriptionId>, S::Event)>,
+    latest: Arc<Mutex<Option<S::Event>>>,
+}
+
+impl<S> Clone for Subscriber<S>
+where
+    S: Spec + 'static,
+{
+    fn clone(&self) -> Self {
+        Self {
+            subscription: self.subscription.clone(),
+            inner: self.inner.clone(),
+            latest: self.latest.clone(),
+        }
+    }
+}
+
+impl<S> Subscriber<S>
+where
+    S: Spec + 'static,
+{
+    /// Create a new instance
+    pub fn new(
+        subscription: Arc<S::SubscriptionId>,
+        inner: &mpsc::Sender<(Arc<S::SubscriptionId>, S::Event)>,
+    ) -> Self {
+        Self {
+            inner: inner.clone(),
+            subscription,
+            latest: Arc::new(Mutex::new(None)),
+        }
+    }
+
+    /// Send a message
+    pub fn send(&self, event: S::Event) {
+        let mut latest = if let Ok(reader) = self.latest.lock() {
+            reader
+        } else {
+            let _ = self.inner.try_send((self.subscription.to_owned(), event));
+            return;
+        };
+
+        if let Some(last_event) = latest.replace(event.clone()) {
+            if last_event == event {
+                return;
+            }
+        }
+
+        let _ = self.inner.try_send((self.subscription.to_owned(), event));
+    }
+}

+ 80 - 0
crates/cdk-common/src/pub_sub/types.rs

@@ -0,0 +1,80 @@
+//! Pubsub Event definition
+//!
+//! The Pubsub Event defines the Topic struct and how an event can be converted to Topics.
+
+use std::hash::Hash;
+use std::sync::Arc;
+
+use serde::de::DeserializeOwned;
+use serde::Serialize;
+
+use super::Subscriber;
+
+/// Pubsub settings
+#[async_trait::async_trait]
+pub trait Spec: Send + Sync {
+    /// Topic
+    type Topic: Send
+        + Sync
+        + Clone
+        + Eq
+        + PartialEq
+        + Ord
+        + PartialOrd
+        + Hash
+        + Send
+        + Sync
+        + DeserializeOwned
+        + Serialize;
+
+    /// Event
+    type Event: Event<Topic = Self::Topic>
+        + Send
+        + Sync
+        + Eq
+        + PartialEq
+        + DeserializeOwned
+        + Serialize;
+
+    /// Subscription Id
+    type SubscriptionId: Clone
+        + Default
+        + Eq
+        + PartialEq
+        + Ord
+        + PartialOrd
+        + Hash
+        + Send
+        + Sync
+        + DeserializeOwned
+        + Serialize;
+
+    /// Create a new context
+    type Context;
+
+    /// Create a new instance from a given context
+    fn new_instance(context: Self::Context) -> Arc<Self>
+    where
+        Self: Sized;
+
+    /// Callback function that is called on new subscriptions, to back-fill optionally the previous
+    /// events
+    async fn fetch_events(
+        self: &Arc<Self>,
+        topics: Vec<<Self::Event as Event>::Topic>,
+        reply_to: Subscriber<Self>,
+    ) where
+        Self: Sized;
+}
+
+/// Event trait
+pub trait Event: Clone + Send + Sync + Eq + PartialEq + DeserializeOwned + Serialize {
+    /// Generic Topic
+    ///
+    /// It should be serializable/deserializable to be stored in the database layer and it should
+    /// also be sorted in a BTree for in-memory matching
+    type Topic;
+
+    /// To topics
+    fn get_topics(&self) -> Vec<Self::Topic>;
+}

+ 91 - 74
crates/cdk-common/src/subscription.rs

@@ -1,98 +1,115 @@
 //! Subscription types and traits
-#[cfg(feature = "mint")]
+use std::ops::Deref;
 use std::str::FromStr;
+use std::sync::Arc;
 
-use cashu::nut17::{self};
-#[cfg(feature = "mint")]
-use cashu::nut17::{Error, Kind, Notification};
-#[cfg(feature = "mint")]
+use cashu::nut17::{self, Kind, NotificationId};
 use cashu::quote_id::QuoteId;
-#[cfg(feature = "mint")]
-use cashu::{NotificationPayload, PublicKey};
-#[cfg(feature = "mint")]
+use cashu::PublicKey;
 use serde::{Deserialize, Serialize};
 
-#[cfg(feature = "mint")]
-use crate::pub_sub::index::{Index, Indexable, SubscriptionGlobalId};
-use crate::pub_sub::SubId;
+use crate::pub_sub::{Error, SubscriptionRequest};
 
-/// Subscription parameters.
+/// CDK/Mint Subscription parameters.
 ///
 /// This is a concrete type alias for `nut17::Params<SubId>`.
-pub type Params = nut17::Params<SubId>;
+pub type Params = nut17::Params<Arc<SubId>>;
 
-/// Wrapper around `nut17::Params` to implement `Indexable` for `Notification`.
-#[cfg(feature = "mint")]
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct IndexableParams(Params);
+impl SubscriptionRequest for Params {
+    type Topic = NotificationId<QuoteId>;
 
-#[cfg(feature = "mint")]
-impl From<Params> for IndexableParams {
-    fn from(params: Params) -> Self {
-        Self(params)
+    type SubscriptionId = SubId;
+
+    fn subscription_name(&self) -> Arc<Self::SubscriptionId> {
+        self.id.clone()
+    }
+
+    fn try_get_topics(&self) -> Result<Vec<Self::Topic>, Error> {
+        self.filters
+            .iter()
+            .map(|filter| match self.kind {
+                Kind::Bolt11MeltQuote => QuoteId::from_str(filter)
+                    .map(NotificationId::MeltQuoteBolt11)
+                    .map_err(|_| Error::ParsingError(filter.to_owned())),
+                Kind::Bolt11MintQuote => QuoteId::from_str(filter)
+                    .map(NotificationId::MintQuoteBolt11)
+                    .map_err(|_| Error::ParsingError(filter.to_owned())),
+                Kind::ProofState => PublicKey::from_str(filter)
+                    .map(NotificationId::ProofState)
+                    .map_err(|_| Error::ParsingError(filter.to_owned())),
+
+                Kind::Bolt12MintQuote => QuoteId::from_str(filter)
+                    .map(NotificationId::MintQuoteBolt12)
+                    .map_err(|_| Error::ParsingError(filter.to_owned())),
+            })
+            .collect::<Result<Vec<_>, _>>()
     }
 }
 
-#[cfg(feature = "mint")]
-impl TryFrom<IndexableParams> for Vec<Index<Notification>> {
-    type Error = Error;
-    fn try_from(params: IndexableParams) -> Result<Self, Self::Error> {
-        let sub_id: SubscriptionGlobalId = Default::default();
-        let params = params.0;
-        params
-            .filters
-            .into_iter()
+/// Subscriptions parameters for the wallet
+///
+/// This is because the Wallet can subscribe to non CDK quotes, where IDs are not constraint to
+/// QuoteId
+pub type WalletParams = nut17::Params<Arc<String>>;
+
+impl SubscriptionRequest for WalletParams {
+    type Topic = NotificationId<String>;
+
+    type SubscriptionId = String;
+
+    fn subscription_name(&self) -> Arc<Self::SubscriptionId> {
+        self.id.clone()
+    }
+
+    fn try_get_topics(&self) -> Result<Vec<Self::Topic>, Error> {
+        self.filters
+            .iter()
             .map(|filter| {
-                let idx = match params.kind {
-                    Kind::Bolt11MeltQuote => {
-                        Notification::MeltQuoteBolt11(QuoteId::from_str(&filter)?)
-                    }
-                    Kind::Bolt11MintQuote => {
-                        Notification::MintQuoteBolt11(QuoteId::from_str(&filter)?)
-                    }
-                    Kind::ProofState => Notification::ProofState(PublicKey::from_str(&filter)?),
-                    Kind::Bolt12MintQuote => {
-                        Notification::MintQuoteBolt12(QuoteId::from_str(&filter)?)
-                    }
-                };
-
-                Ok(Index::from((idx, params.id.clone(), sub_id)))
+                Ok(match self.kind {
+                    Kind::Bolt11MeltQuote => NotificationId::MeltQuoteBolt11(filter.to_owned()),
+                    Kind::Bolt11MintQuote => NotificationId::MintQuoteBolt11(filter.to_owned()),
+                    Kind::ProofState => PublicKey::from_str(filter)
+                        .map(NotificationId::ProofState)
+                        .map_err(|_| Error::ParsingError(filter.to_owned()))?,
+
+                    Kind::Bolt12MintQuote => NotificationId::MintQuoteBolt12(filter.to_owned()),
+                })
             })
-            .collect::<Result<_, _>>()
+            .collect::<Result<Vec<_>, _>>()
+    }
+}
+
+/// Subscription Id wrapper
+///
+/// This is the place to add some sane default (like a max length) to the
+/// subscription ID
+#[derive(Debug, Clone, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
+pub struct SubId(String);
+
+impl From<&str> for SubId {
+    fn from(s: &str) -> Self {
+        Self(s.to_string())
     }
 }
 
-#[cfg(feature = "mint")]
-impl AsRef<SubId> for IndexableParams {
-    fn as_ref(&self) -> &SubId {
-        &self.0.id
+impl From<String> for SubId {
+    fn from(s: String) -> Self {
+        Self(s)
     }
 }
 
-#[cfg(feature = "mint")]
-impl Indexable for NotificationPayload<QuoteId> {
-    type Type = Notification;
-
-    fn to_indexes(&self) -> Vec<Index<Self::Type>> {
-        match self {
-            NotificationPayload::ProofState(proof_state) => {
-                vec![Index::from(Notification::ProofState(proof_state.y))]
-            }
-            NotificationPayload::MeltQuoteBolt11Response(melt_quote) => {
-                vec![Index::from(Notification::MeltQuoteBolt11(
-                    melt_quote.quote.clone(),
-                ))]
-            }
-            NotificationPayload::MintQuoteBolt11Response(mint_quote) => {
-                vec![Index::from(Notification::MintQuoteBolt11(
-                    mint_quote.quote.clone(),
-                ))]
-            }
-            NotificationPayload::MintQuoteBolt12Response(mint_quote) => {
-                vec![Index::from(Notification::MintQuoteBolt12(
-                    mint_quote.quote.clone(),
-                ))]
-            }
-        }
+impl FromStr for SubId {
+    type Err = ();
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Ok(Self(s.to_string()))
+    }
+}
+
+impl Deref for SubId {
+    type Target = String;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
     }
 }

+ 8 - 1
crates/cdk-common/src/wallet.rs

@@ -204,6 +204,10 @@ pub struct Transaction {
     pub metadata: HashMap<String, String>,
     /// Quote ID if this is a mint or melt transaction
     pub quote_id: Option<String>,
+    /// Payment request (e.g., BOLT11 invoice, BOLT12 offer)
+    pub payment_request: Option<String>,
+    /// Payment proof (e.g., preimage for Lightning melt transactions)
+    pub payment_proof: Option<String>,
 }
 
 impl Transaction {
@@ -246,7 +250,10 @@ impl PartialOrd for Transaction {
 
 impl Ord for Transaction {
     fn cmp(&self, other: &Self) -> std::cmp::Ordering {
-        self.timestamp.cmp(&other.timestamp).reverse()
+        self.timestamp
+            .cmp(&other.timestamp)
+            .reverse()
+            .then_with(|| self.id().cmp(&other.id()))
     }
 }
 

+ 3 - 1
crates/cdk-common/src/ws.rs

@@ -2,6 +2,8 @@
 //!
 //! This module extends the `cashu` crate with types and functions for the CDK, using the correct
 //! expected ID types.
+use std::sync::Arc;
+
 #[cfg(feature = "mint")]
 use cashu::nut17::ws::JSON_RPC_VERSION;
 use cashu::nut17::{self};
@@ -10,7 +12,7 @@ use cashu::quote_id::QuoteId;
 #[cfg(feature = "mint")]
 use cashu::NotificationPayload;
 
-use crate::pub_sub::SubId;
+type SubId = Arc<crate::subscription::SubId>;
 
 /// Request to unsubscribe from a websocket subscription
 pub type WsUnsubscribeRequest = nut17::ws::WsUnsubscribeRequest<SubId>;

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

+ 7 - 3
crates/cdk-ffi/Cargo.toml

@@ -17,11 +17,11 @@ async-trait = { workspace = true }
 bip39 = { workspace = true }
 cdk = { workspace = true, default-features = false, features = ["wallet", "auth", "bip353"] }
 cdk-sqlite = { workspace = true }
-ctor = "0.2"
+cdk-postgres = { workspace = true, optional = true }
 futures = { workspace = true }
 once_cell = { workspace = true }
 rand = { workspace = true }
-serde = { workspace = true, features = ["derive"] }
+serde = { workspace = true, features = ["derive", "rc"] }
 serde_json = { workspace = true }
 thiserror = { workspace = true }
 tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] }
@@ -30,9 +30,13 @@ 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]]
 name = "uniffi-bindgen"
 path = "src/bin/uniffi-bindgen.rs"
-

+ 45 - 385
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
@@ -108,6 +109,14 @@ 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,7 +180,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
@@ -465,6 +473,22 @@ 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>,
@@ -556,394 +580,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 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;
 

+ 41 - 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
@@ -226,6 +227,20 @@ impl MultiMintWallet {
         Ok(quote.into())
     }
 
+    /// Check a specific mint quote status
+    pub async fn check_mint_quote(
+        &self,
+        mint_url: MintUrl,
+        quote_id: String,
+    ) -> Result<MintQuote, FfiError> {
+        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
+        let quote = self
+            .inner
+            .check_mint_quote(&cdk_mint_url, &quote_id)
+            .await?;
+        Ok(quote.into())
+    }
+
     /// Mint tokens at a specific mint
     pub async fn mint(
         &self,
@@ -243,6 +258,32 @@ impl MultiMintWallet {
         Ok(proofs.into_iter().map(|p| Arc::new(p.into())).collect())
     }
 
+    /// Wait for a mint quote to be paid and automatically mint the proofs
+    #[cfg(not(target_arch = "wasm32"))]
+    pub async fn wait_for_mint_quote(
+        &self,
+        mint_url: MintUrl,
+        quote_id: String,
+        split_target: SplitTarget,
+        spending_conditions: Option<SpendingConditions>,
+        timeout_secs: u64,
+    ) -> Result<Proofs, FfiError> {
+        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
+        let conditions = spending_conditions.map(|sc| sc.try_into()).transpose()?;
+
+        let proofs = self
+            .inner
+            .wait_for_mint_quote(
+                &cdk_mint_url,
+                &quote_id,
+                split_target.into(),
+                conditions,
+                timeout_secs,
+            )
+            .await?;
+        Ok(proofs.into_iter().map(|p| Arc::new(p.into())).collect())
+    }
+
     /// Get a melt quote from a specific mint
     pub async fn melt_quote(
         &self,

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

@@ -0,0 +1,417 @@
+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 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 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() })
+    }
+}
+
+#[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 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() })
+    }
+}

+ 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()
+    }
+}

+ 0 - 2974
crates/cdk-ffi/src/types.rs

@@ -1,2974 +0,0 @@
-//! FFI-compatible types
-
-use std::collections::HashMap;
-use std::str::FromStr;
-use std::sync::Mutex;
-
-use cdk::nuts::{CurrencyUnit as CdkCurrencyUnit, State as CdkState};
-use cdk::pub_sub::SubId;
-use cdk::Amount as CdkAmount;
-use serde::{Deserialize, Serialize};
-
-use crate::error::FfiError;
-
-/// FFI-compatible Amount type
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
-#[serde(transparent)]
-pub struct Amount {
-    pub value: u64,
-}
-
-impl Amount {
-    pub fn new(value: u64) -> Self {
-        Self { value }
-    }
-
-    pub fn zero() -> Self {
-        Self { value: 0 }
-    }
-
-    pub fn is_zero(&self) -> bool {
-        self.value == 0
-    }
-
-    pub fn convert_unit(
-        &self,
-        current_unit: CurrencyUnit,
-        target_unit: CurrencyUnit,
-    ) -> Result<Amount, FfiError> {
-        Ok(CdkAmount::from(self.value)
-            .convert_unit(&current_unit.into(), &target_unit.into())
-            .map(Into::into)?)
-    }
-
-    pub fn add(&self, other: Amount) -> Result<Amount, FfiError> {
-        let self_amount = CdkAmount::from(self.value);
-        let other_amount = CdkAmount::from(other.value);
-        self_amount
-            .checked_add(other_amount)
-            .map(Into::into)
-            .ok_or(FfiError::AmountOverflow)
-    }
-
-    pub fn subtract(&self, other: Amount) -> Result<Amount, FfiError> {
-        let self_amount = CdkAmount::from(self.value);
-        let other_amount = CdkAmount::from(other.value);
-        self_amount
-            .checked_sub(other_amount)
-            .map(Into::into)
-            .ok_or(FfiError::AmountOverflow)
-    }
-
-    pub fn multiply(&self, factor: u64) -> Result<Amount, FfiError> {
-        let self_amount = CdkAmount::from(self.value);
-        let factor_amount = CdkAmount::from(factor);
-        self_amount
-            .checked_mul(factor_amount)
-            .map(Into::into)
-            .ok_or(FfiError::AmountOverflow)
-    }
-
-    pub fn divide(&self, divisor: u64) -> Result<Amount, FfiError> {
-        if divisor == 0 {
-            return Err(FfiError::DivisionByZero);
-        }
-        let self_amount = CdkAmount::from(self.value);
-        let divisor_amount = CdkAmount::from(divisor);
-        self_amount
-            .checked_div(divisor_amount)
-            .map(Into::into)
-            .ok_or(FfiError::AmountOverflow)
-    }
-}
-
-impl From<CdkAmount> for Amount {
-    fn from(amount: CdkAmount) -> Self {
-        Self {
-            value: u64::from(amount),
-        }
-    }
-}
-
-impl From<Amount> for CdkAmount {
-    fn from(amount: Amount) -> Self {
-        CdkAmount::from(amount.value)
-    }
-}
-
-/// FFI-compatible Currency Unit
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
-pub enum CurrencyUnit {
-    Sat,
-    Msat,
-    Usd,
-    Eur,
-    Auth,
-    Custom { unit: String },
-}
-
-impl From<CdkCurrencyUnit> for CurrencyUnit {
-    fn from(unit: CdkCurrencyUnit) -> Self {
-        match unit {
-            CdkCurrencyUnit::Sat => CurrencyUnit::Sat,
-            CdkCurrencyUnit::Msat => CurrencyUnit::Msat,
-            CdkCurrencyUnit::Usd => CurrencyUnit::Usd,
-            CdkCurrencyUnit::Eur => CurrencyUnit::Eur,
-            CdkCurrencyUnit::Auth => CurrencyUnit::Auth,
-            CdkCurrencyUnit::Custom(s) => CurrencyUnit::Custom { unit: s },
-            _ => CurrencyUnit::Sat, // Default for unknown units
-        }
-    }
-}
-
-impl From<CurrencyUnit> for CdkCurrencyUnit {
-    fn from(unit: CurrencyUnit) -> Self {
-        match unit {
-            CurrencyUnit::Sat => CdkCurrencyUnit::Sat,
-            CurrencyUnit::Msat => CdkCurrencyUnit::Msat,
-            CurrencyUnit::Usd => CdkCurrencyUnit::Usd,
-            CurrencyUnit::Eur => CdkCurrencyUnit::Eur,
-            CurrencyUnit::Auth => CdkCurrencyUnit::Auth,
-            CurrencyUnit::Custom { unit } => CdkCurrencyUnit::Custom(unit),
-        }
-    }
-}
-
-/// FFI-compatible Mint URL
-#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, uniffi::Record)]
-#[serde(transparent)]
-pub struct MintUrl {
-    pub url: String,
-}
-
-impl MintUrl {
-    pub fn new(url: String) -> Result<Self, FfiError> {
-        // Validate URL format
-        url::Url::parse(&url).map_err(|e| FfiError::InvalidUrl { msg: e.to_string() })?;
-
-        Ok(Self { url })
-    }
-}
-
-impl From<cdk::mint_url::MintUrl> for MintUrl {
-    fn from(mint_url: cdk::mint_url::MintUrl) -> Self {
-        Self {
-            url: mint_url.to_string(),
-        }
-    }
-}
-
-impl TryFrom<MintUrl> for cdk::mint_url::MintUrl {
-    type Error = FfiError;
-
-    fn try_from(mint_url: MintUrl) -> Result<Self, Self::Error> {
-        cdk::mint_url::MintUrl::from_str(&mint_url.url)
-            .map_err(|e| FfiError::InvalidUrl { msg: e.to_string() })
-    }
-}
-
-/// FFI-compatible Proof state
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
-pub enum ProofState {
-    Unspent,
-    Pending,
-    Spent,
-    Reserved,
-    PendingSpent,
-}
-
-impl From<CdkState> for ProofState {
-    fn from(state: CdkState) -> Self {
-        match state {
-            CdkState::Unspent => ProofState::Unspent,
-            CdkState::Pending => ProofState::Pending,
-            CdkState::Spent => ProofState::Spent,
-            CdkState::Reserved => ProofState::Reserved,
-            CdkState::PendingSpent => ProofState::PendingSpent,
-        }
-    }
-}
-
-impl From<ProofState> for CdkState {
-    fn from(state: ProofState) -> Self {
-        match state {
-            ProofState::Unspent => CdkState::Unspent,
-            ProofState::Pending => CdkState::Pending,
-            ProofState::Spent => CdkState::Spent,
-            ProofState::Reserved => CdkState::Reserved,
-            ProofState::PendingSpent => CdkState::PendingSpent,
-        }
-    }
-}
-
-/// 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 {
-    /// Memo text
-    pub memo: String,
-    /// Include memo in token
-    pub include_memo: bool,
-}
-
-impl From<SendMemo> for cdk::wallet::SendMemo {
-    fn from(memo: SendMemo) -> Self {
-        cdk::wallet::SendMemo {
-            memo: memo.memo,
-            include_memo: memo.include_memo,
-        }
-    }
-}
-
-impl From<cdk::wallet::SendMemo> for SendMemo {
-    fn from(memo: cdk::wallet::SendMemo) -> Self {
-        Self {
-            memo: memo.memo,
-            include_memo: memo.include_memo,
-        }
-    }
-}
-
-impl SendMemo {
-    /// Convert SendMemo to JSON string
-    pub fn to_json(&self) -> Result<String, FfiError> {
-        Ok(serde_json::to_string(self)?)
-    }
-}
-
-/// Decode SendMemo from JSON string
-#[uniffi::export]
-pub fn decode_send_memo(json: String) -> Result<SendMemo, FfiError> {
-    Ok(serde_json::from_str(&json)?)
-}
-
-/// Encode SendMemo to JSON string
-#[uniffi::export]
-pub fn encode_send_memo(memo: SendMemo) -> Result<String, FfiError> {
-    Ok(serde_json::to_string(&memo)?)
-}
-
-/// FFI-compatible SplitTarget
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)]
-pub enum SplitTarget {
-    /// Default target; least amount of proofs
-    None,
-    /// Target amount for wallet to have most proofs that add up to value
-    Value { amount: Amount },
-    /// Specific amounts to split into (must equal amount being split)
-    Values { amounts: Vec<Amount> },
-}
-
-impl From<SplitTarget> for cdk::amount::SplitTarget {
-    fn from(target: SplitTarget) -> Self {
-        match target {
-            SplitTarget::None => cdk::amount::SplitTarget::None,
-            SplitTarget::Value { amount } => cdk::amount::SplitTarget::Value(amount.into()),
-            SplitTarget::Values { amounts } => {
-                cdk::amount::SplitTarget::Values(amounts.into_iter().map(Into::into).collect())
-            }
-        }
-    }
-}
-
-impl From<cdk::amount::SplitTarget> for SplitTarget {
-    fn from(target: cdk::amount::SplitTarget) -> Self {
-        match target {
-            cdk::amount::SplitTarget::None => SplitTarget::None,
-            cdk::amount::SplitTarget::Value(amount) => SplitTarget::Value {
-                amount: amount.into(),
-            },
-            cdk::amount::SplitTarget::Values(amounts) => SplitTarget::Values {
-                amounts: amounts.into_iter().map(Into::into).collect(),
-            },
-        }
-    }
-}
-
-/// FFI-compatible SendKind
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)]
-pub enum SendKind {
-    /// Allow online swap before send if wallet does not have exact amount
-    OnlineExact,
-    /// Prefer offline send if difference is less than tolerance
-    OnlineTolerance { tolerance: Amount },
-    /// Wallet cannot do an online swap and selected proof must be exactly send amount
-    OfflineExact,
-    /// Wallet must remain offline but can over pay if below tolerance
-    OfflineTolerance { tolerance: Amount },
-}
-
-impl From<SendKind> for cdk::wallet::SendKind {
-    fn from(kind: SendKind) -> Self {
-        match kind {
-            SendKind::OnlineExact => cdk::wallet::SendKind::OnlineExact,
-            SendKind::OnlineTolerance { tolerance } => {
-                cdk::wallet::SendKind::OnlineTolerance(tolerance.into())
-            }
-            SendKind::OfflineExact => cdk::wallet::SendKind::OfflineExact,
-            SendKind::OfflineTolerance { tolerance } => {
-                cdk::wallet::SendKind::OfflineTolerance(tolerance.into())
-            }
-        }
-    }
-}
-
-impl From<cdk::wallet::SendKind> for SendKind {
-    fn from(kind: cdk::wallet::SendKind) -> Self {
-        match kind {
-            cdk::wallet::SendKind::OnlineExact => SendKind::OnlineExact,
-            cdk::wallet::SendKind::OnlineTolerance(tolerance) => SendKind::OnlineTolerance {
-                tolerance: tolerance.into(),
-            },
-            cdk::wallet::SendKind::OfflineExact => SendKind::OfflineExact,
-            cdk::wallet::SendKind::OfflineTolerance(tolerance) => SendKind::OfflineTolerance {
-                tolerance: tolerance.into(),
-            },
-        }
-    }
-}
-
-/// FFI-compatible Send options
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct SendOptions {
-    /// Memo
-    pub memo: Option<SendMemo>,
-    /// Spending conditions
-    pub conditions: Option<SpendingConditions>,
-    /// Amount split target
-    pub amount_split_target: SplitTarget,
-    /// Send kind
-    pub send_kind: SendKind,
-    /// Include fee
-    pub include_fee: bool,
-    /// Maximum number of proofs to include in the token
-    pub max_proofs: Option<u32>,
-    /// Metadata
-    pub metadata: HashMap<String, String>,
-}
-
-impl Default for SendOptions {
-    fn default() -> Self {
-        Self {
-            memo: None,
-            conditions: None,
-            amount_split_target: SplitTarget::None,
-            send_kind: SendKind::OnlineExact,
-            include_fee: false,
-            max_proofs: None,
-            metadata: HashMap::new(),
-        }
-    }
-}
-
-impl From<SendOptions> for cdk::wallet::SendOptions {
-    fn from(opts: SendOptions) -> Self {
-        cdk::wallet::SendOptions {
-            memo: opts.memo.map(Into::into),
-            conditions: opts.conditions.and_then(|c| c.try_into().ok()),
-            amount_split_target: opts.amount_split_target.into(),
-            send_kind: opts.send_kind.into(),
-            include_fee: opts.include_fee,
-            max_proofs: opts.max_proofs.map(|p| p as usize),
-            metadata: opts.metadata,
-        }
-    }
-}
-
-impl From<cdk::wallet::SendOptions> for SendOptions {
-    fn from(opts: cdk::wallet::SendOptions) -> Self {
-        Self {
-            memo: opts.memo.map(Into::into),
-            conditions: opts.conditions.map(Into::into),
-            amount_split_target: opts.amount_split_target.into(),
-            send_kind: opts.send_kind.into(),
-            include_fee: opts.include_fee,
-            max_proofs: opts.max_proofs.map(|p| p as u32),
-            metadata: opts.metadata,
-        }
-    }
-}
-
-impl SendOptions {
-    /// Convert SendOptions to JSON string
-    pub fn to_json(&self) -> Result<String, FfiError> {
-        Ok(serde_json::to_string(self)?)
-    }
-}
-
-/// Decode SendOptions from JSON string
-#[uniffi::export]
-pub fn decode_send_options(json: String) -> Result<SendOptions, FfiError> {
-    Ok(serde_json::from_str(&json)?)
-}
-
-/// Encode SendOptions to JSON string
-#[uniffi::export]
-pub fn encode_send_options(options: SendOptions) -> Result<String, FfiError> {
-    Ok(serde_json::to_string(&options)?)
-}
-
-/// FFI-compatible SecretKey
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-#[serde(transparent)]
-pub struct SecretKey {
-    /// Hex-encoded secret key (64 characters)
-    pub hex: String,
-}
-
-impl SecretKey {
-    /// Create a new SecretKey from hex string
-    pub fn from_hex(hex: String) -> Result<Self, FfiError> {
-        // Validate hex string length (should be 64 characters for 32 bytes)
-        if hex.len() != 64 {
-            return Err(FfiError::InvalidHex {
-                msg: "Secret key hex must be exactly 64 characters (32 bytes)".to_string(),
-            });
-        }
-
-        // Validate hex format
-        if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
-            return Err(FfiError::InvalidHex {
-                msg: "Secret key hex contains invalid characters".to_string(),
-            });
-        }
-
-        Ok(Self { hex })
-    }
-
-    /// Generate a random secret key
-    pub fn random() -> Self {
-        use cdk::nuts::SecretKey as CdkSecretKey;
-        let secret_key = CdkSecretKey::generate();
-        Self {
-            hex: secret_key.to_secret_hex(),
-        }
-    }
-}
-
-impl From<SecretKey> for cdk::nuts::SecretKey {
-    fn from(key: SecretKey) -> Self {
-        // This will panic if hex is invalid, but we validate in from_hex()
-        cdk::nuts::SecretKey::from_hex(&key.hex).expect("Invalid secret key hex")
-    }
-}
-
-impl From<cdk::nuts::SecretKey> for SecretKey {
-    fn from(key: cdk::nuts::SecretKey) -> Self {
-        Self {
-            hex: key.to_secret_hex(),
-        }
-    }
-}
-
-/// FFI-compatible Receive options
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct ReceiveOptions {
-    /// Amount split target
-    pub amount_split_target: SplitTarget,
-    /// P2PK signing keys
-    pub p2pk_signing_keys: Vec<SecretKey>,
-    /// Preimages for HTLC conditions
-    pub preimages: Vec<String>,
-    /// Metadata
-    pub metadata: HashMap<String, String>,
-}
-
-impl Default for ReceiveOptions {
-    fn default() -> Self {
-        Self {
-            amount_split_target: SplitTarget::None,
-            p2pk_signing_keys: Vec::new(),
-            preimages: Vec::new(),
-            metadata: HashMap::new(),
-        }
-    }
-}
-
-impl From<ReceiveOptions> for cdk::wallet::ReceiveOptions {
-    fn from(opts: ReceiveOptions) -> Self {
-        cdk::wallet::ReceiveOptions {
-            amount_split_target: opts.amount_split_target.into(),
-            p2pk_signing_keys: opts.p2pk_signing_keys.into_iter().map(Into::into).collect(),
-            preimages: opts.preimages,
-            metadata: opts.metadata,
-        }
-    }
-}
-
-impl From<cdk::wallet::ReceiveOptions> for ReceiveOptions {
-    fn from(opts: cdk::wallet::ReceiveOptions) -> Self {
-        Self {
-            amount_split_target: opts.amount_split_target.into(),
-            p2pk_signing_keys: opts.p2pk_signing_keys.into_iter().map(Into::into).collect(),
-            preimages: opts.preimages,
-            metadata: opts.metadata,
-        }
-    }
-}
-
-impl ReceiveOptions {
-    /// Convert ReceiveOptions to JSON string
-    pub fn to_json(&self) -> Result<String, FfiError> {
-        Ok(serde_json::to_string(self)?)
-    }
-}
-
-/// Decode ReceiveOptions from JSON string
-#[uniffi::export]
-pub fn decode_receive_options(json: String) -> Result<ReceiveOptions, FfiError> {
-    Ok(serde_json::from_str(&json)?)
-}
-
-/// Encode ReceiveOptions to JSON string
-#[uniffi::export]
-pub fn encode_receive_options(options: ReceiveOptions) -> Result<String, FfiError> {
-    Ok(serde_json::to_string(&options)?)
-}
-
-/// FFI-compatible Proof
-#[derive(Debug, uniffi::Object)]
-pub struct Proof {
-    pub(crate) inner: cdk::nuts::Proof,
-}
-
-impl From<cdk::nuts::Proof> for Proof {
-    fn from(proof: cdk::nuts::Proof) -> Self {
-        Self { inner: proof }
-    }
-}
-
-impl From<Proof> for cdk::nuts::Proof {
-    fn from(proof: Proof) -> Self {
-        proof.inner
-    }
-}
-
-#[uniffi::export]
-impl Proof {
-    /// Get the amount
-    pub fn amount(&self) -> Amount {
-        self.inner.amount.into()
-    }
-
-    /// Get the secret as string
-    pub fn secret(&self) -> String {
-        self.inner.secret.to_string()
-    }
-
-    /// Get the unblinded signature (C) as string
-    pub fn c(&self) -> String {
-        self.inner.c.to_string()
-    }
-
-    /// Get the keyset ID as string
-    pub fn keyset_id(&self) -> String {
-        self.inner.keyset_id.to_string()
-    }
-
-    /// Get the witness
-    pub fn witness(&self) -> Option<Witness> {
-        self.inner.witness.as_ref().map(|w| w.clone().into())
-    }
-
-    /// Check if proof is active with given keyset IDs
-    pub fn is_active(&self, active_keyset_ids: Vec<String>) -> bool {
-        use cdk::nuts::Id;
-        let ids: Vec<Id> = active_keyset_ids
-            .into_iter()
-            .filter_map(|id| Id::from_str(&id).ok())
-            .collect();
-        self.inner.is_active(&ids)
-    }
-
-    /// Get the Y value (hash_to_curve of secret)
-    pub fn y(&self) -> Result<String, FfiError> {
-        Ok(self.inner.y()?.to_string())
-    }
-
-    /// Get the DLEQ proof if present
-    pub fn dleq(&self) -> Option<ProofDleq> {
-        self.inner.dleq.as_ref().map(|d| d.clone().into())
-    }
-
-    /// Check if proof has DLEQ proof
-    pub fn has_dleq(&self) -> bool {
-        self.inner.dleq.is_some()
-    }
-}
-
-/// FFI-compatible Proofs (vector of Proof)
-pub type Proofs = Vec<std::sync::Arc<Proof>>;
-
-/// FFI-compatible DLEQ proof for proofs
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct ProofDleq {
-    /// e value (hex-encoded SecretKey)
-    pub e: String,
-    /// s value (hex-encoded SecretKey)
-    pub s: String,
-    /// r value - blinding factor (hex-encoded SecretKey)
-    pub r: String,
-}
-
-/// FFI-compatible DLEQ proof for blind signatures
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct BlindSignatureDleq {
-    /// e value (hex-encoded SecretKey)
-    pub e: String,
-    /// s value (hex-encoded SecretKey)
-    pub s: String,
-}
-
-impl From<cdk::nuts::ProofDleq> for ProofDleq {
-    fn from(dleq: cdk::nuts::ProofDleq) -> Self {
-        Self {
-            e: dleq.e.to_secret_hex(),
-            s: dleq.s.to_secret_hex(),
-            r: dleq.r.to_secret_hex(),
-        }
-    }
-}
-
-impl From<ProofDleq> for cdk::nuts::ProofDleq {
-    fn from(dleq: ProofDleq) -> Self {
-        Self {
-            e: cdk::nuts::SecretKey::from_hex(&dleq.e).expect("Invalid e hex"),
-            s: cdk::nuts::SecretKey::from_hex(&dleq.s).expect("Invalid s hex"),
-            r: cdk::nuts::SecretKey::from_hex(&dleq.r).expect("Invalid r hex"),
-        }
-    }
-}
-
-impl From<cdk::nuts::BlindSignatureDleq> for BlindSignatureDleq {
-    fn from(dleq: cdk::nuts::BlindSignatureDleq) -> Self {
-        Self {
-            e: dleq.e.to_secret_hex(),
-            s: dleq.s.to_secret_hex(),
-        }
-    }
-}
-
-impl From<BlindSignatureDleq> for cdk::nuts::BlindSignatureDleq {
-    fn from(dleq: BlindSignatureDleq) -> Self {
-        Self {
-            e: cdk::nuts::SecretKey::from_hex(&dleq.e).expect("Invalid e hex"),
-            s: cdk::nuts::SecretKey::from_hex(&dleq.s).expect("Invalid s hex"),
-        }
-    }
-}
-
-/// Helper functions for Proofs
-pub fn proofs_total_amount(proofs: &Proofs) -> Result<Amount, FfiError> {
-    let cdk_proofs: Vec<cdk::nuts::Proof> = proofs.iter().map(|p| p.inner.clone()).collect();
-    use cdk::nuts::ProofsMethods;
-    Ok(cdk_proofs.total_amount()?.into())
-}
-
-/// FFI-compatible MintQuote
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct MintQuote {
-    /// Quote ID
-    pub id: String,
-    /// Quote amount
-    pub amount: Option<Amount>,
-    /// Currency unit
-    pub unit: CurrencyUnit,
-    /// Payment request
-    pub request: String,
-    /// Quote state
-    pub state: QuoteState,
-    /// Expiry timestamp
-    pub expiry: u64,
-    /// Mint URL
-    pub mint_url: MintUrl,
-    /// Amount issued
-    pub amount_issued: Amount,
-    /// Amount paid
-    pub amount_paid: Amount,
-    /// Payment method
-    pub payment_method: PaymentMethod,
-    /// Secret key (optional, hex-encoded)
-    pub secret_key: Option<String>,
-}
-
-impl From<cdk::wallet::MintQuote> for MintQuote {
-    fn from(quote: cdk::wallet::MintQuote) -> Self {
-        Self {
-            id: quote.id.clone(),
-            amount: quote.amount.map(Into::into),
-            unit: quote.unit.clone().into(),
-            request: quote.request.clone(),
-            state: quote.state.into(),
-            expiry: quote.expiry,
-            mint_url: quote.mint_url.clone().into(),
-            amount_issued: quote.amount_issued.into(),
-            amount_paid: quote.amount_paid.into(),
-            payment_method: quote.payment_method.into(),
-            secret_key: quote.secret_key.map(|sk| sk.to_secret_hex()),
-        }
-    }
-}
-
-impl TryFrom<MintQuote> for cdk::wallet::MintQuote {
-    type Error = FfiError;
-
-    fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
-        let secret_key = quote
-            .secret_key
-            .map(|hex| cdk::nuts::SecretKey::from_hex(&hex))
-            .transpose()
-            .map_err(|e| FfiError::InvalidCryptographicKey { msg: e.to_string() })?;
-
-        Ok(Self {
-            id: quote.id,
-            amount: quote.amount.map(Into::into),
-            unit: quote.unit.into(),
-            request: quote.request,
-            state: quote.state.into(),
-            expiry: quote.expiry,
-            mint_url: quote.mint_url.try_into()?,
-            amount_issued: quote.amount_issued.into(),
-            amount_paid: quote.amount_paid.into(),
-            payment_method: quote.payment_method.into(),
-            secret_key,
-        })
-    }
-}
-
-impl MintQuote {
-    /// Get total amount (amount + fees)
-    pub fn total_amount(&self) -> Amount {
-        if let Some(amount) = self.amount {
-            Amount::new(amount.value + self.amount_paid.value - self.amount_issued.value)
-        } else {
-            Amount::zero()
-        }
-    }
-
-    /// Check if quote is expired
-    pub fn is_expired(&self, current_time: u64) -> bool {
-        current_time > self.expiry
-    }
-
-    /// Get amount that can be minted
-    pub fn amount_mintable(&self) -> Amount {
-        Amount::new(self.amount_paid.value - self.amount_issued.value)
-    }
-
-    /// Convert MintQuote to JSON string
-    pub fn to_json(&self) -> Result<String, FfiError> {
-        Ok(serde_json::to_string(self)?)
-    }
-}
-
-/// Decode MintQuote from JSON string
-#[uniffi::export]
-pub fn decode_mint_quote(json: String) -> Result<MintQuote, FfiError> {
-    let quote: cdk::wallet::MintQuote = serde_json::from_str(&json)?;
-    Ok(quote.into())
-}
-
-/// Encode MintQuote to JSON string
-#[uniffi::export]
-pub fn encode_mint_quote(quote: MintQuote) -> Result<String, FfiError> {
-    Ok(serde_json::to_string(&quote)?)
-}
-
-/// FFI-compatible MintQuoteBolt11Response
-#[derive(Debug, uniffi::Object)]
-pub struct MintQuoteBolt11Response {
-    /// Quote ID
-    pub quote: String,
-    /// Request string
-    pub request: String,
-    /// State of the quote
-    pub state: QuoteState,
-    /// Expiry timestamp (optional)
-    pub expiry: Option<u64>,
-    /// Amount (optional)
-    pub amount: Option<Amount>,
-    /// Unit (optional)
-    pub unit: Option<CurrencyUnit>,
-    /// Pubkey (optional)
-    pub pubkey: Option<String>,
-}
-
-impl From<cdk::nuts::MintQuoteBolt11Response<String>> for MintQuoteBolt11Response {
-    fn from(response: cdk::nuts::MintQuoteBolt11Response<String>) -> Self {
-        Self {
-            quote: response.quote,
-            request: response.request,
-            state: response.state.into(),
-            expiry: response.expiry,
-            amount: response.amount.map(Into::into),
-            unit: response.unit.map(Into::into),
-            pubkey: response.pubkey.map(|p| p.to_string()),
-        }
-    }
-}
-
-#[uniffi::export]
-impl MintQuoteBolt11Response {
-    /// Get quote ID
-    pub fn quote(&self) -> String {
-        self.quote.clone()
-    }
-
-    /// Get request string
-    pub fn request(&self) -> String {
-        self.request.clone()
-    }
-
-    /// Get state
-    pub fn state(&self) -> QuoteState {
-        self.state.clone()
-    }
-
-    /// Get expiry
-    pub fn expiry(&self) -> Option<u64> {
-        self.expiry
-    }
-
-    /// Get amount
-    pub fn amount(&self) -> Option<Amount> {
-        self.amount
-    }
-
-    /// Get unit
-    pub fn unit(&self) -> Option<CurrencyUnit> {
-        self.unit.clone()
-    }
-
-    /// Get pubkey
-    pub fn pubkey(&self) -> Option<String> {
-        self.pubkey.clone()
-    }
-}
-
-/// FFI-compatible MeltQuoteBolt11Response
-#[derive(Debug, uniffi::Object)]
-pub struct MeltQuoteBolt11Response {
-    /// Quote ID
-    pub quote: String,
-    /// Amount
-    pub amount: Amount,
-    /// Fee reserve
-    pub fee_reserve: Amount,
-    /// State of the quote
-    pub state: QuoteState,
-    /// Expiry timestamp
-    pub expiry: u64,
-    /// Payment preimage (optional)
-    pub payment_preimage: Option<String>,
-    /// Request string (optional)
-    pub request: Option<String>,
-    /// Unit (optional)
-    pub unit: Option<CurrencyUnit>,
-}
-
-impl From<cdk::nuts::MeltQuoteBolt11Response<String>> for MeltQuoteBolt11Response {
-    fn from(response: cdk::nuts::MeltQuoteBolt11Response<String>) -> Self {
-        Self {
-            quote: response.quote,
-            amount: response.amount.into(),
-            fee_reserve: response.fee_reserve.into(),
-            state: response.state.into(),
-            expiry: response.expiry,
-            payment_preimage: response.payment_preimage,
-            request: response.request,
-            unit: response.unit.map(Into::into),
-        }
-    }
-}
-
-#[uniffi::export]
-impl MeltQuoteBolt11Response {
-    /// Get quote ID
-    pub fn quote(&self) -> String {
-        self.quote.clone()
-    }
-
-    /// Get amount
-    pub fn amount(&self) -> Amount {
-        self.amount
-    }
-
-    /// Get fee reserve
-    pub fn fee_reserve(&self) -> Amount {
-        self.fee_reserve
-    }
-
-    /// Get state
-    pub fn state(&self) -> QuoteState {
-        self.state.clone()
-    }
-
-    /// Get expiry
-    pub fn expiry(&self) -> u64 {
-        self.expiry
-    }
-
-    /// Get payment preimage
-    pub fn payment_preimage(&self) -> Option<String> {
-        self.payment_preimage.clone()
-    }
-
-    /// Get request
-    pub fn request(&self) -> Option<String> {
-        self.request.clone()
-    }
-
-    /// Get unit
-    pub fn unit(&self) -> Option<CurrencyUnit> {
-        self.unit.clone()
-    }
-}
-
-/// FFI-compatible PaymentMethod
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
-pub enum PaymentMethod {
-    /// Bolt11 payment type
-    Bolt11,
-    /// Bolt12 payment type
-    Bolt12,
-    /// Custom payment type
-    Custom { method: String },
-}
-
-impl From<cdk::nuts::PaymentMethod> for PaymentMethod {
-    fn from(method: cdk::nuts::PaymentMethod) -> Self {
-        match method {
-            cdk::nuts::PaymentMethod::Bolt11 => Self::Bolt11,
-            cdk::nuts::PaymentMethod::Bolt12 => Self::Bolt12,
-            cdk::nuts::PaymentMethod::Custom(s) => Self::Custom { method: s },
-        }
-    }
-}
-
-impl From<PaymentMethod> for cdk::nuts::PaymentMethod {
-    fn from(method: PaymentMethod) -> Self {
-        match method {
-            PaymentMethod::Bolt11 => Self::Bolt11,
-            PaymentMethod::Bolt12 => Self::Bolt12,
-            PaymentMethod::Custom { method } => Self::Custom(method),
-        }
-    }
-}
-
-/// FFI-compatible MeltQuote
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct MeltQuote {
-    /// Quote ID
-    pub id: String,
-    /// Quote amount
-    pub amount: Amount,
-    /// Currency unit
-    pub unit: CurrencyUnit,
-    /// Payment request
-    pub request: String,
-    /// Fee reserve
-    pub fee_reserve: Amount,
-    /// Quote state
-    pub state: QuoteState,
-    /// Expiry timestamp
-    pub expiry: u64,
-    /// Payment preimage
-    pub payment_preimage: Option<String>,
-    /// Payment method
-    pub payment_method: PaymentMethod,
-}
-
-impl From<cdk::wallet::MeltQuote> for MeltQuote {
-    fn from(quote: cdk::wallet::MeltQuote) -> Self {
-        Self {
-            id: quote.id.clone(),
-            amount: quote.amount.into(),
-            unit: quote.unit.clone().into(),
-            request: quote.request.clone(),
-            fee_reserve: quote.fee_reserve.into(),
-            state: quote.state.into(),
-            expiry: quote.expiry,
-            payment_preimage: quote.payment_preimage.clone(),
-            payment_method: quote.payment_method.into(),
-        }
-    }
-}
-
-impl TryFrom<MeltQuote> for cdk::wallet::MeltQuote {
-    type Error = FfiError;
-
-    fn try_from(quote: MeltQuote) -> Result<Self, Self::Error> {
-        Ok(Self {
-            id: quote.id,
-            amount: quote.amount.into(),
-            unit: quote.unit.into(),
-            request: quote.request,
-            fee_reserve: quote.fee_reserve.into(),
-            state: quote.state.into(),
-            expiry: quote.expiry,
-            payment_preimage: quote.payment_preimage,
-            payment_method: quote.payment_method.into(),
-        })
-    }
-}
-
-impl MeltQuote {
-    /// Convert MeltQuote to JSON string
-    pub fn to_json(&self) -> Result<String, FfiError> {
-        Ok(serde_json::to_string(self)?)
-    }
-}
-
-/// Decode MeltQuote from JSON string
-#[uniffi::export]
-pub fn decode_melt_quote(json: String) -> Result<MeltQuote, FfiError> {
-    let quote: cdk::wallet::MeltQuote = serde_json::from_str(&json)?;
-    Ok(quote.into())
-}
-
-/// Encode MeltQuote to JSON string
-#[uniffi::export]
-pub fn encode_melt_quote(quote: MeltQuote) -> Result<String, FfiError> {
-    Ok(serde_json::to_string(&quote)?)
-}
-
-/// FFI-compatible QuoteState
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
-pub enum QuoteState {
-    Unpaid,
-    Paid,
-    Pending,
-    Issued,
-}
-
-impl From<cdk::nuts::nut05::QuoteState> for QuoteState {
-    fn from(state: cdk::nuts::nut05::QuoteState) -> Self {
-        match state {
-            cdk::nuts::nut05::QuoteState::Unpaid => QuoteState::Unpaid,
-            cdk::nuts::nut05::QuoteState::Paid => QuoteState::Paid,
-            cdk::nuts::nut05::QuoteState::Pending => QuoteState::Pending,
-            cdk::nuts::nut05::QuoteState::Unknown => QuoteState::Unpaid,
-            cdk::nuts::nut05::QuoteState::Failed => QuoteState::Unpaid,
-        }
-    }
-}
-
-impl From<QuoteState> for cdk::nuts::nut05::QuoteState {
-    fn from(state: QuoteState) -> Self {
-        match state {
-            QuoteState::Unpaid => cdk::nuts::nut05::QuoteState::Unpaid,
-            QuoteState::Paid => cdk::nuts::nut05::QuoteState::Paid,
-            QuoteState::Pending => cdk::nuts::nut05::QuoteState::Pending,
-            QuoteState::Issued => cdk::nuts::nut05::QuoteState::Paid, // Map issued to paid for melt quotes
-        }
-    }
-}
-
-impl From<cdk::nuts::MintQuoteState> for QuoteState {
-    fn from(state: cdk::nuts::MintQuoteState) -> Self {
-        match state {
-            cdk::nuts::MintQuoteState::Unpaid => QuoteState::Unpaid,
-            cdk::nuts::MintQuoteState::Paid => QuoteState::Paid,
-            cdk::nuts::MintQuoteState::Issued => QuoteState::Issued,
-        }
-    }
-}
-
-impl From<QuoteState> for cdk::nuts::MintQuoteState {
-    fn from(state: QuoteState) -> Self {
-        match state {
-            QuoteState::Unpaid => cdk::nuts::MintQuoteState::Unpaid,
-            QuoteState::Paid => cdk::nuts::MintQuoteState::Paid,
-            QuoteState::Issued => cdk::nuts::MintQuoteState::Issued,
-            QuoteState::Pending => cdk::nuts::MintQuoteState::Paid, // Map pending to paid
-        }
-    }
-}
-
-// Note: MeltQuoteState is the same as nut05::QuoteState, so we don't need a separate impl
-
-/// FFI-compatible PreparedSend
-#[derive(Debug, uniffi::Object)]
-pub struct PreparedSend {
-    inner: Mutex<Option<cdk::wallet::PreparedSend>>,
-    id: String,
-    amount: Amount,
-    proofs: Proofs,
-}
-
-impl From<cdk::wallet::PreparedSend> for PreparedSend {
-    fn from(prepared: cdk::wallet::PreparedSend) -> Self {
-        let id = format!("{:?}", prepared); // Use debug format as ID
-        let amount = prepared.amount().into();
-        let proofs = prepared
-            .proofs()
-            .iter()
-            .cloned()
-            .map(|p| std::sync::Arc::new(p.into()))
-            .collect();
-        Self {
-            inner: Mutex::new(Some(prepared)),
-            id,
-            amount,
-            proofs,
-        }
-    }
-}
-
-#[uniffi::export(async_runtime = "tokio")]
-impl PreparedSend {
-    /// Get the prepared send ID
-    pub fn id(&self) -> String {
-        self.id.clone()
-    }
-
-    /// Get the amount to send
-    pub fn amount(&self) -> Amount {
-        self.amount
-    }
-
-    /// Get the proofs that will be used
-    pub fn proofs(&self) -> Proofs {
-        self.proofs.clone()
-    }
-
-    /// Get the total fee for this send operation
-    pub fn fee(&self) -> Amount {
-        if let Ok(guard) = self.inner.lock() {
-            if let Some(ref inner) = *guard {
-                inner.fee().into()
-            } else {
-                Amount::new(0)
-            }
-        } else {
-            Amount::new(0)
-        }
-    }
-
-    /// Confirm the prepared send and create a token
-    pub async fn confirm(
-        self: std::sync::Arc<Self>,
-        memo: Option<String>,
-    ) -> Result<Token, FfiError> {
-        let inner = {
-            if let Ok(mut guard) = self.inner.lock() {
-                guard.take()
-            } else {
-                return Err(FfiError::Generic {
-                    msg: "Failed to acquire lock on PreparedSend".to_string(),
-                });
-            }
-        };
-
-        if let Some(inner) = inner {
-            let send_memo = memo.map(|m| cdk::wallet::SendMemo::for_token(&m));
-            let token = inner.confirm(send_memo).await?;
-            Ok(token.into())
-        } else {
-            Err(FfiError::Generic {
-                msg: "PreparedSend has already been consumed or cancelled".to_string(),
-            })
-        }
-    }
-
-    /// Cancel the prepared send operation
-    pub async fn cancel(self: std::sync::Arc<Self>) -> Result<(), FfiError> {
-        let inner = {
-            if let Ok(mut guard) = self.inner.lock() {
-                guard.take()
-            } else {
-                return Err(FfiError::Generic {
-                    msg: "Failed to acquire lock on PreparedSend".to_string(),
-                });
-            }
-        };
-
-        if let Some(inner) = inner {
-            inner.cancel().await?;
-            Ok(())
-        } else {
-            Err(FfiError::Generic {
-                msg: "PreparedSend has already been consumed or cancelled".to_string(),
-            })
-        }
-    }
-}
-
-/// FFI-compatible Melted result
-#[derive(Debug, Clone, uniffi::Record)]
-pub struct Melted {
-    pub state: QuoteState,
-    pub preimage: Option<String>,
-    pub change: Option<Proofs>,
-    pub amount: Amount,
-    pub fee_paid: Amount,
-}
-
-// MeltQuoteState is just an alias for nut05::QuoteState, so we don't need a separate implementation
-
-impl From<cdk::types::Melted> for Melted {
-    fn from(melted: cdk::types::Melted) -> Self {
-        Self {
-            state: melted.state.into(),
-            preimage: melted.preimage,
-            change: melted.change.map(|proofs| {
-                proofs
-                    .into_iter()
-                    .map(|p| std::sync::Arc::new(p.into()))
-                    .collect()
-            }),
-            amount: melted.amount.into(),
-            fee_paid: melted.fee_paid.into(),
-        }
-    }
-}
-
-/// FFI-compatible MeltOptions
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)]
-pub enum MeltOptions {
-    /// MPP (Multi-Part Payments) options
-    Mpp { amount: Amount },
-    /// Amountless options
-    Amountless { amount_msat: Amount },
-}
-
-impl From<MeltOptions> for cdk::nuts::MeltOptions {
-    fn from(opts: MeltOptions) -> Self {
-        match opts {
-            MeltOptions::Mpp { amount } => {
-                let cdk_amount: cdk::Amount = amount.into();
-                cdk::nuts::MeltOptions::new_mpp(cdk_amount)
-            }
-            MeltOptions::Amountless { amount_msat } => {
-                let cdk_amount: cdk::Amount = amount_msat.into();
-                cdk::nuts::MeltOptions::new_amountless(cdk_amount)
-            }
-        }
-    }
-}
-
-impl From<cdk::nuts::MeltOptions> for MeltOptions {
-    fn from(opts: cdk::nuts::MeltOptions) -> Self {
-        match opts {
-            cdk::nuts::MeltOptions::Mpp { mpp } => MeltOptions::Mpp {
-                amount: mpp.amount.into(),
-            },
-            cdk::nuts::MeltOptions::Amountless { amountless } => MeltOptions::Amountless {
-                amount_msat: amountless.amount_msat.into(),
-            },
-        }
-    }
-}
-
-/// FFI-compatible MintVersion
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct MintVersion {
-    /// Mint Software name
-    pub name: String,
-    /// Mint Version
-    pub version: String,
-}
-
-impl From<cdk::nuts::MintVersion> for MintVersion {
-    fn from(version: cdk::nuts::MintVersion) -> Self {
-        Self {
-            name: version.name,
-            version: version.version,
-        }
-    }
-}
-
-impl From<MintVersion> for cdk::nuts::MintVersion {
-    fn from(version: MintVersion) -> Self {
-        Self {
-            name: version.name,
-            version: version.version,
-        }
-    }
-}
-
-impl MintVersion {
-    /// Convert MintVersion to JSON string
-    pub fn to_json(&self) -> Result<String, FfiError> {
-        Ok(serde_json::to_string(self)?)
-    }
-}
-
-/// Decode MintVersion from JSON string
-#[uniffi::export]
-pub fn decode_mint_version(json: String) -> Result<MintVersion, FfiError> {
-    Ok(serde_json::from_str(&json)?)
-}
-
-/// Encode MintVersion to JSON string
-#[uniffi::export]
-pub fn encode_mint_version(version: MintVersion) -> Result<String, FfiError> {
-    Ok(serde_json::to_string(&version)?)
-}
-
-/// FFI-compatible ContactInfo
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct ContactInfo {
-    /// Contact Method i.e. nostr
-    pub method: String,
-    /// Contact info i.e. npub...
-    pub info: String,
-}
-
-impl From<cdk::nuts::ContactInfo> for ContactInfo {
-    fn from(contact: cdk::nuts::ContactInfo) -> Self {
-        Self {
-            method: contact.method,
-            info: contact.info,
-        }
-    }
-}
-
-impl From<ContactInfo> for cdk::nuts::ContactInfo {
-    fn from(contact: ContactInfo) -> Self {
-        Self {
-            method: contact.method,
-            info: contact.info,
-        }
-    }
-}
-
-impl ContactInfo {
-    /// Convert ContactInfo to JSON string
-    pub fn to_json(&self) -> Result<String, FfiError> {
-        Ok(serde_json::to_string(self)?)
-    }
-}
-
-/// Decode ContactInfo from JSON string
-#[uniffi::export]
-pub fn decode_contact_info(json: String) -> Result<ContactInfo, FfiError> {
-    Ok(serde_json::from_str(&json)?)
-}
-
-/// Encode ContactInfo to JSON string
-#[uniffi::export]
-pub fn encode_contact_info(info: ContactInfo) -> Result<String, FfiError> {
-    Ok(serde_json::to_string(&info)?)
-}
-
-/// FFI-compatible SupportedSettings
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-#[serde(transparent)]
-pub struct SupportedSettings {
-    /// Setting supported
-    pub supported: bool,
-}
-
-impl From<cdk::nuts::nut06::SupportedSettings> for SupportedSettings {
-    fn from(settings: cdk::nuts::nut06::SupportedSettings) -> Self {
-        Self {
-            supported: settings.supported,
-        }
-    }
-}
-
-impl From<SupportedSettings> for cdk::nuts::nut06::SupportedSettings {
-    fn from(settings: SupportedSettings) -> Self {
-        Self {
-            supported: settings.supported,
-        }
-    }
-}
-
-// -----------------------------
-// NUT-04/05 FFI Types
-// -----------------------------
-
-/// FFI-compatible MintMethodSettings (NUT-04)
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct MintMethodSettings {
-    pub method: PaymentMethod,
-    pub unit: CurrencyUnit,
-    pub min_amount: Option<Amount>,
-    pub max_amount: Option<Amount>,
-    /// For bolt11, whether mint supports setting invoice description
-    pub description: Option<bool>,
-}
-
-impl From<cdk::nuts::nut04::MintMethodSettings> for MintMethodSettings {
-    fn from(s: cdk::nuts::nut04::MintMethodSettings) -> Self {
-        let description = match s.options {
-            Some(cdk::nuts::nut04::MintMethodOptions::Bolt11 { description }) => Some(description),
-            _ => None,
-        };
-        Self {
-            method: s.method.into(),
-            unit: s.unit.into(),
-            min_amount: s.min_amount.map(Into::into),
-            max_amount: s.max_amount.map(Into::into),
-            description,
-        }
-    }
-}
-
-impl TryFrom<MintMethodSettings> for cdk::nuts::nut04::MintMethodSettings {
-    type Error = FfiError;
-
-    fn try_from(s: MintMethodSettings) -> Result<Self, Self::Error> {
-        let options = match (s.method.clone(), s.description) {
-            (PaymentMethod::Bolt11, Some(description)) => {
-                Some(cdk::nuts::nut04::MintMethodOptions::Bolt11 { description })
-            }
-            _ => None,
-        };
-        Ok(Self {
-            method: s.method.into(),
-            unit: s.unit.into(),
-            min_amount: s.min_amount.map(Into::into),
-            max_amount: s.max_amount.map(Into::into),
-            options,
-        })
-    }
-}
-
-/// FFI-compatible Nut04 Settings
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct Nut04Settings {
-    pub methods: Vec<MintMethodSettings>,
-    pub disabled: bool,
-}
-
-impl From<cdk::nuts::nut04::Settings> for Nut04Settings {
-    fn from(s: cdk::nuts::nut04::Settings) -> Self {
-        Self {
-            methods: s.methods.into_iter().map(Into::into).collect(),
-            disabled: s.disabled,
-        }
-    }
-}
-
-impl TryFrom<Nut04Settings> for cdk::nuts::nut04::Settings {
-    type Error = FfiError;
-
-    fn try_from(s: Nut04Settings) -> Result<Self, Self::Error> {
-        Ok(Self {
-            methods: s
-                .methods
-                .into_iter()
-                .map(TryInto::try_into)
-                .collect::<Result<_, _>>()?,
-            disabled: s.disabled,
-        })
-    }
-}
-
-/// FFI-compatible MeltMethodSettings (NUT-05)
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct MeltMethodSettings {
-    pub method: PaymentMethod,
-    pub unit: CurrencyUnit,
-    pub min_amount: Option<Amount>,
-    pub max_amount: Option<Amount>,
-    /// For bolt11, whether mint supports amountless invoices
-    pub amountless: Option<bool>,
-}
-
-impl From<cdk::nuts::nut05::MeltMethodSettings> for MeltMethodSettings {
-    fn from(s: cdk::nuts::nut05::MeltMethodSettings) -> Self {
-        let amountless = match s.options {
-            Some(cdk::nuts::nut05::MeltMethodOptions::Bolt11 { amountless }) => Some(amountless),
-            _ => None,
-        };
-        Self {
-            method: s.method.into(),
-            unit: s.unit.into(),
-            min_amount: s.min_amount.map(Into::into),
-            max_amount: s.max_amount.map(Into::into),
-            amountless,
-        }
-    }
-}
-
-impl TryFrom<MeltMethodSettings> for cdk::nuts::nut05::MeltMethodSettings {
-    type Error = FfiError;
-
-    fn try_from(s: MeltMethodSettings) -> Result<Self, Self::Error> {
-        let options = match (s.method.clone(), s.amountless) {
-            (PaymentMethod::Bolt11, Some(amountless)) => {
-                Some(cdk::nuts::nut05::MeltMethodOptions::Bolt11 { amountless })
-            }
-            _ => None,
-        };
-        Ok(Self {
-            method: s.method.into(),
-            unit: s.unit.into(),
-            min_amount: s.min_amount.map(Into::into),
-            max_amount: s.max_amount.map(Into::into),
-            options,
-        })
-    }
-}
-
-/// FFI-compatible Nut05 Settings
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct Nut05Settings {
-    pub methods: Vec<MeltMethodSettings>,
-    pub disabled: bool,
-}
-
-impl From<cdk::nuts::nut05::Settings> for Nut05Settings {
-    fn from(s: cdk::nuts::nut05::Settings) -> Self {
-        Self {
-            methods: s.methods.into_iter().map(Into::into).collect(),
-            disabled: s.disabled,
-        }
-    }
-}
-
-impl TryFrom<Nut05Settings> for cdk::nuts::nut05::Settings {
-    type Error = FfiError;
-
-    fn try_from(s: Nut05Settings) -> Result<Self, Self::Error> {
-        Ok(Self {
-            methods: s
-                .methods
-                .into_iter()
-                .map(TryInto::try_into)
-                .collect::<Result<_, _>>()?,
-            disabled: s.disabled,
-        })
-    }
-}
-
-/// FFI-compatible ProtectedEndpoint (for auth nuts)
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct ProtectedEndpoint {
-    /// HTTP method (GET, POST, etc.)
-    pub method: String,
-    /// Endpoint path
-    pub path: String,
-}
-
-/// FFI-compatible ClearAuthSettings (NUT-21)
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct ClearAuthSettings {
-    /// OpenID Connect discovery URL
-    pub openid_discovery: String,
-    /// OAuth 2.0 client ID
-    pub client_id: String,
-    /// Protected endpoints requiring clear authentication
-    pub protected_endpoints: Vec<ProtectedEndpoint>,
-}
-
-/// FFI-compatible BlindAuthSettings (NUT-22)
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct BlindAuthSettings {
-    /// Maximum number of blind auth tokens that can be minted per request
-    pub bat_max_mint: u64,
-    /// Protected endpoints requiring blind authentication
-    pub protected_endpoints: Vec<ProtectedEndpoint>,
-}
-
-impl From<cdk::nuts::ClearAuthSettings> for ClearAuthSettings {
-    fn from(settings: cdk::nuts::ClearAuthSettings) -> Self {
-        Self {
-            openid_discovery: settings.openid_discovery,
-            client_id: settings.client_id,
-            protected_endpoints: settings
-                .protected_endpoints
-                .into_iter()
-                .map(Into::into)
-                .collect(),
-        }
-    }
-}
-
-impl TryFrom<ClearAuthSettings> for cdk::nuts::ClearAuthSettings {
-    type Error = FfiError;
-
-    fn try_from(settings: ClearAuthSettings) -> Result<Self, Self::Error> {
-        Ok(Self {
-            openid_discovery: settings.openid_discovery,
-            client_id: settings.client_id,
-            protected_endpoints: settings
-                .protected_endpoints
-                .into_iter()
-                .map(|e| e.try_into())
-                .collect::<Result<Vec<_>, _>>()?,
-        })
-    }
-}
-
-impl From<cdk::nuts::BlindAuthSettings> for BlindAuthSettings {
-    fn from(settings: cdk::nuts::BlindAuthSettings) -> Self {
-        Self {
-            bat_max_mint: settings.bat_max_mint,
-            protected_endpoints: settings
-                .protected_endpoints
-                .into_iter()
-                .map(Into::into)
-                .collect(),
-        }
-    }
-}
-
-impl TryFrom<BlindAuthSettings> for cdk::nuts::BlindAuthSettings {
-    type Error = FfiError;
-
-    fn try_from(settings: BlindAuthSettings) -> Result<Self, Self::Error> {
-        Ok(Self {
-            bat_max_mint: settings.bat_max_mint,
-            protected_endpoints: settings
-                .protected_endpoints
-                .into_iter()
-                .map(|e| e.try_into())
-                .collect::<Result<Vec<_>, _>>()?,
-        })
-    }
-}
-
-impl From<cdk::nuts::ProtectedEndpoint> for ProtectedEndpoint {
-    fn from(endpoint: cdk::nuts::ProtectedEndpoint) -> Self {
-        Self {
-            method: match endpoint.method {
-                cdk::nuts::Method::Get => "GET".to_string(),
-                cdk::nuts::Method::Post => "POST".to_string(),
-            },
-            path: endpoint.path.to_string(),
-        }
-    }
-}
-
-impl TryFrom<ProtectedEndpoint> for cdk::nuts::ProtectedEndpoint {
-    type Error = FfiError;
-
-    fn try_from(endpoint: ProtectedEndpoint) -> Result<Self, Self::Error> {
-        let method = match endpoint.method.as_str() {
-            "GET" => cdk::nuts::Method::Get,
-            "POST" => cdk::nuts::Method::Post,
-            _ => {
-                return Err(FfiError::Generic {
-                    msg: format!(
-                        "Invalid HTTP method: {}. Only GET and POST are supported",
-                        endpoint.method
-                    ),
-                })
-            }
-        };
-
-        // Convert path string to RoutePath by matching against known paths
-        let route_path = match endpoint.path.as_str() {
-            "/v1/mint/quote/bolt11" => cdk::nuts::RoutePath::MintQuoteBolt11,
-            "/v1/mint/bolt11" => cdk::nuts::RoutePath::MintBolt11,
-            "/v1/melt/quote/bolt11" => cdk::nuts::RoutePath::MeltQuoteBolt11,
-            "/v1/melt/bolt11" => cdk::nuts::RoutePath::MeltBolt11,
-            "/v1/swap" => cdk::nuts::RoutePath::Swap,
-            "/v1/checkstate" => cdk::nuts::RoutePath::Checkstate,
-            "/v1/restore" => cdk::nuts::RoutePath::Restore,
-            "/v1/auth/blind/mint" => cdk::nuts::RoutePath::MintBlindAuth,
-            "/v1/mint/quote/bolt12" => cdk::nuts::RoutePath::MintQuoteBolt12,
-            "/v1/mint/bolt12" => cdk::nuts::RoutePath::MintBolt12,
-            "/v1/melt/quote/bolt12" => cdk::nuts::RoutePath::MeltQuoteBolt12,
-            "/v1/melt/bolt12" => cdk::nuts::RoutePath::MeltBolt12,
-            _ => {
-                return Err(FfiError::Generic {
-                    msg: format!("Unknown route path: {}", endpoint.path),
-                })
-            }
-        };
-
-        Ok(cdk::nuts::ProtectedEndpoint::new(method, route_path))
-    }
-}
-
-/// FFI-compatible Nuts settings (extended to include NUT-04 and NUT-05 settings)
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct Nuts {
-    /// NUT04 Settings
-    pub nut04: Nut04Settings,
-    /// NUT05 Settings
-    pub nut05: Nut05Settings,
-    /// NUT07 Settings - Token state check
-    pub nut07_supported: bool,
-    /// NUT08 Settings - Lightning fee return
-    pub nut08_supported: bool,
-    /// NUT09 Settings - Restore signature
-    pub nut09_supported: bool,
-    /// NUT10 Settings - Spending conditions
-    pub nut10_supported: bool,
-    /// NUT11 Settings - Pay to Public Key Hash
-    pub nut11_supported: bool,
-    /// NUT12 Settings - DLEQ proofs
-    pub nut12_supported: bool,
-    /// NUT14 Settings - Hashed Time Locked Contracts
-    pub nut14_supported: bool,
-    /// NUT20 Settings - Web sockets
-    pub nut20_supported: bool,
-    /// NUT21 Settings - Clear authentication
-    pub nut21: Option<ClearAuthSettings>,
-    /// NUT22 Settings - Blind authentication
-    pub nut22: Option<BlindAuthSettings>,
-    /// Supported currency units for minting
-    pub mint_units: Vec<CurrencyUnit>,
-    /// Supported currency units for melting
-    pub melt_units: Vec<CurrencyUnit>,
-}
-
-impl From<cdk::nuts::Nuts> for Nuts {
-    fn from(nuts: cdk::nuts::Nuts) -> Self {
-        let mint_units = nuts
-            .supported_mint_units()
-            .into_iter()
-            .map(|u| u.clone().into())
-            .collect();
-        let melt_units = nuts
-            .supported_melt_units()
-            .into_iter()
-            .map(|u| u.clone().into())
-            .collect();
-
-        Self {
-            nut04: nuts.nut04.clone().into(),
-            nut05: nuts.nut05.clone().into(),
-            nut07_supported: nuts.nut07.supported,
-            nut08_supported: nuts.nut08.supported,
-            nut09_supported: nuts.nut09.supported,
-            nut10_supported: nuts.nut10.supported,
-            nut11_supported: nuts.nut11.supported,
-            nut12_supported: nuts.nut12.supported,
-            nut14_supported: nuts.nut14.supported,
-            nut20_supported: nuts.nut20.supported,
-            nut21: nuts.nut21.map(Into::into),
-            nut22: nuts.nut22.map(Into::into),
-            mint_units,
-            melt_units,
-        }
-    }
-}
-
-impl TryFrom<Nuts> for cdk::nuts::Nuts {
-    type Error = FfiError;
-
-    fn try_from(n: Nuts) -> Result<Self, Self::Error> {
-        Ok(Self {
-            nut04: n.nut04.try_into()?,
-            nut05: n.nut05.try_into()?,
-            nut07: cdk::nuts::nut06::SupportedSettings {
-                supported: n.nut07_supported,
-            },
-            nut08: cdk::nuts::nut06::SupportedSettings {
-                supported: n.nut08_supported,
-            },
-            nut09: cdk::nuts::nut06::SupportedSettings {
-                supported: n.nut09_supported,
-            },
-            nut10: cdk::nuts::nut06::SupportedSettings {
-                supported: n.nut10_supported,
-            },
-            nut11: cdk::nuts::nut06::SupportedSettings {
-                supported: n.nut11_supported,
-            },
-            nut12: cdk::nuts::nut06::SupportedSettings {
-                supported: n.nut12_supported,
-            },
-            nut14: cdk::nuts::nut06::SupportedSettings {
-                supported: n.nut14_supported,
-            },
-            nut15: Default::default(),
-            nut17: Default::default(),
-            nut19: Default::default(),
-            nut20: cdk::nuts::nut06::SupportedSettings {
-                supported: n.nut20_supported,
-            },
-            nut21: n.nut21.map(|s| s.try_into()).transpose()?,
-            nut22: n.nut22.map(|s| s.try_into()).transpose()?,
-        })
-    }
-}
-
-impl Nuts {
-    /// Convert Nuts to JSON string
-    pub fn to_json(&self) -> Result<String, FfiError> {
-        Ok(serde_json::to_string(self)?)
-    }
-}
-
-/// Decode Nuts from JSON string
-#[uniffi::export]
-pub fn decode_nuts(json: String) -> Result<Nuts, FfiError> {
-    Ok(serde_json::from_str(&json)?)
-}
-
-/// Encode Nuts to JSON string
-#[uniffi::export]
-pub fn encode_nuts(nuts: Nuts) -> Result<String, FfiError> {
-    Ok(serde_json::to_string(&nuts)?)
-}
-
-/// FFI-compatible MintInfo
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct MintInfo {
-    /// name of the mint and should be recognizable
-    pub name: Option<String>,
-    /// hex pubkey of the mint  
-    pub pubkey: Option<String>,
-    /// implementation name and the version running
-    pub version: Option<MintVersion>,
-    /// short description of the mint
-    pub description: Option<String>,
-    /// long description
-    pub description_long: Option<String>,
-    /// Contact info
-    pub contact: Option<Vec<ContactInfo>>,
-    /// shows which NUTs the mint supports
-    pub nuts: Nuts,
-    /// Mint's icon URL
-    pub icon_url: Option<String>,
-    /// Mint's endpoint URLs
-    pub urls: Option<Vec<String>>,
-    /// message of the day that the wallet must display to the user
-    pub motd: Option<String>,
-    /// server unix timestamp
-    pub time: Option<u64>,
-    /// terms of url service of the mint
-    pub tos_url: Option<String>,
-}
-
-impl From<cdk::nuts::MintInfo> for MintInfo {
-    fn from(info: cdk::nuts::MintInfo) -> Self {
-        Self {
-            name: info.name,
-            pubkey: info.pubkey.map(|p| p.to_string()),
-            version: info.version.map(Into::into),
-            description: info.description,
-            description_long: info.description_long,
-            contact: info
-                .contact
-                .map(|contacts| contacts.into_iter().map(Into::into).collect()),
-            nuts: info.nuts.into(),
-            icon_url: info.icon_url,
-            urls: info.urls,
-            motd: info.motd,
-            time: info.time,
-            tos_url: info.tos_url,
-        }
-    }
-}
-
-impl From<MintInfo> for cdk::nuts::MintInfo {
-    fn from(info: MintInfo) -> Self {
-        // Convert FFI Nuts back to cdk::nuts::Nuts (best-effort)
-        let nuts_cdk: cdk::nuts::Nuts = info.nuts.clone().try_into().unwrap_or_default();
-        Self {
-            name: info.name,
-            pubkey: info.pubkey.and_then(|p| p.parse().ok()),
-            version: info.version.map(Into::into),
-            description: info.description,
-            description_long: info.description_long,
-            contact: info
-                .contact
-                .map(|contacts| contacts.into_iter().map(Into::into).collect()),
-            nuts: nuts_cdk,
-            icon_url: info.icon_url,
-            urls: info.urls,
-            motd: info.motd,
-            time: info.time,
-            tos_url: info.tos_url,
-        }
-    }
-}
-
-impl MintInfo {
-    /// Convert MintInfo to JSON string
-    pub fn to_json(&self) -> Result<String, FfiError> {
-        Ok(serde_json::to_string(self)?)
-    }
-}
-
-/// Decode MintInfo from JSON string
-#[uniffi::export]
-pub fn decode_mint_info(json: String) -> Result<MintInfo, FfiError> {
-    Ok(serde_json::from_str(&json)?)
-}
-
-/// Encode MintInfo to JSON string
-#[uniffi::export]
-pub fn encode_mint_info(info: MintInfo) -> Result<String, FfiError> {
-    Ok(serde_json::to_string(&info)?)
-}
-
-/// FFI-compatible Conditions (for spending conditions)
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct Conditions {
-    /// Unix locktime after which refund keys can be used
-    pub locktime: Option<u64>,
-    /// Additional Public keys (as hex strings)
-    pub pubkeys: Vec<String>,
-    /// Refund keys (as hex strings)
-    pub refund_keys: Vec<String>,
-    /// Number of signatures required (default 1)
-    pub num_sigs: Option<u64>,
-    /// Signature flag (0 = SigInputs, 1 = SigAll)
-    pub sig_flag: u8,
-    /// Number of refund signatures required (default 1)
-    pub num_sigs_refund: Option<u64>,
-}
-
-impl From<cdk::nuts::nut11::Conditions> for Conditions {
-    fn from(conditions: cdk::nuts::nut11::Conditions) -> Self {
-        Self {
-            locktime: conditions.locktime,
-            pubkeys: conditions
-                .pubkeys
-                .unwrap_or_default()
-                .into_iter()
-                .map(|p| p.to_string())
-                .collect(),
-            refund_keys: conditions
-                .refund_keys
-                .unwrap_or_default()
-                .into_iter()
-                .map(|p| p.to_string())
-                .collect(),
-            num_sigs: conditions.num_sigs,
-            sig_flag: match conditions.sig_flag {
-                cdk::nuts::nut11::SigFlag::SigInputs => 0,
-                cdk::nuts::nut11::SigFlag::SigAll => 1,
-            },
-            num_sigs_refund: conditions.num_sigs_refund,
-        }
-    }
-}
-
-impl TryFrom<Conditions> for cdk::nuts::nut11::Conditions {
-    type Error = FfiError;
-
-    fn try_from(conditions: Conditions) -> Result<Self, Self::Error> {
-        let pubkeys = if conditions.pubkeys.is_empty() {
-            None
-        } else {
-            Some(
-                conditions
-                    .pubkeys
-                    .into_iter()
-                    .map(|s| {
-                        s.parse().map_err(|e| FfiError::InvalidCryptographicKey {
-                            msg: format!("Invalid pubkey: {}", e),
-                        })
-                    })
-                    .collect::<Result<Vec<_>, _>>()?,
-            )
-        };
-
-        let refund_keys = if conditions.refund_keys.is_empty() {
-            None
-        } else {
-            Some(
-                conditions
-                    .refund_keys
-                    .into_iter()
-                    .map(|s| {
-                        s.parse().map_err(|e| FfiError::InvalidCryptographicKey {
-                            msg: format!("Invalid refund key: {}", e),
-                        })
-                    })
-                    .collect::<Result<Vec<_>, _>>()?,
-            )
-        };
-
-        let sig_flag = match conditions.sig_flag {
-            0 => cdk::nuts::nut11::SigFlag::SigInputs,
-            1 => cdk::nuts::nut11::SigFlag::SigAll,
-            _ => {
-                return Err(FfiError::Generic {
-                    msg: "Invalid sig_flag value".to_string(),
-                })
-            }
-        };
-
-        Ok(Self {
-            locktime: conditions.locktime,
-            pubkeys,
-            refund_keys,
-            num_sigs: conditions.num_sigs,
-            sig_flag,
-            num_sigs_refund: conditions.num_sigs_refund,
-        })
-    }
-}
-
-impl Conditions {
-    /// Convert Conditions to JSON string
-    pub fn to_json(&self) -> Result<String, FfiError> {
-        Ok(serde_json::to_string(self)?)
-    }
-}
-
-/// Decode Conditions from JSON string
-#[uniffi::export]
-pub fn decode_conditions(json: String) -> Result<Conditions, FfiError> {
-    Ok(serde_json::from_str(&json)?)
-}
-
-/// Encode Conditions to JSON string
-#[uniffi::export]
-pub fn encode_conditions(conditions: Conditions) -> Result<String, FfiError> {
-    Ok(serde_json::to_string(&conditions)?)
-}
-
-/// FFI-compatible Witness
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)]
-pub enum Witness {
-    /// P2PK Witness
-    P2PK {
-        /// Signatures
-        signatures: Vec<String>,
-    },
-    /// HTLC Witness  
-    HTLC {
-        /// Preimage
-        preimage: String,
-        /// Optional signatures
-        signatures: Option<Vec<String>>,
-    },
-}
-
-impl From<cdk::nuts::Witness> for Witness {
-    fn from(witness: cdk::nuts::Witness) -> Self {
-        match witness {
-            cdk::nuts::Witness::P2PKWitness(p2pk) => Self::P2PK {
-                signatures: p2pk.signatures,
-            },
-            cdk::nuts::Witness::HTLCWitness(htlc) => Self::HTLC {
-                preimage: htlc.preimage,
-                signatures: htlc.signatures,
-            },
-        }
-    }
-}
-
-impl From<Witness> for cdk::nuts::Witness {
-    fn from(witness: Witness) -> Self {
-        match witness {
-            Witness::P2PK { signatures } => {
-                Self::P2PKWitness(cdk::nuts::nut11::P2PKWitness { signatures })
-            }
-            Witness::HTLC {
-                preimage,
-                signatures,
-            } => Self::HTLCWitness(cdk::nuts::nut14::HTLCWitness {
-                preimage,
-                signatures,
-            }),
-        }
-    }
-}
-
-/// FFI-compatible SpendingConditions
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)]
-pub enum SpendingConditions {
-    /// P2PK (Pay to Public Key) conditions
-    P2PK {
-        /// The public key (as hex string)
-        pubkey: String,
-        /// Additional conditions
-        conditions: Option<Conditions>,
-    },
-    /// HTLC (Hash Time Locked Contract) conditions
-    HTLC {
-        /// Hash of the preimage (as hex string)
-        hash: String,
-        /// Additional conditions
-        conditions: Option<Conditions>,
-    },
-}
-
-impl From<cdk::nuts::SpendingConditions> for SpendingConditions {
-    fn from(spending_conditions: cdk::nuts::SpendingConditions) -> Self {
-        match spending_conditions {
-            cdk::nuts::SpendingConditions::P2PKConditions { data, conditions } => Self::P2PK {
-                pubkey: data.to_string(),
-                conditions: conditions.map(Into::into),
-            },
-            cdk::nuts::SpendingConditions::HTLCConditions { data, conditions } => Self::HTLC {
-                hash: data.to_string(),
-                conditions: conditions.map(Into::into),
-            },
-        }
-    }
-}
-
-/// FFI-compatible Transaction
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct Transaction {
-    /// Transaction ID
-    pub id: TransactionId,
-    /// Mint URL
-    pub mint_url: MintUrl,
-    /// Transaction direction
-    pub direction: TransactionDirection,
-    /// Amount
-    pub amount: Amount,
-    /// Fee
-    pub fee: Amount,
-    /// Currency Unit
-    pub unit: CurrencyUnit,
-    /// Proof Ys (Y values from proofs)
-    pub ys: Vec<PublicKey>,
-    /// Unix timestamp
-    pub timestamp: u64,
-    /// Memo
-    pub memo: Option<String>,
-    /// User-defined metadata
-    pub metadata: HashMap<String, String>,
-    /// Quote ID if this is a mint or melt transaction
-    pub quote_id: Option<String>,
-}
-
-impl From<cdk::wallet::types::Transaction> for Transaction {
-    fn from(tx: cdk::wallet::types::Transaction) -> Self {
-        Self {
-            id: tx.id().into(),
-            mint_url: tx.mint_url.into(),
-            direction: tx.direction.into(),
-            amount: tx.amount.into(),
-            fee: tx.fee.into(),
-            unit: tx.unit.into(),
-            ys: tx.ys.into_iter().map(Into::into).collect(),
-            timestamp: tx.timestamp,
-            memo: tx.memo,
-            metadata: tx.metadata,
-            quote_id: tx.quote_id,
-        }
-    }
-}
-
-/// Convert FFI Transaction to CDK Transaction
-impl TryFrom<Transaction> for cdk::wallet::types::Transaction {
-    type Error = FfiError;
-
-    fn try_from(tx: Transaction) -> Result<Self, Self::Error> {
-        let cdk_ys: Result<Vec<cdk::nuts::PublicKey>, _> =
-            tx.ys.into_iter().map(|pk| pk.try_into()).collect();
-        let cdk_ys = cdk_ys?;
-
-        Ok(Self {
-            mint_url: tx.mint_url.try_into()?,
-            direction: tx.direction.into(),
-            amount: tx.amount.into(),
-            fee: tx.fee.into(),
-            unit: tx.unit.into(),
-            ys: cdk_ys,
-            timestamp: tx.timestamp,
-            memo: tx.memo,
-            metadata: tx.metadata,
-            quote_id: tx.quote_id,
-        })
-    }
-}
-
-impl Transaction {
-    /// Convert Transaction to JSON string
-    pub fn to_json(&self) -> Result<String, FfiError> {
-        Ok(serde_json::to_string(self)?)
-    }
-}
-
-/// Decode Transaction from JSON string
-#[uniffi::export]
-pub fn decode_transaction(json: String) -> Result<Transaction, FfiError> {
-    Ok(serde_json::from_str(&json)?)
-}
-
-/// Encode Transaction to JSON string
-#[uniffi::export]
-pub fn encode_transaction(transaction: Transaction) -> Result<String, FfiError> {
-    Ok(serde_json::to_string(&transaction)?)
-}
-
-/// FFI-compatible TransactionDirection
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
-pub enum TransactionDirection {
-    /// Incoming transaction (i.e., receive or mint)
-    Incoming,
-    /// Outgoing transaction (i.e., send or melt)
-    Outgoing,
-}
-
-impl From<cdk::wallet::types::TransactionDirection> for TransactionDirection {
-    fn from(direction: cdk::wallet::types::TransactionDirection) -> Self {
-        match direction {
-            cdk::wallet::types::TransactionDirection::Incoming => TransactionDirection::Incoming,
-            cdk::wallet::types::TransactionDirection::Outgoing => TransactionDirection::Outgoing,
-        }
-    }
-}
-
-impl From<TransactionDirection> for cdk::wallet::types::TransactionDirection {
-    fn from(direction: TransactionDirection) -> Self {
-        match direction {
-            TransactionDirection::Incoming => cdk::wallet::types::TransactionDirection::Incoming,
-            TransactionDirection::Outgoing => cdk::wallet::types::TransactionDirection::Outgoing,
-        }
-    }
-}
-
-/// FFI-compatible TransactionId
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-#[serde(transparent)]
-pub struct TransactionId {
-    /// Hex-encoded transaction ID (64 characters)
-    pub hex: String,
-}
-
-impl TransactionId {
-    /// Create a new TransactionId from hex string
-    pub fn from_hex(hex: String) -> Result<Self, FfiError> {
-        // Validate hex string length (should be 64 characters for 32 bytes)
-        if hex.len() != 64 {
-            return Err(FfiError::InvalidHex {
-                msg: "Transaction ID hex must be exactly 64 characters (32 bytes)".to_string(),
-            });
-        }
-
-        // Validate hex format
-        if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
-            return Err(FfiError::InvalidHex {
-                msg: "Transaction ID hex contains invalid characters".to_string(),
-            });
-        }
-
-        Ok(Self { hex })
-    }
-
-    /// Create from proofs
-    pub fn from_proofs(proofs: &Proofs) -> Result<Self, FfiError> {
-        let cdk_proofs: Vec<cdk::nuts::Proof> = proofs.iter().map(|p| p.inner.clone()).collect();
-        let id = cdk::wallet::types::TransactionId::from_proofs(cdk_proofs)?;
-        Ok(Self {
-            hex: id.to_string(),
-        })
-    }
-}
-
-impl From<cdk::wallet::types::TransactionId> for TransactionId {
-    fn from(id: cdk::wallet::types::TransactionId) -> Self {
-        Self {
-            hex: id.to_string(),
-        }
-    }
-}
-
-impl TryFrom<TransactionId> for cdk::wallet::types::TransactionId {
-    type Error = FfiError;
-
-    fn try_from(id: TransactionId) -> Result<Self, Self::Error> {
-        cdk::wallet::types::TransactionId::from_hex(&id.hex)
-            .map_err(|e| FfiError::InvalidHex { msg: e.to_string() })
-    }
-}
-
-/// FFI-compatible AuthProof
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct AuthProof {
-    /// Keyset ID
-    pub keyset_id: String,
-    /// Secret message
-    pub secret: String,
-    /// Unblinded signature (C)
-    pub c: String,
-    /// Y value (hash_to_curve of secret)
-    pub y: String,
-}
-
-impl From<cdk::nuts::AuthProof> for AuthProof {
-    fn from(auth_proof: cdk::nuts::AuthProof) -> Self {
-        Self {
-            keyset_id: auth_proof.keyset_id.to_string(),
-            secret: auth_proof.secret.to_string(),
-            c: auth_proof.c.to_string(),
-            y: auth_proof
-                .y()
-                .map(|y| y.to_string())
-                .unwrap_or_else(|_| "".to_string()),
-        }
-    }
-}
-
-impl TryFrom<AuthProof> for cdk::nuts::AuthProof {
-    type Error = FfiError;
-
-    fn try_from(auth_proof: AuthProof) -> Result<Self, Self::Error> {
-        use std::str::FromStr;
-        Ok(Self {
-            keyset_id: cdk::nuts::Id::from_str(&auth_proof.keyset_id)
-                .map_err(|e| FfiError::Serialization { msg: e.to_string() })?,
-            secret: {
-                use std::str::FromStr;
-                cdk::secret::Secret::from_str(&auth_proof.secret)
-                    .map_err(|e| FfiError::Serialization { msg: e.to_string() })?
-            },
-            c: cdk::nuts::PublicKey::from_str(&auth_proof.c)
-                .map_err(|e| FfiError::InvalidCryptographicKey { msg: e.to_string() })?,
-            dleq: None, // FFI doesn't expose DLEQ proofs for simplicity
-        })
-    }
-}
-
-impl AuthProof {
-    /// Convert AuthProof to JSON string
-    pub fn to_json(&self) -> Result<String, FfiError> {
-        Ok(serde_json::to_string(self)?)
-    }
-}
-
-/// Decode AuthProof from JSON string
-#[uniffi::export]
-pub fn decode_auth_proof(json: String) -> Result<AuthProof, FfiError> {
-    Ok(serde_json::from_str(&json)?)
-}
-
-/// Encode AuthProof to JSON string
-#[uniffi::export]
-pub fn encode_auth_proof(proof: AuthProof) -> Result<String, FfiError> {
-    Ok(serde_json::to_string(&proof)?)
-}
-
-impl TryFrom<SpendingConditions> for cdk::nuts::SpendingConditions {
-    type Error = FfiError;
-
-    fn try_from(spending_conditions: SpendingConditions) -> Result<Self, Self::Error> {
-        match spending_conditions {
-            SpendingConditions::P2PK { pubkey, conditions } => {
-                let pubkey = pubkey
-                    .parse()
-                    .map_err(|e| FfiError::InvalidCryptographicKey {
-                        msg: format!("Invalid pubkey: {}", e),
-                    })?;
-                let conditions = conditions.map(|c| c.try_into()).transpose()?;
-                Ok(Self::P2PKConditions {
-                    data: pubkey,
-                    conditions,
-                })
-            }
-            SpendingConditions::HTLC { hash, conditions } => {
-                let hash = hash
-                    .parse()
-                    .map_err(|e| FfiError::InvalidCryptographicKey {
-                        msg: format!("Invalid hash: {}", e),
-                    })?;
-                let conditions = conditions.map(|c| c.try_into()).transpose()?;
-                Ok(Self::HTLCConditions {
-                    data: hash,
-                    conditions,
-                })
-            }
-        }
-    }
-}
-
-/// FFI-compatible SubscriptionKind
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
-pub enum SubscriptionKind {
-    /// Bolt 11 Melt Quote
-    Bolt11MeltQuote,
-    /// Bolt 11 Mint Quote
-    Bolt11MintQuote,
-    /// Bolt 12 Mint Quote
-    Bolt12MintQuote,
-    /// Proof State
-    ProofState,
-}
-
-impl From<SubscriptionKind> for cdk::nuts::nut17::Kind {
-    fn from(kind: SubscriptionKind) -> Self {
-        match kind {
-            SubscriptionKind::Bolt11MeltQuote => cdk::nuts::nut17::Kind::Bolt11MeltQuote,
-            SubscriptionKind::Bolt11MintQuote => cdk::nuts::nut17::Kind::Bolt11MintQuote,
-            SubscriptionKind::Bolt12MintQuote => cdk::nuts::nut17::Kind::Bolt12MintQuote,
-            SubscriptionKind::ProofState => cdk::nuts::nut17::Kind::ProofState,
-        }
-    }
-}
-
-impl From<cdk::nuts::nut17::Kind> for SubscriptionKind {
-    fn from(kind: cdk::nuts::nut17::Kind) -> Self {
-        match kind {
-            cdk::nuts::nut17::Kind::Bolt11MeltQuote => SubscriptionKind::Bolt11MeltQuote,
-            cdk::nuts::nut17::Kind::Bolt11MintQuote => SubscriptionKind::Bolt11MintQuote,
-            cdk::nuts::nut17::Kind::Bolt12MintQuote => SubscriptionKind::Bolt12MintQuote,
-            cdk::nuts::nut17::Kind::ProofState => SubscriptionKind::ProofState,
-        }
-    }
-}
-
-/// FFI-compatible SubscribeParams
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct SubscribeParams {
-    /// Subscription kind
-    pub kind: SubscriptionKind,
-    /// Filters
-    pub filters: Vec<String>,
-    /// Subscription ID (optional, will be generated if not provided)
-    pub id: Option<String>,
-}
-
-impl From<SubscribeParams> for cdk::nuts::nut17::Params<cdk::pub_sub::SubId> {
-    fn from(params: SubscribeParams) -> Self {
-        let sub_id = params
-            .id
-            .map(|id| SubId::from(id.as_str()))
-            .unwrap_or_else(|| {
-                // Generate a random ID
-                let uuid = uuid::Uuid::new_v4();
-                SubId::from(uuid.to_string().as_str())
-            });
-
-        cdk::nuts::nut17::Params {
-            kind: params.kind.into(),
-            filters: params.filters,
-            id: sub_id,
-        }
-    }
-}
-
-impl SubscribeParams {
-    /// Convert SubscribeParams to JSON string
-    pub fn to_json(&self) -> Result<String, FfiError> {
-        Ok(serde_json::to_string(self)?)
-    }
-}
-
-/// Decode SubscribeParams from JSON string
-#[uniffi::export]
-pub fn decode_subscribe_params(json: String) -> Result<SubscribeParams, FfiError> {
-    Ok(serde_json::from_str(&json)?)
-}
-
-/// Encode SubscribeParams to JSON string
-#[uniffi::export]
-pub fn encode_subscribe_params(params: SubscribeParams) -> Result<String, FfiError> {
-    Ok(serde_json::to_string(&params)?)
-}
-
-/// FFI-compatible ActiveSubscription
-#[derive(uniffi::Object)]
-pub struct ActiveSubscription {
-    inner: std::sync::Arc<tokio::sync::Mutex<cdk::wallet::subscription::ActiveSubscription>>,
-    pub sub_id: String,
-}
-
-impl ActiveSubscription {
-    pub(crate) fn new(
-        inner: cdk::wallet::subscription::ActiveSubscription,
-        sub_id: String,
-    ) -> Self {
-        Self {
-            inner: std::sync::Arc::new(tokio::sync::Mutex::new(inner)),
-            sub_id,
-        }
-    }
-}
-
-#[uniffi::export(async_runtime = "tokio")]
-impl ActiveSubscription {
-    /// Get the subscription ID
-    pub fn id(&self) -> String {
-        self.sub_id.clone()
-    }
-
-    /// Receive the next notification
-    pub async fn recv(&self) -> Result<NotificationPayload, FfiError> {
-        let mut guard = self.inner.lock().await;
-        guard
-            .recv()
-            .await
-            .ok_or(FfiError::Generic {
-                msg: "Subscription closed".to_string(),
-            })
-            .map(Into::into)
-    }
-
-    /// Try to receive a notification without blocking
-    pub async fn try_recv(&self) -> Result<Option<NotificationPayload>, FfiError> {
-        let mut guard = self.inner.lock().await;
-        guard
-            .try_recv()
-            .map(|opt| opt.map(Into::into))
-            .map_err(|e| FfiError::Generic {
-                msg: format!("Failed to receive notification: {}", e),
-            })
-    }
-}
-
-/// FFI-compatible NotificationPayload
-#[derive(Debug, Clone, uniffi::Enum)]
-pub enum NotificationPayload {
-    /// Proof state update
-    ProofState { proof_states: Vec<ProofStateUpdate> },
-    /// Mint quote update
-    MintQuoteUpdate {
-        quote: std::sync::Arc<MintQuoteBolt11Response>,
-    },
-    /// Melt quote update
-    MeltQuoteUpdate {
-        quote: std::sync::Arc<MeltQuoteBolt11Response>,
-    },
-}
-
-impl From<cdk::nuts::NotificationPayload<String>> for NotificationPayload {
-    fn from(payload: cdk::nuts::NotificationPayload<String>) -> Self {
-        match payload {
-            cdk::nuts::NotificationPayload::ProofState(states) => NotificationPayload::ProofState {
-                proof_states: vec![states.into()],
-            },
-            cdk::nuts::NotificationPayload::MintQuoteBolt11Response(quote_resp) => {
-                NotificationPayload::MintQuoteUpdate {
-                    quote: std::sync::Arc::new(quote_resp.into()),
-                }
-            }
-            cdk::nuts::NotificationPayload::MeltQuoteBolt11Response(quote_resp) => {
-                NotificationPayload::MeltQuoteUpdate {
-                    quote: std::sync::Arc::new(quote_resp.into()),
-                }
-            }
-            _ => {
-                // For now, handle other notification types as empty ProofState
-                NotificationPayload::ProofState {
-                    proof_states: vec![],
-                }
-            }
-        }
-    }
-}
-
-/// FFI-compatible ProofStateUpdate
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct ProofStateUpdate {
-    /// Y value (hash_to_curve of secret)
-    pub y: String,
-    /// Current state
-    pub state: ProofState,
-    /// Optional witness data
-    pub witness: Option<String>,
-}
-
-impl From<cdk::nuts::nut07::ProofState> for ProofStateUpdate {
-    fn from(proof_state: cdk::nuts::nut07::ProofState) -> Self {
-        Self {
-            y: proof_state.y.to_string(),
-            state: proof_state.state.into(),
-            witness: proof_state.witness.map(|w| format!("{:?}", w)),
-        }
-    }
-}
-
-impl ProofStateUpdate {
-    /// Convert ProofStateUpdate to JSON string
-    pub fn to_json(&self) -> Result<String, FfiError> {
-        Ok(serde_json::to_string(self)?)
-    }
-}
-
-/// Decode ProofStateUpdate from JSON string
-#[uniffi::export]
-pub fn decode_proof_state_update(json: String) -> Result<ProofStateUpdate, FfiError> {
-    Ok(serde_json::from_str(&json)?)
-}
-
-/// Encode ProofStateUpdate to JSON string
-#[uniffi::export]
-pub fn encode_proof_state_update(update: ProofStateUpdate) -> Result<String, FfiError> {
-    Ok(serde_json::to_string(&update)?)
-}
-
-/// FFI-compatible KeySetInfo
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct KeySetInfo {
-    pub id: String,
-    pub unit: CurrencyUnit,
-    pub active: bool,
-    /// Input fee per thousand (ppk)
-    pub input_fee_ppk: u64,
-}
-
-impl From<cdk::nuts::KeySetInfo> for KeySetInfo {
-    fn from(keyset: cdk::nuts::KeySetInfo) -> Self {
-        Self {
-            id: keyset.id.to_string(),
-            unit: keyset.unit.into(),
-            active: keyset.active,
-            input_fee_ppk: keyset.input_fee_ppk,
-        }
-    }
-}
-
-impl From<KeySetInfo> for cdk::nuts::KeySetInfo {
-    fn from(keyset: KeySetInfo) -> Self {
-        use std::str::FromStr;
-        Self {
-            id: cdk::nuts::Id::from_str(&keyset.id).unwrap(),
-            unit: keyset.unit.into(),
-            active: keyset.active,
-            final_expiry: None,
-            input_fee_ppk: keyset.input_fee_ppk,
-        }
-    }
-}
-
-impl KeySetInfo {
-    /// Convert KeySetInfo to JSON string
-    pub fn to_json(&self) -> Result<String, FfiError> {
-        Ok(serde_json::to_string(self)?)
-    }
-}
-
-/// Decode KeySetInfo from JSON string
-#[uniffi::export]
-pub fn decode_key_set_info(json: String) -> Result<KeySetInfo, FfiError> {
-    Ok(serde_json::from_str(&json)?)
-}
-
-/// Encode KeySetInfo to JSON string
-#[uniffi::export]
-pub fn encode_key_set_info(info: KeySetInfo) -> Result<String, FfiError> {
-    Ok(serde_json::to_string(&info)?)
-}
-
-/// FFI-compatible PublicKey
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-#[serde(transparent)]
-pub struct PublicKey {
-    /// Hex-encoded public key
-    pub hex: String,
-}
-
-impl From<cdk::nuts::PublicKey> for PublicKey {
-    fn from(key: cdk::nuts::PublicKey) -> Self {
-        Self {
-            hex: key.to_string(),
-        }
-    }
-}
-
-impl TryFrom<PublicKey> for cdk::nuts::PublicKey {
-    type Error = FfiError;
-
-    fn try_from(key: PublicKey) -> Result<Self, Self::Error> {
-        key.hex
-            .parse()
-            .map_err(|e| FfiError::InvalidCryptographicKey {
-                msg: format!("Invalid public key: {}", e),
-            })
-    }
-}
-
-/// FFI-compatible Keys (simplified - contains only essential info)
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct Keys {
-    /// Keyset ID
-    pub id: String,
-    /// Currency unit
-    pub unit: CurrencyUnit,
-    /// Map of amount to public key hex (simplified from BTreeMap)
-    pub keys: HashMap<u64, String>,
-}
-
-impl From<cdk::nuts::Keys> for Keys {
-    fn from(keys: cdk::nuts::Keys) -> Self {
-        // Keys doesn't have id and unit - we'll need to get these from context
-        // For now, use placeholder values
-        Self {
-            id: "unknown".to_string(), // This should come from KeySet
-            unit: CurrencyUnit::Sat,   // This should come from KeySet
-            keys: keys
-                .keys()
-                .iter()
-                .map(|(amount, pubkey)| (u64::from(*amount), pubkey.to_string()))
-                .collect(),
-        }
-    }
-}
-
-impl TryFrom<Keys> for cdk::nuts::Keys {
-    type Error = FfiError;
-
-    fn try_from(keys: Keys) -> Result<Self, Self::Error> {
-        use std::collections::BTreeMap;
-        use std::str::FromStr;
-
-        // Convert the HashMap to BTreeMap with proper types
-        let mut keys_map = BTreeMap::new();
-        for (amount_u64, pubkey_hex) in keys.keys {
-            let amount = cdk::Amount::from(amount_u64);
-            let pubkey = cdk::nuts::PublicKey::from_str(&pubkey_hex)
-                .map_err(|e| FfiError::InvalidCryptographicKey { msg: e.to_string() })?;
-            keys_map.insert(amount, pubkey);
-        }
-
-        Ok(cdk::nuts::Keys::new(keys_map))
-    }
-}
-
-impl Keys {
-    /// Convert Keys to JSON string
-    pub fn to_json(&self) -> Result<String, FfiError> {
-        Ok(serde_json::to_string(self)?)
-    }
-}
-
-/// Decode Keys from JSON string
-#[uniffi::export]
-pub fn decode_keys(json: String) -> Result<Keys, FfiError> {
-    Ok(serde_json::from_str(&json)?)
-}
-
-/// Encode Keys to JSON string
-#[uniffi::export]
-pub fn encode_keys(keys: Keys) -> Result<String, FfiError> {
-    Ok(serde_json::to_string(&keys)?)
-}
-
-/// FFI-compatible KeySet
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-pub struct KeySet {
-    /// Keyset ID
-    pub id: String,
-    /// Currency unit  
-    pub unit: CurrencyUnit,
-    /// The keys (map of amount to public key hex)
-    pub keys: HashMap<u64, String>,
-    /// Optional expiry timestamp
-    pub final_expiry: Option<u64>,
-}
-
-impl From<cdk::nuts::KeySet> for KeySet {
-    fn from(keyset: cdk::nuts::KeySet) -> Self {
-        Self {
-            id: keyset.id.to_string(),
-            unit: keyset.unit.into(),
-            keys: keyset
-                .keys
-                .keys()
-                .iter()
-                .map(|(amount, pubkey)| (u64::from(*amount), pubkey.to_string()))
-                .collect(),
-            final_expiry: keyset.final_expiry,
-        }
-    }
-}
-
-impl TryFrom<KeySet> for cdk::nuts::KeySet {
-    type Error = FfiError;
-
-    fn try_from(keyset: KeySet) -> Result<Self, Self::Error> {
-        use std::collections::BTreeMap;
-        use std::str::FromStr;
-
-        // Convert id
-        let id = cdk::nuts::Id::from_str(&keyset.id)
-            .map_err(|e| FfiError::Serialization { msg: e.to_string() })?;
-
-        // Convert unit
-        let unit: cdk::nuts::CurrencyUnit = keyset.unit.into();
-
-        // Convert keys
-        let mut keys_map = BTreeMap::new();
-        for (amount_u64, pubkey_hex) in keyset.keys {
-            let amount = cdk::Amount::from(amount_u64);
-            let pubkey = cdk::nuts::PublicKey::from_str(&pubkey_hex)
-                .map_err(|e| FfiError::InvalidCryptographicKey { msg: e.to_string() })?;
-            keys_map.insert(amount, pubkey);
-        }
-        let keys = cdk::nuts::Keys::new(keys_map);
-
-        Ok(cdk::nuts::KeySet {
-            id,
-            unit,
-            keys,
-            final_expiry: keyset.final_expiry,
-        })
-    }
-}
-
-impl KeySet {
-    /// Convert KeySet to JSON string
-    pub fn to_json(&self) -> Result<String, FfiError> {
-        Ok(serde_json::to_string(self)?)
-    }
-}
-
-/// Decode KeySet from JSON string
-#[uniffi::export]
-pub fn decode_key_set(json: String) -> Result<KeySet, FfiError> {
-    Ok(serde_json::from_str(&json)?)
-}
-
-/// Encode KeySet to JSON string
-#[uniffi::export]
-pub fn encode_key_set(keyset: KeySet) -> Result<String, FfiError> {
-    Ok(serde_json::to_string(&keyset)?)
-}
-
-/// FFI-compatible ProofInfo
-#[derive(Debug, Clone, uniffi::Record)]
-pub struct ProofInfo {
-    /// Proof
-    pub proof: std::sync::Arc<Proof>,
-    /// Y value (hash_to_curve of secret)
-    pub y: PublicKey,
-    /// Mint URL
-    pub mint_url: MintUrl,
-    /// Proof state
-    pub state: ProofState,
-    /// Proof Spending Conditions
-    pub spending_condition: Option<SpendingConditions>,
-    /// Currency unit
-    pub unit: CurrencyUnit,
-}
-
-impl From<cdk::types::ProofInfo> for ProofInfo {
-    fn from(info: cdk::types::ProofInfo) -> Self {
-        Self {
-            proof: std::sync::Arc::new(info.proof.into()),
-            y: info.y.into(),
-            mint_url: info.mint_url.into(),
-            state: info.state.into(),
-            spending_condition: info.spending_condition.map(Into::into),
-            unit: info.unit.into(),
-        }
-    }
-}
-
-/// Decode ProofInfo from JSON string
-#[uniffi::export]
-pub fn decode_proof_info(json: String) -> Result<ProofInfo, FfiError> {
-    let info: cdk::types::ProofInfo = serde_json::from_str(&json)?;
-    Ok(info.into())
-}
-
-/// Encode ProofInfo to JSON string
-#[uniffi::export]
-pub fn encode_proof_info(info: ProofInfo) -> Result<String, FfiError> {
-    // Convert to cdk::types::ProofInfo for serialization
-    let cdk_info = 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.and_then(|c| c.try_into().ok()),
-        unit: info.unit.into(),
-    };
-    Ok(serde_json::to_string(&cdk_info)?)
-}
-
-// State enum removed - using ProofState instead
-
-/// FFI-compatible Id (for keyset IDs)
-#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
-#[serde(transparent)]
-pub struct Id {
-    pub hex: String,
-}
-
-impl From<cdk::nuts::Id> for Id {
-    fn from(id: cdk::nuts::Id) -> Self {
-        Self {
-            hex: id.to_string(),
-        }
-    }
-}
-
-impl From<Id> for cdk::nuts::Id {
-    fn from(id: Id) -> Self {
-        use std::str::FromStr;
-        Self::from_str(&id.hex).unwrap()
-    }
-}

+ 166 - 0
crates/cdk-ffi/src/types/amount.rs

@@ -0,0 +1,166 @@
+//! Amount and currency related types
+
+use cdk::nuts::CurrencyUnit as CdkCurrencyUnit;
+use cdk::Amount as CdkAmount;
+use serde::{Deserialize, Serialize};
+
+use crate::error::FfiError;
+
+/// FFI-compatible Amount type
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
+#[serde(transparent)]
+pub struct Amount {
+    pub value: u64,
+}
+
+impl Amount {
+    pub fn new(value: u64) -> Self {
+        Self { value }
+    }
+
+    pub fn zero() -> Self {
+        Self { value: 0 }
+    }
+
+    pub fn is_zero(&self) -> bool {
+        self.value == 0
+    }
+
+    pub fn convert_unit(
+        &self,
+        current_unit: CurrencyUnit,
+        target_unit: CurrencyUnit,
+    ) -> Result<Amount, FfiError> {
+        Ok(CdkAmount::from(self.value)
+            .convert_unit(&current_unit.into(), &target_unit.into())
+            .map(Into::into)?)
+    }
+
+    pub fn add(&self, other: Amount) -> Result<Amount, FfiError> {
+        let self_amount = CdkAmount::from(self.value);
+        let other_amount = CdkAmount::from(other.value);
+        self_amount
+            .checked_add(other_amount)
+            .map(Into::into)
+            .ok_or(FfiError::AmountOverflow)
+    }
+
+    pub fn subtract(&self, other: Amount) -> Result<Amount, FfiError> {
+        let self_amount = CdkAmount::from(self.value);
+        let other_amount = CdkAmount::from(other.value);
+        self_amount
+            .checked_sub(other_amount)
+            .map(Into::into)
+            .ok_or(FfiError::AmountOverflow)
+    }
+
+    pub fn multiply(&self, factor: u64) -> Result<Amount, FfiError> {
+        let self_amount = CdkAmount::from(self.value);
+        let factor_amount = CdkAmount::from(factor);
+        self_amount
+            .checked_mul(factor_amount)
+            .map(Into::into)
+            .ok_or(FfiError::AmountOverflow)
+    }
+
+    pub fn divide(&self, divisor: u64) -> Result<Amount, FfiError> {
+        if divisor == 0 {
+            return Err(FfiError::DivisionByZero);
+        }
+        let self_amount = CdkAmount::from(self.value);
+        let divisor_amount = CdkAmount::from(divisor);
+        self_amount
+            .checked_div(divisor_amount)
+            .map(Into::into)
+            .ok_or(FfiError::AmountOverflow)
+    }
+}
+
+impl From<CdkAmount> for Amount {
+    fn from(amount: CdkAmount) -> Self {
+        Self {
+            value: u64::from(amount),
+        }
+    }
+}
+
+impl From<Amount> for CdkAmount {
+    fn from(amount: Amount) -> Self {
+        CdkAmount::from(amount.value)
+    }
+}
+
+/// FFI-compatible Currency Unit
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
+pub enum CurrencyUnit {
+    Sat,
+    Msat,
+    Usd,
+    Eur,
+    Auth,
+    Custom { unit: String },
+}
+
+impl From<CdkCurrencyUnit> for CurrencyUnit {
+    fn from(unit: CdkCurrencyUnit) -> Self {
+        match unit {
+            CdkCurrencyUnit::Sat => CurrencyUnit::Sat,
+            CdkCurrencyUnit::Msat => CurrencyUnit::Msat,
+            CdkCurrencyUnit::Usd => CurrencyUnit::Usd,
+            CdkCurrencyUnit::Eur => CurrencyUnit::Eur,
+            CdkCurrencyUnit::Auth => CurrencyUnit::Auth,
+            CdkCurrencyUnit::Custom(s) => CurrencyUnit::Custom { unit: s },
+            _ => CurrencyUnit::Sat, // Default for unknown units
+        }
+    }
+}
+
+impl From<CurrencyUnit> for CdkCurrencyUnit {
+    fn from(unit: CurrencyUnit) -> Self {
+        match unit {
+            CurrencyUnit::Sat => CdkCurrencyUnit::Sat,
+            CurrencyUnit::Msat => CdkCurrencyUnit::Msat,
+            CurrencyUnit::Usd => CdkCurrencyUnit::Usd,
+            CurrencyUnit::Eur => CdkCurrencyUnit::Eur,
+            CurrencyUnit::Auth => CdkCurrencyUnit::Auth,
+            CurrencyUnit::Custom { unit } => CdkCurrencyUnit::Custom(unit),
+        }
+    }
+}
+
+/// FFI-compatible SplitTarget
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)]
+pub enum SplitTarget {
+    /// Default target; least amount of proofs
+    None,
+    /// Target amount for wallet to have most proofs that add up to value
+    Value { amount: Amount },
+    /// Specific amounts to split into (must equal amount being split)
+    Values { amounts: Vec<Amount> },
+}
+
+impl From<SplitTarget> for cdk::amount::SplitTarget {
+    fn from(target: SplitTarget) -> Self {
+        match target {
+            SplitTarget::None => cdk::amount::SplitTarget::None,
+            SplitTarget::Value { amount } => cdk::amount::SplitTarget::Value(amount.into()),
+            SplitTarget::Values { amounts } => {
+                cdk::amount::SplitTarget::Values(amounts.into_iter().map(Into::into).collect())
+            }
+        }
+    }
+}
+
+impl From<cdk::amount::SplitTarget> for SplitTarget {
+    fn from(target: cdk::amount::SplitTarget) -> Self {
+        match target {
+            cdk::amount::SplitTarget::None => SplitTarget::None,
+            cdk::amount::SplitTarget::Value(amount) => SplitTarget::Value {
+                amount: amount.into(),
+            },
+            cdk::amount::SplitTarget::Values(amounts) => SplitTarget::Values {
+                amounts: amounts.into_iter().map(Into::into).collect(),
+            },
+        }
+    }
+}

+ 258 - 0
crates/cdk-ffi/src/types/keys.rs

@@ -0,0 +1,258 @@
+//! Key-related FFI types
+
+use std::collections::HashMap;
+
+use serde::{Deserialize, Serialize};
+
+use super::amount::CurrencyUnit;
+use crate::error::FfiError;
+
+/// FFI-compatible KeySetInfo
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct KeySetInfo {
+    pub id: String,
+    pub unit: CurrencyUnit,
+    pub active: bool,
+    /// Input fee per thousand (ppk)
+    pub input_fee_ppk: u64,
+}
+
+impl From<cdk::nuts::KeySetInfo> for KeySetInfo {
+    fn from(keyset: cdk::nuts::KeySetInfo) -> Self {
+        Self {
+            id: keyset.id.to_string(),
+            unit: keyset.unit.into(),
+            active: keyset.active,
+            input_fee_ppk: keyset.input_fee_ppk,
+        }
+    }
+}
+
+impl From<KeySetInfo> for cdk::nuts::KeySetInfo {
+    fn from(keyset: KeySetInfo) -> Self {
+        use std::str::FromStr;
+        Self {
+            id: cdk::nuts::Id::from_str(&keyset.id).unwrap(),
+            unit: keyset.unit.into(),
+            active: keyset.active,
+            final_expiry: None,
+            input_fee_ppk: keyset.input_fee_ppk,
+        }
+    }
+}
+
+impl KeySetInfo {
+    /// Convert KeySetInfo to JSON string
+    pub fn to_json(&self) -> Result<String, FfiError> {
+        Ok(serde_json::to_string(self)?)
+    }
+}
+
+/// Decode KeySetInfo from JSON string
+#[uniffi::export]
+pub fn decode_key_set_info(json: String) -> Result<KeySetInfo, FfiError> {
+    Ok(serde_json::from_str(&json)?)
+}
+
+/// Encode KeySetInfo to JSON string
+#[uniffi::export]
+pub fn encode_key_set_info(info: KeySetInfo) -> Result<String, FfiError> {
+    Ok(serde_json::to_string(&info)?)
+}
+
+/// FFI-compatible PublicKey
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+#[serde(transparent)]
+pub struct PublicKey {
+    /// Hex-encoded public key
+    pub hex: String,
+}
+
+impl From<cdk::nuts::PublicKey> for PublicKey {
+    fn from(key: cdk::nuts::PublicKey) -> Self {
+        Self {
+            hex: key.to_string(),
+        }
+    }
+}
+
+impl TryFrom<PublicKey> for cdk::nuts::PublicKey {
+    type Error = FfiError;
+
+    fn try_from(key: PublicKey) -> Result<Self, Self::Error> {
+        key.hex
+            .parse()
+            .map_err(|e| FfiError::InvalidCryptographicKey {
+                msg: format!("Invalid public key: {}", e),
+            })
+    }
+}
+
+/// FFI-compatible Keys (simplified - contains only essential info)
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct Keys {
+    /// Keyset ID
+    pub id: String,
+    /// Currency unit
+    pub unit: CurrencyUnit,
+    /// Map of amount to public key hex (simplified from BTreeMap)
+    pub keys: HashMap<u64, String>,
+}
+
+impl From<cdk::nuts::Keys> for Keys {
+    fn from(keys: cdk::nuts::Keys) -> Self {
+        // Keys doesn't have id and unit - we'll need to get these from context
+        // For now, use placeholder values
+        Self {
+            id: "unknown".to_string(), // This should come from KeySet
+            unit: CurrencyUnit::Sat,   // This should come from KeySet
+            keys: keys
+                .keys()
+                .iter()
+                .map(|(amount, pubkey)| (u64::from(*amount), pubkey.to_string()))
+                .collect(),
+        }
+    }
+}
+
+impl TryFrom<Keys> for cdk::nuts::Keys {
+    type Error = FfiError;
+
+    fn try_from(keys: Keys) -> Result<Self, Self::Error> {
+        use std::collections::BTreeMap;
+        use std::str::FromStr;
+
+        // Convert the HashMap to BTreeMap with proper types
+        let mut keys_map = BTreeMap::new();
+        for (amount_u64, pubkey_hex) in keys.keys {
+            let amount = cdk::Amount::from(amount_u64);
+            let pubkey = cdk::nuts::PublicKey::from_str(&pubkey_hex)
+                .map_err(|e| FfiError::InvalidCryptographicKey { msg: e.to_string() })?;
+            keys_map.insert(amount, pubkey);
+        }
+
+        Ok(cdk::nuts::Keys::new(keys_map))
+    }
+}
+
+impl Keys {
+    /// Convert Keys to JSON string
+    pub fn to_json(&self) -> Result<String, FfiError> {
+        Ok(serde_json::to_string(self)?)
+    }
+}
+
+/// Decode Keys from JSON string
+#[uniffi::export]
+pub fn decode_keys(json: String) -> Result<Keys, FfiError> {
+    Ok(serde_json::from_str(&json)?)
+}
+
+/// Encode Keys to JSON string
+#[uniffi::export]
+pub fn encode_keys(keys: Keys) -> Result<String, FfiError> {
+    Ok(serde_json::to_string(&keys)?)
+}
+
+/// FFI-compatible KeySet
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct KeySet {
+    /// Keyset ID
+    pub id: String,
+    /// Currency unit
+    pub unit: CurrencyUnit,
+    /// The keys (map of amount to public key hex)
+    pub keys: HashMap<u64, String>,
+    /// Optional expiry timestamp
+    pub final_expiry: Option<u64>,
+}
+
+impl From<cdk::nuts::KeySet> for KeySet {
+    fn from(keyset: cdk::nuts::KeySet) -> Self {
+        Self {
+            id: keyset.id.to_string(),
+            unit: keyset.unit.into(),
+            keys: keyset
+                .keys
+                .keys()
+                .iter()
+                .map(|(amount, pubkey)| (u64::from(*amount), pubkey.to_string()))
+                .collect(),
+            final_expiry: keyset.final_expiry,
+        }
+    }
+}
+
+impl TryFrom<KeySet> for cdk::nuts::KeySet {
+    type Error = FfiError;
+
+    fn try_from(keyset: KeySet) -> Result<Self, Self::Error> {
+        use std::collections::BTreeMap;
+        use std::str::FromStr;
+
+        // Convert id
+        let id = cdk::nuts::Id::from_str(&keyset.id)
+            .map_err(|e| FfiError::Serialization { msg: e.to_string() })?;
+
+        // Convert unit
+        let unit: cdk::nuts::CurrencyUnit = keyset.unit.into();
+
+        // Convert keys
+        let mut keys_map = BTreeMap::new();
+        for (amount_u64, pubkey_hex) in keyset.keys {
+            let amount = cdk::Amount::from(amount_u64);
+            let pubkey = cdk::nuts::PublicKey::from_str(&pubkey_hex)
+                .map_err(|e| FfiError::InvalidCryptographicKey { msg: e.to_string() })?;
+            keys_map.insert(amount, pubkey);
+        }
+        let keys = cdk::nuts::Keys::new(keys_map);
+
+        Ok(cdk::nuts::KeySet {
+            id,
+            unit,
+            keys,
+            final_expiry: keyset.final_expiry,
+        })
+    }
+}
+
+impl KeySet {
+    /// Convert KeySet to JSON string
+    pub fn to_json(&self) -> Result<String, FfiError> {
+        Ok(serde_json::to_string(self)?)
+    }
+}
+
+/// Decode KeySet from JSON string
+#[uniffi::export]
+pub fn decode_key_set(json: String) -> Result<KeySet, FfiError> {
+    Ok(serde_json::from_str(&json)?)
+}
+
+/// Encode KeySet to JSON string
+#[uniffi::export]
+pub fn encode_key_set(keyset: KeySet) -> Result<String, FfiError> {
+    Ok(serde_json::to_string(&keyset)?)
+}
+
+/// FFI-compatible Id (for keyset IDs)
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+#[serde(transparent)]
+pub struct Id {
+    pub hex: String,
+}
+
+impl From<cdk::nuts::Id> for Id {
+    fn from(id: cdk::nuts::Id) -> Self {
+        Self {
+            hex: id.to_string(),
+        }
+    }
+}
+
+impl From<Id> for cdk::nuts::Id {
+    fn from(id: Id) -> Self {
+        use std::str::FromStr;
+        Self::from_str(&id.hex).unwrap()
+    }
+}

+ 1012 - 0
crates/cdk-ffi/src/types/mint.rs

@@ -0,0 +1,1012 @@
+//! Mint-related FFI types
+
+use std::str::FromStr;
+
+use serde::{Deserialize, Serialize};
+
+use super::amount::{Amount, CurrencyUnit};
+use super::quote::PaymentMethod;
+use crate::error::FfiError;
+
+/// FFI-compatible Mint URL
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, uniffi::Record)]
+#[serde(transparent)]
+pub struct MintUrl {
+    pub url: String,
+}
+
+impl MintUrl {
+    pub fn new(url: String) -> Result<Self, FfiError> {
+        // Validate URL format
+        url::Url::parse(&url).map_err(|e| FfiError::InvalidUrl { msg: e.to_string() })?;
+
+        Ok(Self { url })
+    }
+}
+
+impl From<cdk::mint_url::MintUrl> for MintUrl {
+    fn from(mint_url: cdk::mint_url::MintUrl) -> Self {
+        Self {
+            url: mint_url.to_string(),
+        }
+    }
+}
+
+impl TryFrom<MintUrl> for cdk::mint_url::MintUrl {
+    type Error = FfiError;
+
+    fn try_from(mint_url: MintUrl) -> Result<Self, Self::Error> {
+        cdk::mint_url::MintUrl::from_str(&mint_url.url)
+            .map_err(|e| FfiError::InvalidUrl { msg: e.to_string() })
+    }
+}
+
+/// FFI-compatible MintVersion
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct MintVersion {
+    /// Mint Software name
+    pub name: String,
+    /// Mint Version
+    pub version: String,
+}
+
+impl From<cdk::nuts::MintVersion> for MintVersion {
+    fn from(version: cdk::nuts::MintVersion) -> Self {
+        Self {
+            name: version.name,
+            version: version.version,
+        }
+    }
+}
+
+impl From<MintVersion> for cdk::nuts::MintVersion {
+    fn from(version: MintVersion) -> Self {
+        Self {
+            name: version.name,
+            version: version.version,
+        }
+    }
+}
+
+impl MintVersion {
+    /// Convert MintVersion to JSON string
+    pub fn to_json(&self) -> Result<String, FfiError> {
+        Ok(serde_json::to_string(self)?)
+    }
+}
+
+/// Decode MintVersion from JSON string
+#[uniffi::export]
+pub fn decode_mint_version(json: String) -> Result<MintVersion, FfiError> {
+    Ok(serde_json::from_str(&json)?)
+}
+
+/// Encode MintVersion to JSON string
+#[uniffi::export]
+pub fn encode_mint_version(version: MintVersion) -> Result<String, FfiError> {
+    Ok(serde_json::to_string(&version)?)
+}
+
+/// FFI-compatible ContactInfo
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct ContactInfo {
+    /// Contact Method i.e. nostr
+    pub method: String,
+    /// Contact info i.e. npub...
+    pub info: String,
+}
+
+impl From<cdk::nuts::ContactInfo> for ContactInfo {
+    fn from(contact: cdk::nuts::ContactInfo) -> Self {
+        Self {
+            method: contact.method,
+            info: contact.info,
+        }
+    }
+}
+
+impl From<ContactInfo> for cdk::nuts::ContactInfo {
+    fn from(contact: ContactInfo) -> Self {
+        Self {
+            method: contact.method,
+            info: contact.info,
+        }
+    }
+}
+
+impl ContactInfo {
+    /// Convert ContactInfo to JSON string
+    pub fn to_json(&self) -> Result<String, FfiError> {
+        Ok(serde_json::to_string(self)?)
+    }
+}
+
+/// Decode ContactInfo from JSON string
+#[uniffi::export]
+pub fn decode_contact_info(json: String) -> Result<ContactInfo, FfiError> {
+    Ok(serde_json::from_str(&json)?)
+}
+
+/// Encode ContactInfo to JSON string
+#[uniffi::export]
+pub fn encode_contact_info(info: ContactInfo) -> Result<String, FfiError> {
+    Ok(serde_json::to_string(&info)?)
+}
+
+/// FFI-compatible SupportedSettings
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+#[serde(transparent)]
+pub struct SupportedSettings {
+    /// Setting supported
+    pub supported: bool,
+}
+
+impl From<cdk::nuts::nut06::SupportedSettings> for SupportedSettings {
+    fn from(settings: cdk::nuts::nut06::SupportedSettings) -> Self {
+        Self {
+            supported: settings.supported,
+        }
+    }
+}
+
+impl From<SupportedSettings> for cdk::nuts::nut06::SupportedSettings {
+    fn from(settings: SupportedSettings) -> Self {
+        Self {
+            supported: settings.supported,
+        }
+    }
+}
+
+// -----------------------------
+// NUT-04/05 FFI Types
+// -----------------------------
+
+/// FFI-compatible MintMethodSettings (NUT-04)
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct MintMethodSettings {
+    pub method: PaymentMethod,
+    pub unit: CurrencyUnit,
+    pub min_amount: Option<Amount>,
+    pub max_amount: Option<Amount>,
+    /// For bolt11, whether mint supports setting invoice description
+    pub description: Option<bool>,
+}
+
+impl From<cdk::nuts::nut04::MintMethodSettings> for MintMethodSettings {
+    fn from(s: cdk::nuts::nut04::MintMethodSettings) -> Self {
+        let description = match s.options {
+            Some(cdk::nuts::nut04::MintMethodOptions::Bolt11 { description }) => Some(description),
+            _ => None,
+        };
+        Self {
+            method: s.method.into(),
+            unit: s.unit.into(),
+            min_amount: s.min_amount.map(Into::into),
+            max_amount: s.max_amount.map(Into::into),
+            description,
+        }
+    }
+}
+
+impl TryFrom<MintMethodSettings> for cdk::nuts::nut04::MintMethodSettings {
+    type Error = FfiError;
+
+    fn try_from(s: MintMethodSettings) -> Result<Self, Self::Error> {
+        let options = match (s.method.clone(), s.description) {
+            (PaymentMethod::Bolt11, Some(description)) => {
+                Some(cdk::nuts::nut04::MintMethodOptions::Bolt11 { description })
+            }
+            _ => None,
+        };
+        Ok(Self {
+            method: s.method.into(),
+            unit: s.unit.into(),
+            min_amount: s.min_amount.map(Into::into),
+            max_amount: s.max_amount.map(Into::into),
+            options,
+        })
+    }
+}
+
+/// FFI-compatible Nut04 Settings
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct Nut04Settings {
+    pub methods: Vec<MintMethodSettings>,
+    pub disabled: bool,
+}
+
+impl From<cdk::nuts::nut04::Settings> for Nut04Settings {
+    fn from(s: cdk::nuts::nut04::Settings) -> Self {
+        Self {
+            methods: s.methods.into_iter().map(Into::into).collect(),
+            disabled: s.disabled,
+        }
+    }
+}
+
+impl TryFrom<Nut04Settings> for cdk::nuts::nut04::Settings {
+    type Error = FfiError;
+
+    fn try_from(s: Nut04Settings) -> Result<Self, Self::Error> {
+        Ok(Self {
+            methods: s
+                .methods
+                .into_iter()
+                .map(TryInto::try_into)
+                .collect::<Result<_, _>>()?,
+            disabled: s.disabled,
+        })
+    }
+}
+
+/// FFI-compatible MeltMethodSettings (NUT-05)
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct MeltMethodSettings {
+    pub method: PaymentMethod,
+    pub unit: CurrencyUnit,
+    pub min_amount: Option<Amount>,
+    pub max_amount: Option<Amount>,
+    /// For bolt11, whether mint supports amountless invoices
+    pub amountless: Option<bool>,
+}
+
+impl From<cdk::nuts::nut05::MeltMethodSettings> for MeltMethodSettings {
+    fn from(s: cdk::nuts::nut05::MeltMethodSettings) -> Self {
+        let amountless = match s.options {
+            Some(cdk::nuts::nut05::MeltMethodOptions::Bolt11 { amountless }) => Some(amountless),
+            _ => None,
+        };
+        Self {
+            method: s.method.into(),
+            unit: s.unit.into(),
+            min_amount: s.min_amount.map(Into::into),
+            max_amount: s.max_amount.map(Into::into),
+            amountless,
+        }
+    }
+}
+
+impl TryFrom<MeltMethodSettings> for cdk::nuts::nut05::MeltMethodSettings {
+    type Error = FfiError;
+
+    fn try_from(s: MeltMethodSettings) -> Result<Self, Self::Error> {
+        let options = match (s.method.clone(), s.amountless) {
+            (PaymentMethod::Bolt11, Some(amountless)) => {
+                Some(cdk::nuts::nut05::MeltMethodOptions::Bolt11 { amountless })
+            }
+            _ => None,
+        };
+        Ok(Self {
+            method: s.method.into(),
+            unit: s.unit.into(),
+            min_amount: s.min_amount.map(Into::into),
+            max_amount: s.max_amount.map(Into::into),
+            options,
+        })
+    }
+}
+
+/// FFI-compatible Nut05 Settings
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct Nut05Settings {
+    pub methods: Vec<MeltMethodSettings>,
+    pub disabled: bool,
+}
+
+impl From<cdk::nuts::nut05::Settings> for Nut05Settings {
+    fn from(s: cdk::nuts::nut05::Settings) -> Self {
+        Self {
+            methods: s.methods.into_iter().map(Into::into).collect(),
+            disabled: s.disabled,
+        }
+    }
+}
+
+impl TryFrom<Nut05Settings> for cdk::nuts::nut05::Settings {
+    type Error = FfiError;
+
+    fn try_from(s: Nut05Settings) -> Result<Self, Self::Error> {
+        Ok(Self {
+            methods: s
+                .methods
+                .into_iter()
+                .map(TryInto::try_into)
+                .collect::<Result<_, _>>()?,
+            disabled: s.disabled,
+        })
+    }
+}
+
+/// FFI-compatible ProtectedEndpoint (for auth nuts)
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct ProtectedEndpoint {
+    /// HTTP method (GET, POST, etc.)
+    pub method: String,
+    /// Endpoint path
+    pub path: String,
+}
+
+/// FFI-compatible ClearAuthSettings (NUT-21)
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct ClearAuthSettings {
+    /// OpenID Connect discovery URL
+    pub openid_discovery: String,
+    /// OAuth 2.0 client ID
+    pub client_id: String,
+    /// Protected endpoints requiring clear authentication
+    pub protected_endpoints: Vec<ProtectedEndpoint>,
+}
+
+/// FFI-compatible BlindAuthSettings (NUT-22)
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct BlindAuthSettings {
+    /// Maximum number of blind auth tokens that can be minted per request
+    pub bat_max_mint: u64,
+    /// Protected endpoints requiring blind authentication
+    pub protected_endpoints: Vec<ProtectedEndpoint>,
+}
+
+impl From<cdk::nuts::ClearAuthSettings> for ClearAuthSettings {
+    fn from(settings: cdk::nuts::ClearAuthSettings) -> Self {
+        Self {
+            openid_discovery: settings.openid_discovery,
+            client_id: settings.client_id,
+            protected_endpoints: settings
+                .protected_endpoints
+                .into_iter()
+                .map(Into::into)
+                .collect(),
+        }
+    }
+}
+
+impl TryFrom<ClearAuthSettings> for cdk::nuts::ClearAuthSettings {
+    type Error = FfiError;
+
+    fn try_from(settings: ClearAuthSettings) -> Result<Self, Self::Error> {
+        Ok(Self {
+            openid_discovery: settings.openid_discovery,
+            client_id: settings.client_id,
+            protected_endpoints: settings
+                .protected_endpoints
+                .into_iter()
+                .map(|e| e.try_into())
+                .collect::<Result<Vec<_>, _>>()?,
+        })
+    }
+}
+
+impl From<cdk::nuts::BlindAuthSettings> for BlindAuthSettings {
+    fn from(settings: cdk::nuts::BlindAuthSettings) -> Self {
+        Self {
+            bat_max_mint: settings.bat_max_mint,
+            protected_endpoints: settings
+                .protected_endpoints
+                .into_iter()
+                .map(Into::into)
+                .collect(),
+        }
+    }
+}
+
+impl TryFrom<BlindAuthSettings> for cdk::nuts::BlindAuthSettings {
+    type Error = FfiError;
+
+    fn try_from(settings: BlindAuthSettings) -> Result<Self, Self::Error> {
+        Ok(Self {
+            bat_max_mint: settings.bat_max_mint,
+            protected_endpoints: settings
+                .protected_endpoints
+                .into_iter()
+                .map(|e| e.try_into())
+                .collect::<Result<Vec<_>, _>>()?,
+        })
+    }
+}
+
+impl From<cdk::nuts::ProtectedEndpoint> for ProtectedEndpoint {
+    fn from(endpoint: cdk::nuts::ProtectedEndpoint) -> Self {
+        Self {
+            method: match endpoint.method {
+                cdk::nuts::Method::Get => "GET".to_string(),
+                cdk::nuts::Method::Post => "POST".to_string(),
+            },
+            path: endpoint.path.to_string(),
+        }
+    }
+}
+
+impl TryFrom<ProtectedEndpoint> for cdk::nuts::ProtectedEndpoint {
+    type Error = FfiError;
+
+    fn try_from(endpoint: ProtectedEndpoint) -> Result<Self, Self::Error> {
+        let method = match endpoint.method.as_str() {
+            "GET" => cdk::nuts::Method::Get,
+            "POST" => cdk::nuts::Method::Post,
+            _ => {
+                return Err(FfiError::Generic {
+                    msg: format!(
+                        "Invalid HTTP method: {}. Only GET and POST are supported",
+                        endpoint.method
+                    ),
+                })
+            }
+        };
+
+        // Convert path string to RoutePath by matching against known paths
+        let route_path = match endpoint.path.as_str() {
+            "/v1/mint/quote/bolt11" => cdk::nuts::RoutePath::MintQuoteBolt11,
+            "/v1/mint/bolt11" => cdk::nuts::RoutePath::MintBolt11,
+            "/v1/melt/quote/bolt11" => cdk::nuts::RoutePath::MeltQuoteBolt11,
+            "/v1/melt/bolt11" => cdk::nuts::RoutePath::MeltBolt11,
+            "/v1/swap" => cdk::nuts::RoutePath::Swap,
+            "/v1/ws" => cdk::nuts::RoutePath::Ws,
+            "/v1/checkstate" => cdk::nuts::RoutePath::Checkstate,
+            "/v1/restore" => cdk::nuts::RoutePath::Restore,
+            "/v1/auth/blind/mint" => cdk::nuts::RoutePath::MintBlindAuth,
+            "/v1/mint/quote/bolt12" => cdk::nuts::RoutePath::MintQuoteBolt12,
+            "/v1/mint/bolt12" => cdk::nuts::RoutePath::MintBolt12,
+            "/v1/melt/quote/bolt12" => cdk::nuts::RoutePath::MeltQuoteBolt12,
+            "/v1/melt/bolt12" => cdk::nuts::RoutePath::MeltBolt12,
+            _ => {
+                return Err(FfiError::Generic {
+                    msg: format!("Unknown route path: {}", endpoint.path),
+                })
+            }
+        };
+
+        Ok(cdk::nuts::ProtectedEndpoint::new(method, route_path))
+    }
+}
+
+/// FFI-compatible Nuts settings (extended to include NUT-04 and NUT-05 settings)
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct Nuts {
+    /// NUT04 Settings
+    pub nut04: Nut04Settings,
+    /// NUT05 Settings
+    pub nut05: Nut05Settings,
+    /// NUT07 Settings - Token state check
+    pub nut07_supported: bool,
+    /// NUT08 Settings - Lightning fee return
+    pub nut08_supported: bool,
+    /// NUT09 Settings - Restore signature
+    pub nut09_supported: bool,
+    /// NUT10 Settings - Spending conditions
+    pub nut10_supported: bool,
+    /// NUT11 Settings - Pay to Public Key Hash
+    pub nut11_supported: bool,
+    /// NUT12 Settings - DLEQ proofs
+    pub nut12_supported: bool,
+    /// NUT14 Settings - Hashed Time Locked Contracts
+    pub nut14_supported: bool,
+    /// NUT20 Settings - Web sockets
+    pub nut20_supported: bool,
+    /// NUT21 Settings - Clear authentication
+    pub nut21: Option<ClearAuthSettings>,
+    /// NUT22 Settings - Blind authentication
+    pub nut22: Option<BlindAuthSettings>,
+    /// Supported currency units for minting
+    pub mint_units: Vec<CurrencyUnit>,
+    /// Supported currency units for melting
+    pub melt_units: Vec<CurrencyUnit>,
+}
+
+impl From<cdk::nuts::Nuts> for Nuts {
+    fn from(nuts: cdk::nuts::Nuts) -> Self {
+        let mint_units = nuts
+            .supported_mint_units()
+            .into_iter()
+            .map(|u| u.clone().into())
+            .collect();
+        let melt_units = nuts
+            .supported_melt_units()
+            .into_iter()
+            .map(|u| u.clone().into())
+            .collect();
+
+        Self {
+            nut04: nuts.nut04.clone().into(),
+            nut05: nuts.nut05.clone().into(),
+            nut07_supported: nuts.nut07.supported,
+            nut08_supported: nuts.nut08.supported,
+            nut09_supported: nuts.nut09.supported,
+            nut10_supported: nuts.nut10.supported,
+            nut11_supported: nuts.nut11.supported,
+            nut12_supported: nuts.nut12.supported,
+            nut14_supported: nuts.nut14.supported,
+            nut20_supported: nuts.nut20.supported,
+            nut21: nuts.nut21.map(Into::into),
+            nut22: nuts.nut22.map(Into::into),
+            mint_units,
+            melt_units,
+        }
+    }
+}
+
+impl TryFrom<Nuts> for cdk::nuts::Nuts {
+    type Error = FfiError;
+
+    fn try_from(n: Nuts) -> Result<Self, Self::Error> {
+        Ok(Self {
+            nut04: n.nut04.try_into()?,
+            nut05: n.nut05.try_into()?,
+            nut07: cdk::nuts::nut06::SupportedSettings {
+                supported: n.nut07_supported,
+            },
+            nut08: cdk::nuts::nut06::SupportedSettings {
+                supported: n.nut08_supported,
+            },
+            nut09: cdk::nuts::nut06::SupportedSettings {
+                supported: n.nut09_supported,
+            },
+            nut10: cdk::nuts::nut06::SupportedSettings {
+                supported: n.nut10_supported,
+            },
+            nut11: cdk::nuts::nut06::SupportedSettings {
+                supported: n.nut11_supported,
+            },
+            nut12: cdk::nuts::nut06::SupportedSettings {
+                supported: n.nut12_supported,
+            },
+            nut14: cdk::nuts::nut06::SupportedSettings {
+                supported: n.nut14_supported,
+            },
+            nut15: Default::default(),
+            nut17: Default::default(),
+            nut19: Default::default(),
+            nut20: cdk::nuts::nut06::SupportedSettings {
+                supported: n.nut20_supported,
+            },
+            nut21: n.nut21.map(|s| s.try_into()).transpose()?,
+            nut22: n.nut22.map(|s| s.try_into()).transpose()?,
+        })
+    }
+}
+
+impl Nuts {
+    /// Convert Nuts to JSON string
+    pub fn to_json(&self) -> Result<String, FfiError> {
+        Ok(serde_json::to_string(self)?)
+    }
+}
+
+/// Decode Nuts from JSON string
+#[uniffi::export]
+pub fn decode_nuts(json: String) -> Result<Nuts, FfiError> {
+    Ok(serde_json::from_str(&json)?)
+}
+
+/// Encode Nuts to JSON string
+#[uniffi::export]
+pub fn encode_nuts(nuts: Nuts) -> Result<String, FfiError> {
+    Ok(serde_json::to_string(&nuts)?)
+}
+
+/// FFI-compatible MintInfo
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct MintInfo {
+    /// name of the mint and should be recognizable
+    pub name: Option<String>,
+    /// hex pubkey of the mint
+    pub pubkey: Option<String>,
+    /// implementation name and the version running
+    pub version: Option<MintVersion>,
+    /// short description of the mint
+    pub description: Option<String>,
+    /// long description
+    pub description_long: Option<String>,
+    /// Contact info
+    pub contact: Option<Vec<ContactInfo>>,
+    /// shows which NUTs the mint supports
+    pub nuts: Nuts,
+    /// Mint's icon URL
+    pub icon_url: Option<String>,
+    /// Mint's endpoint URLs
+    pub urls: Option<Vec<String>>,
+    /// message of the day that the wallet must display to the user
+    pub motd: Option<String>,
+    /// server unix timestamp
+    pub time: Option<u64>,
+    /// terms of url service of the mint
+    pub tos_url: Option<String>,
+}
+
+impl From<cdk::nuts::MintInfo> for MintInfo {
+    fn from(info: cdk::nuts::MintInfo) -> Self {
+        Self {
+            name: info.name,
+            pubkey: info.pubkey.map(|p| p.to_string()),
+            version: info.version.map(Into::into),
+            description: info.description,
+            description_long: info.description_long,
+            contact: info
+                .contact
+                .map(|contacts| contacts.into_iter().map(Into::into).collect()),
+            nuts: info.nuts.into(),
+            icon_url: info.icon_url,
+            urls: info.urls,
+            motd: info.motd,
+            time: info.time,
+            tos_url: info.tos_url,
+        }
+    }
+}
+
+impl From<MintInfo> for cdk::nuts::MintInfo {
+    fn from(info: MintInfo) -> Self {
+        // Convert FFI Nuts back to cdk::nuts::Nuts (best-effort)
+        let nuts_cdk: cdk::nuts::Nuts = info.nuts.clone().try_into().unwrap_or_default();
+        Self {
+            name: info.name,
+            pubkey: info.pubkey.and_then(|p| p.parse().ok()),
+            version: info.version.map(Into::into),
+            description: info.description,
+            description_long: info.description_long,
+            contact: info
+                .contact
+                .map(|contacts| contacts.into_iter().map(Into::into).collect()),
+            nuts: nuts_cdk,
+            icon_url: info.icon_url,
+            urls: info.urls,
+            motd: info.motd,
+            time: info.time,
+            tos_url: info.tos_url,
+        }
+    }
+}
+
+impl MintInfo {
+    /// Convert MintInfo to JSON string
+    pub fn to_json(&self) -> Result<String, FfiError> {
+        Ok(serde_json::to_string(self)?)
+    }
+}
+
+/// Decode MintInfo from JSON string
+#[uniffi::export]
+pub fn decode_mint_info(json: String) -> Result<MintInfo, FfiError> {
+    Ok(serde_json::from_str(&json)?)
+}
+
+/// Encode MintInfo to JSON string
+#[uniffi::export]
+pub fn encode_mint_info(info: MintInfo) -> Result<String, FfiError> {
+    Ok(serde_json::to_string(&info)?)
+}
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Helper function to create a sample cdk::nuts::Nuts for testing
+    fn create_sample_cdk_nuts() -> cdk::nuts::Nuts {
+        cdk::nuts::Nuts {
+            nut04: cdk::nuts::nut04::Settings {
+                methods: vec![cdk::nuts::nut04::MintMethodSettings {
+                    method: cdk::nuts::PaymentMethod::Bolt11,
+                    unit: cdk::nuts::CurrencyUnit::Sat,
+                    min_amount: Some(cdk::Amount::from(1)),
+                    max_amount: Some(cdk::Amount::from(100000)),
+                    options: Some(cdk::nuts::nut04::MintMethodOptions::Bolt11 {
+                        description: true,
+                    }),
+                }],
+                disabled: false,
+            },
+            nut05: cdk::nuts::nut05::Settings {
+                methods: vec![cdk::nuts::nut05::MeltMethodSettings {
+                    method: cdk::nuts::PaymentMethod::Bolt11,
+                    unit: cdk::nuts::CurrencyUnit::Sat,
+                    min_amount: Some(cdk::Amount::from(1)),
+                    max_amount: Some(cdk::Amount::from(100000)),
+                    options: Some(cdk::nuts::nut05::MeltMethodOptions::Bolt11 { amountless: true }),
+                }],
+                disabled: false,
+            },
+            nut07: cdk::nuts::nut06::SupportedSettings { supported: true },
+            nut08: cdk::nuts::nut06::SupportedSettings { supported: true },
+            nut09: cdk::nuts::nut06::SupportedSettings { supported: false },
+            nut10: cdk::nuts::nut06::SupportedSettings { supported: true },
+            nut11: cdk::nuts::nut06::SupportedSettings { supported: true },
+            nut12: cdk::nuts::nut06::SupportedSettings { supported: true },
+            nut14: cdk::nuts::nut06::SupportedSettings { supported: false },
+            nut15: Default::default(),
+            nut17: Default::default(),
+            nut19: Default::default(),
+            nut20: cdk::nuts::nut06::SupportedSettings { supported: true },
+            nut21: Some(cdk::nuts::ClearAuthSettings {
+                openid_discovery: "https://example.com/.well-known/openid-configuration"
+                    .to_string(),
+                client_id: "test-client".to_string(),
+                protected_endpoints: vec![cdk::nuts::ProtectedEndpoint::new(
+                    cdk::nuts::Method::Post,
+                    cdk::nuts::RoutePath::Swap,
+                )],
+            }),
+            nut22: Some(cdk::nuts::BlindAuthSettings {
+                bat_max_mint: 100,
+                protected_endpoints: vec![cdk::nuts::ProtectedEndpoint::new(
+                    cdk::nuts::Method::Post,
+                    cdk::nuts::RoutePath::MintBolt11,
+                )],
+            }),
+        }
+    }
+
+    #[test]
+    fn test_nuts_from_cdk_to_ffi() {
+        let cdk_nuts = create_sample_cdk_nuts();
+        let ffi_nuts: Nuts = cdk_nuts.clone().into();
+
+        // Verify NUT04 settings
+        assert_eq!(ffi_nuts.nut04.disabled, false);
+        assert_eq!(ffi_nuts.nut04.methods.len(), 1);
+        assert_eq!(ffi_nuts.nut04.methods[0].description, Some(true));
+
+        // Verify NUT05 settings
+        assert_eq!(ffi_nuts.nut05.disabled, false);
+        assert_eq!(ffi_nuts.nut05.methods.len(), 1);
+        assert_eq!(ffi_nuts.nut05.methods[0].amountless, Some(true));
+
+        // Verify supported flags
+        assert!(ffi_nuts.nut07_supported);
+        assert!(ffi_nuts.nut08_supported);
+        assert!(!ffi_nuts.nut09_supported);
+        assert!(ffi_nuts.nut10_supported);
+        assert!(ffi_nuts.nut11_supported);
+        assert!(ffi_nuts.nut12_supported);
+        assert!(!ffi_nuts.nut14_supported);
+        assert!(ffi_nuts.nut20_supported);
+
+        // Verify auth settings
+        assert!(ffi_nuts.nut21.is_some());
+        let nut21 = ffi_nuts.nut21.as_ref().unwrap();
+        assert_eq!(
+            nut21.openid_discovery,
+            "https://example.com/.well-known/openid-configuration"
+        );
+        assert_eq!(nut21.client_id, "test-client");
+        assert_eq!(nut21.protected_endpoints.len(), 1);
+
+        assert!(ffi_nuts.nut22.is_some());
+        let nut22 = ffi_nuts.nut22.as_ref().unwrap();
+        assert_eq!(nut22.bat_max_mint, 100);
+        assert_eq!(nut22.protected_endpoints.len(), 1);
+
+        // Verify units
+        assert!(!ffi_nuts.mint_units.is_empty());
+        assert!(!ffi_nuts.melt_units.is_empty());
+    }
+
+    #[test]
+    fn test_nuts_round_trip_conversion() {
+        let original_cdk_nuts = create_sample_cdk_nuts();
+
+        // Convert cdk -> ffi -> cdk
+        let ffi_nuts: Nuts = original_cdk_nuts.clone().into();
+        let converted_back: cdk::nuts::Nuts = ffi_nuts.try_into().unwrap();
+
+        // Verify all supported flags match
+        assert_eq!(
+            original_cdk_nuts.nut07.supported,
+            converted_back.nut07.supported
+        );
+        assert_eq!(
+            original_cdk_nuts.nut08.supported,
+            converted_back.nut08.supported
+        );
+        assert_eq!(
+            original_cdk_nuts.nut09.supported,
+            converted_back.nut09.supported
+        );
+        assert_eq!(
+            original_cdk_nuts.nut10.supported,
+            converted_back.nut10.supported
+        );
+        assert_eq!(
+            original_cdk_nuts.nut11.supported,
+            converted_back.nut11.supported
+        );
+        assert_eq!(
+            original_cdk_nuts.nut12.supported,
+            converted_back.nut12.supported
+        );
+        assert_eq!(
+            original_cdk_nuts.nut14.supported,
+            converted_back.nut14.supported
+        );
+        assert_eq!(
+            original_cdk_nuts.nut20.supported,
+            converted_back.nut20.supported
+        );
+
+        // Verify NUT04 settings
+        assert_eq!(
+            original_cdk_nuts.nut04.disabled,
+            converted_back.nut04.disabled
+        );
+        assert_eq!(
+            original_cdk_nuts.nut04.methods.len(),
+            converted_back.nut04.methods.len()
+        );
+
+        // Verify NUT05 settings
+        assert_eq!(
+            original_cdk_nuts.nut05.disabled,
+            converted_back.nut05.disabled
+        );
+        assert_eq!(
+            original_cdk_nuts.nut05.methods.len(),
+            converted_back.nut05.methods.len()
+        );
+
+        // Verify auth settings presence
+        assert_eq!(
+            original_cdk_nuts.nut21.is_some(),
+            converted_back.nut21.is_some()
+        );
+        assert_eq!(
+            original_cdk_nuts.nut22.is_some(),
+            converted_back.nut22.is_some()
+        );
+    }
+
+    #[test]
+    fn test_nuts_without_auth() {
+        let cdk_nuts = cdk::nuts::Nuts {
+            nut04: Default::default(),
+            nut05: Default::default(),
+            nut07: cdk::nuts::nut06::SupportedSettings { supported: true },
+            nut08: cdk::nuts::nut06::SupportedSettings { supported: false },
+            nut09: cdk::nuts::nut06::SupportedSettings { supported: false },
+            nut10: cdk::nuts::nut06::SupportedSettings { supported: false },
+            nut11: cdk::nuts::nut06::SupportedSettings { supported: false },
+            nut12: cdk::nuts::nut06::SupportedSettings { supported: false },
+            nut14: cdk::nuts::nut06::SupportedSettings { supported: false },
+            nut15: Default::default(),
+            nut17: Default::default(),
+            nut19: Default::default(),
+            nut20: cdk::nuts::nut06::SupportedSettings { supported: false },
+            nut21: None,
+            nut22: None,
+        };
+
+        let ffi_nuts: Nuts = cdk_nuts.into();
+
+        assert!(ffi_nuts.nut21.is_none());
+        assert!(ffi_nuts.nut22.is_none());
+        assert!(ffi_nuts.nut07_supported);
+        assert!(!ffi_nuts.nut08_supported);
+    }
+
+    #[test]
+    fn test_ffi_nuts_to_cdk_with_defaults() {
+        let ffi_nuts = Nuts {
+            nut04: Nut04Settings {
+                methods: vec![],
+                disabled: true,
+            },
+            nut05: Nut05Settings {
+                methods: vec![],
+                disabled: true,
+            },
+            nut07_supported: false,
+            nut08_supported: false,
+            nut09_supported: false,
+            nut10_supported: false,
+            nut11_supported: false,
+            nut12_supported: false,
+            nut14_supported: false,
+            nut20_supported: false,
+            nut21: None,
+            nut22: None,
+            mint_units: vec![],
+            melt_units: vec![],
+        };
+
+        let cdk_nuts: Result<cdk::nuts::Nuts, _> = ffi_nuts.try_into();
+        assert!(cdk_nuts.is_ok());
+
+        let cdk_nuts = cdk_nuts.unwrap();
+        assert!(!cdk_nuts.nut07.supported);
+        assert!(!cdk_nuts.nut08.supported);
+        assert!(cdk_nuts.nut21.is_none());
+        assert!(cdk_nuts.nut22.is_none());
+
+        // Verify default values for nuts not included in FFI
+        assert_eq!(cdk_nuts.nut17.supported.len(), 0);
+    }
+
+    #[test]
+    fn test_nuts_serialization() {
+        let cdk_nuts = create_sample_cdk_nuts();
+        let ffi_nuts: Nuts = cdk_nuts.into();
+
+        // Test JSON serialization
+        let json = ffi_nuts.to_json();
+        assert!(json.is_ok());
+
+        let json_str = json.unwrap();
+        assert!(json_str.contains("nut04"));
+        assert!(json_str.contains("nut05"));
+
+        // Test deserialization
+        let decoded: Result<Nuts, _> = serde_json::from_str(&json_str);
+        assert!(decoded.is_ok());
+
+        let decoded_nuts = decoded.unwrap();
+        assert_eq!(decoded_nuts.nut07_supported, ffi_nuts.nut07_supported);
+        assert_eq!(decoded_nuts.nut08_supported, ffi_nuts.nut08_supported);
+    }
+
+    #[test]
+    fn test_nuts_multiple_units() {
+        let mut cdk_nuts = create_sample_cdk_nuts();
+
+        // Add multiple payment methods to test unit collection
+        cdk_nuts
+            .nut04
+            .methods
+            .push(cdk::nuts::nut04::MintMethodSettings {
+                method: cdk::nuts::PaymentMethod::Bolt11,
+                unit: cdk::nuts::CurrencyUnit::Msat,
+                min_amount: Some(cdk::Amount::from(1)),
+                max_amount: Some(cdk::Amount::from(100000)),
+                options: None,
+            });
+
+        cdk_nuts
+            .nut05
+            .methods
+            .push(cdk::nuts::nut05::MeltMethodSettings {
+                method: cdk::nuts::PaymentMethod::Bolt11,
+                unit: cdk::nuts::CurrencyUnit::Usd,
+                min_amount: None,
+                max_amount: None,
+                options: None,
+            });
+
+        let ffi_nuts: Nuts = cdk_nuts.into();
+
+        // Should have collected multiple units
+        assert!(ffi_nuts.mint_units.len() >= 1);
+        assert!(ffi_nuts.melt_units.len() >= 1);
+    }
+
+    #[test]
+    fn test_protected_endpoint_conversion() {
+        let cdk_endpoint =
+            cdk::nuts::ProtectedEndpoint::new(cdk::nuts::Method::Post, cdk::nuts::RoutePath::Swap);
+
+        let ffi_endpoint: ProtectedEndpoint = cdk_endpoint.into();
+
+        assert_eq!(ffi_endpoint.method, "POST");
+        assert_eq!(ffi_endpoint.path, "/v1/swap");
+
+        // Test round-trip
+        let converted_back: Result<cdk::nuts::ProtectedEndpoint, _> = ffi_endpoint.try_into();
+        assert!(converted_back.is_ok());
+    }
+
+    #[test]
+    fn test_invalid_protected_endpoint_method() {
+        let invalid_endpoint = ProtectedEndpoint {
+            method: "INVALID".to_string(),
+            path: "/v1/swap".to_string(),
+        };
+
+        let result: Result<cdk::nuts::ProtectedEndpoint, _> = invalid_endpoint.try_into();
+        assert!(result.is_err());
+    }
+
+    #[test]
+    fn test_invalid_protected_endpoint_path() {
+        let invalid_endpoint = ProtectedEndpoint {
+            method: "POST".to_string(),
+            path: "/invalid/path".to_string(),
+        };
+
+        let result: Result<cdk::nuts::ProtectedEndpoint, _> = invalid_endpoint.try_into();
+        assert!(result.is_err());
+    }
+}

+ 24 - 0
crates/cdk-ffi/src/types/mod.rs

@@ -0,0 +1,24 @@
+//! FFI-compatible types
+//!
+//! This module contains all the FFI types used by the UniFFI bindings.
+//! Types are organized into logical submodules for better maintainability.
+
+// Module declarations
+pub mod amount;
+pub mod keys;
+pub mod mint;
+pub mod proof;
+pub mod quote;
+pub mod subscription;
+pub mod transaction;
+pub mod wallet;
+
+// Re-export all types for convenient access
+pub use amount::*;
+pub use keys::*;
+pub use mint::*;
+pub use proof::*;
+pub use quote::*;
+pub use subscription::*;
+pub use transaction::*;
+pub use wallet::*;

+ 509 - 0
crates/cdk-ffi/src/types/proof.rs

@@ -0,0 +1,509 @@
+//! Proof-related FFI types
+
+use std::str::FromStr;
+
+use cdk::nuts::State as CdkState;
+use serde::{Deserialize, Serialize};
+
+use super::amount::{Amount, CurrencyUnit};
+use super::mint::MintUrl;
+use crate::error::FfiError;
+
+/// FFI-compatible Proof state
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
+pub enum ProofState {
+    Unspent,
+    Pending,
+    Spent,
+    Reserved,
+    PendingSpent,
+}
+
+impl From<CdkState> for ProofState {
+    fn from(state: CdkState) -> Self {
+        match state {
+            CdkState::Unspent => ProofState::Unspent,
+            CdkState::Pending => ProofState::Pending,
+            CdkState::Spent => ProofState::Spent,
+            CdkState::Reserved => ProofState::Reserved,
+            CdkState::PendingSpent => ProofState::PendingSpent,
+        }
+    }
+}
+
+impl From<ProofState> for CdkState {
+    fn from(state: ProofState) -> Self {
+        match state {
+            ProofState::Unspent => CdkState::Unspent,
+            ProofState::Pending => CdkState::Pending,
+            ProofState::Spent => CdkState::Spent,
+            ProofState::Reserved => CdkState::Reserved,
+            ProofState::PendingSpent => CdkState::PendingSpent,
+        }
+    }
+}
+
+/// FFI-compatible Proof
+#[derive(Debug, uniffi::Object)]
+pub struct Proof {
+    pub(crate) inner: cdk::nuts::Proof,
+}
+
+impl From<cdk::nuts::Proof> for Proof {
+    fn from(proof: cdk::nuts::Proof) -> Self {
+        Self { inner: proof }
+    }
+}
+
+impl From<Proof> for cdk::nuts::Proof {
+    fn from(proof: Proof) -> Self {
+        proof.inner
+    }
+}
+
+#[uniffi::export]
+impl Proof {
+    /// Get the amount
+    pub fn amount(&self) -> Amount {
+        self.inner.amount.into()
+    }
+
+    /// Get the secret as string
+    pub fn secret(&self) -> String {
+        self.inner.secret.to_string()
+    }
+
+    /// Get the unblinded signature (C) as string
+    pub fn c(&self) -> String {
+        self.inner.c.to_string()
+    }
+
+    /// Get the keyset ID as string
+    pub fn keyset_id(&self) -> String {
+        self.inner.keyset_id.to_string()
+    }
+
+    /// Get the witness
+    pub fn witness(&self) -> Option<Witness> {
+        self.inner.witness.as_ref().map(|w| w.clone().into())
+    }
+
+    /// Check if proof is active with given keyset IDs
+    pub fn is_active(&self, active_keyset_ids: Vec<String>) -> bool {
+        use cdk::nuts::Id;
+        let ids: Vec<Id> = active_keyset_ids
+            .into_iter()
+            .filter_map(|id| Id::from_str(&id).ok())
+            .collect();
+        self.inner.is_active(&ids)
+    }
+
+    /// Get the Y value (hash_to_curve of secret)
+    pub fn y(&self) -> Result<String, FfiError> {
+        Ok(self.inner.y()?.to_string())
+    }
+
+    /// Get the DLEQ proof if present
+    pub fn dleq(&self) -> Option<ProofDleq> {
+        self.inner.dleq.as_ref().map(|d| d.clone().into())
+    }
+
+    /// Check if proof has DLEQ proof
+    pub fn has_dleq(&self) -> bool {
+        self.inner.dleq.is_some()
+    }
+}
+
+/// FFI-compatible Proofs (vector of Proof)
+pub type Proofs = Vec<std::sync::Arc<Proof>>;
+
+/// FFI-compatible DLEQ proof for proofs
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct ProofDleq {
+    /// e value (hex-encoded SecretKey)
+    pub e: String,
+    /// s value (hex-encoded SecretKey)
+    pub s: String,
+    /// r value - blinding factor (hex-encoded SecretKey)
+    pub r: String,
+}
+
+/// FFI-compatible DLEQ proof for blind signatures
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct BlindSignatureDleq {
+    /// e value (hex-encoded SecretKey)
+    pub e: String,
+    /// s value (hex-encoded SecretKey)
+    pub s: String,
+}
+
+impl From<cdk::nuts::ProofDleq> for ProofDleq {
+    fn from(dleq: cdk::nuts::ProofDleq) -> Self {
+        Self {
+            e: dleq.e.to_secret_hex(),
+            s: dleq.s.to_secret_hex(),
+            r: dleq.r.to_secret_hex(),
+        }
+    }
+}
+
+impl From<ProofDleq> for cdk::nuts::ProofDleq {
+    fn from(dleq: ProofDleq) -> Self {
+        Self {
+            e: cdk::nuts::SecretKey::from_hex(&dleq.e).expect("Invalid e hex"),
+            s: cdk::nuts::SecretKey::from_hex(&dleq.s).expect("Invalid s hex"),
+            r: cdk::nuts::SecretKey::from_hex(&dleq.r).expect("Invalid r hex"),
+        }
+    }
+}
+
+impl From<cdk::nuts::BlindSignatureDleq> for BlindSignatureDleq {
+    fn from(dleq: cdk::nuts::BlindSignatureDleq) -> Self {
+        Self {
+            e: dleq.e.to_secret_hex(),
+            s: dleq.s.to_secret_hex(),
+        }
+    }
+}
+
+impl From<BlindSignatureDleq> for cdk::nuts::BlindSignatureDleq {
+    fn from(dleq: BlindSignatureDleq) -> Self {
+        Self {
+            e: cdk::nuts::SecretKey::from_hex(&dleq.e).expect("Invalid e hex"),
+            s: cdk::nuts::SecretKey::from_hex(&dleq.s).expect("Invalid s hex"),
+        }
+    }
+}
+
+/// Helper functions for Proofs
+pub fn proofs_total_amount(proofs: &Proofs) -> Result<Amount, FfiError> {
+    let cdk_proofs: Vec<cdk::nuts::Proof> = proofs.iter().map(|p| p.inner.clone()).collect();
+    use cdk::nuts::ProofsMethods;
+    Ok(cdk_proofs.total_amount()?.into())
+}
+
+/// FFI-compatible Conditions (for spending conditions)
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct Conditions {
+    /// Unix locktime after which refund keys can be used
+    pub locktime: Option<u64>,
+    /// Additional Public keys (as hex strings)
+    pub pubkeys: Vec<String>,
+    /// Refund keys (as hex strings)
+    pub refund_keys: Vec<String>,
+    /// Number of signatures required (default 1)
+    pub num_sigs: Option<u64>,
+    /// Signature flag (0 = SigInputs, 1 = SigAll)
+    pub sig_flag: u8,
+    /// Number of refund signatures required (default 1)
+    pub num_sigs_refund: Option<u64>,
+}
+
+impl From<cdk::nuts::nut11::Conditions> for Conditions {
+    fn from(conditions: cdk::nuts::nut11::Conditions) -> Self {
+        Self {
+            locktime: conditions.locktime,
+            pubkeys: conditions
+                .pubkeys
+                .unwrap_or_default()
+                .into_iter()
+                .map(|p| p.to_string())
+                .collect(),
+            refund_keys: conditions
+                .refund_keys
+                .unwrap_or_default()
+                .into_iter()
+                .map(|p| p.to_string())
+                .collect(),
+            num_sigs: conditions.num_sigs,
+            sig_flag: match conditions.sig_flag {
+                cdk::nuts::nut11::SigFlag::SigInputs => 0,
+                cdk::nuts::nut11::SigFlag::SigAll => 1,
+            },
+            num_sigs_refund: conditions.num_sigs_refund,
+        }
+    }
+}
+
+impl TryFrom<Conditions> for cdk::nuts::nut11::Conditions {
+    type Error = FfiError;
+
+    fn try_from(conditions: Conditions) -> Result<Self, Self::Error> {
+        let pubkeys = if conditions.pubkeys.is_empty() {
+            None
+        } else {
+            Some(
+                conditions
+                    .pubkeys
+                    .into_iter()
+                    .map(|s| {
+                        s.parse().map_err(|e| FfiError::InvalidCryptographicKey {
+                            msg: format!("Invalid pubkey: {}", e),
+                        })
+                    })
+                    .collect::<Result<Vec<_>, _>>()?,
+            )
+        };
+
+        let refund_keys = if conditions.refund_keys.is_empty() {
+            None
+        } else {
+            Some(
+                conditions
+                    .refund_keys
+                    .into_iter()
+                    .map(|s| {
+                        s.parse().map_err(|e| FfiError::InvalidCryptographicKey {
+                            msg: format!("Invalid refund key: {}", e),
+                        })
+                    })
+                    .collect::<Result<Vec<_>, _>>()?,
+            )
+        };
+
+        let sig_flag = match conditions.sig_flag {
+            0 => cdk::nuts::nut11::SigFlag::SigInputs,
+            1 => cdk::nuts::nut11::SigFlag::SigAll,
+            _ => {
+                return Err(FfiError::Generic {
+                    msg: "Invalid sig_flag value".to_string(),
+                })
+            }
+        };
+
+        Ok(Self {
+            locktime: conditions.locktime,
+            pubkeys,
+            refund_keys,
+            num_sigs: conditions.num_sigs,
+            sig_flag,
+            num_sigs_refund: conditions.num_sigs_refund,
+        })
+    }
+}
+
+impl Conditions {
+    /// Convert Conditions to JSON string
+    pub fn to_json(&self) -> Result<String, FfiError> {
+        Ok(serde_json::to_string(self)?)
+    }
+}
+
+/// Decode Conditions from JSON string
+#[uniffi::export]
+pub fn decode_conditions(json: String) -> Result<Conditions, FfiError> {
+    Ok(serde_json::from_str(&json)?)
+}
+
+/// Encode Conditions to JSON string
+#[uniffi::export]
+pub fn encode_conditions(conditions: Conditions) -> Result<String, FfiError> {
+    Ok(serde_json::to_string(&conditions)?)
+}
+
+/// FFI-compatible Witness
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)]
+pub enum Witness {
+    /// P2PK Witness
+    P2PK {
+        /// Signatures
+        signatures: Vec<String>,
+    },
+    /// HTLC Witness
+    HTLC {
+        /// Preimage
+        preimage: String,
+        /// Optional signatures
+        signatures: Option<Vec<String>>,
+    },
+}
+
+impl From<cdk::nuts::Witness> for Witness {
+    fn from(witness: cdk::nuts::Witness) -> Self {
+        match witness {
+            cdk::nuts::Witness::P2PKWitness(p2pk) => Self::P2PK {
+                signatures: p2pk.signatures,
+            },
+            cdk::nuts::Witness::HTLCWitness(htlc) => Self::HTLC {
+                preimage: htlc.preimage,
+                signatures: htlc.signatures,
+            },
+        }
+    }
+}
+
+impl From<Witness> for cdk::nuts::Witness {
+    fn from(witness: Witness) -> Self {
+        match witness {
+            Witness::P2PK { signatures } => {
+                Self::P2PKWitness(cdk::nuts::nut11::P2PKWitness { signatures })
+            }
+            Witness::HTLC {
+                preimage,
+                signatures,
+            } => Self::HTLCWitness(cdk::nuts::nut14::HTLCWitness {
+                preimage,
+                signatures,
+            }),
+        }
+    }
+}
+
+/// FFI-compatible SpendingConditions
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)]
+pub enum SpendingConditions {
+    /// P2PK (Pay to Public Key) conditions
+    P2PK {
+        /// The public key (as hex string)
+        pubkey: String,
+        /// Additional conditions
+        conditions: Option<Conditions>,
+    },
+    /// HTLC (Hash Time Locked Contract) conditions
+    HTLC {
+        /// Hash of the preimage (as hex string)
+        hash: String,
+        /// Additional conditions
+        conditions: Option<Conditions>,
+    },
+}
+
+impl From<cdk::nuts::SpendingConditions> for SpendingConditions {
+    fn from(spending_conditions: cdk::nuts::SpendingConditions) -> Self {
+        match spending_conditions {
+            cdk::nuts::SpendingConditions::P2PKConditions { data, conditions } => Self::P2PK {
+                pubkey: data.to_string(),
+                conditions: conditions.map(Into::into),
+            },
+            cdk::nuts::SpendingConditions::HTLCConditions { data, conditions } => Self::HTLC {
+                hash: data.to_string(),
+                conditions: conditions.map(Into::into),
+            },
+        }
+    }
+}
+
+impl TryFrom<SpendingConditions> for cdk::nuts::SpendingConditions {
+    type Error = FfiError;
+
+    fn try_from(spending_conditions: SpendingConditions) -> Result<Self, Self::Error> {
+        match spending_conditions {
+            SpendingConditions::P2PK { pubkey, conditions } => {
+                let pubkey = pubkey
+                    .parse()
+                    .map_err(|e| FfiError::InvalidCryptographicKey {
+                        msg: format!("Invalid pubkey: {}", e),
+                    })?;
+                let conditions = conditions.map(|c| c.try_into()).transpose()?;
+                Ok(Self::P2PKConditions {
+                    data: pubkey,
+                    conditions,
+                })
+            }
+            SpendingConditions::HTLC { hash, conditions } => {
+                let hash = hash
+                    .parse()
+                    .map_err(|e| FfiError::InvalidCryptographicKey {
+                        msg: format!("Invalid hash: {}", e),
+                    })?;
+                let conditions = conditions.map(|c| c.try_into()).transpose()?;
+                Ok(Self::HTLCConditions {
+                    data: hash,
+                    conditions,
+                })
+            }
+        }
+    }
+}
+
+/// FFI-compatible ProofInfo
+#[derive(Debug, Clone, uniffi::Record)]
+pub struct ProofInfo {
+    /// Proof
+    pub proof: std::sync::Arc<Proof>,
+    /// Y value (hash_to_curve of secret)
+    pub y: super::keys::PublicKey,
+    /// Mint URL
+    pub mint_url: MintUrl,
+    /// Proof state
+    pub state: ProofState,
+    /// Proof Spending Conditions
+    pub spending_condition: Option<SpendingConditions>,
+    /// Currency unit
+    pub unit: CurrencyUnit,
+}
+
+impl From<cdk::types::ProofInfo> for ProofInfo {
+    fn from(info: cdk::types::ProofInfo) -> Self {
+        Self {
+            proof: std::sync::Arc::new(info.proof.into()),
+            y: info.y.into(),
+            mint_url: info.mint_url.into(),
+            state: info.state.into(),
+            spending_condition: info.spending_condition.map(Into::into),
+            unit: info.unit.into(),
+        }
+    }
+}
+
+/// Decode ProofInfo from JSON string
+#[uniffi::export]
+pub fn decode_proof_info(json: String) -> Result<ProofInfo, FfiError> {
+    let info: cdk::types::ProofInfo = serde_json::from_str(&json)?;
+    Ok(info.into())
+}
+
+/// Encode ProofInfo to JSON string
+#[uniffi::export]
+pub fn encode_proof_info(info: ProofInfo) -> Result<String, FfiError> {
+    // Convert to cdk::types::ProofInfo for serialization
+    let cdk_info = 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.and_then(|c| c.try_into().ok()),
+        unit: info.unit.into(),
+    };
+    Ok(serde_json::to_string(&cdk_info)?)
+}
+
+/// FFI-compatible ProofStateUpdate
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct ProofStateUpdate {
+    /// Y value (hash_to_curve of secret)
+    pub y: String,
+    /// Current state
+    pub state: ProofState,
+    /// Optional witness data
+    pub witness: Option<String>,
+}
+
+impl From<cdk::nuts::nut07::ProofState> for ProofStateUpdate {
+    fn from(proof_state: cdk::nuts::nut07::ProofState) -> Self {
+        Self {
+            y: proof_state.y.to_string(),
+            state: proof_state.state.into(),
+            witness: proof_state.witness.map(|w| format!("{:?}", w)),
+        }
+    }
+}
+
+impl ProofStateUpdate {
+    /// Convert ProofStateUpdate to JSON string
+    pub fn to_json(&self) -> Result<String, FfiError> {
+        Ok(serde_json::to_string(self)?)
+    }
+}
+
+/// Decode ProofStateUpdate from JSON string
+#[uniffi::export]
+pub fn decode_proof_state_update(json: String) -> Result<ProofStateUpdate, FfiError> {
+    Ok(serde_json::from_str(&json)?)
+}
+
+/// Encode ProofStateUpdate to JSON string
+#[uniffi::export]
+pub fn encode_proof_state_update(update: ProofStateUpdate) -> Result<String, FfiError> {
+    Ok(serde_json::to_string(&update)?)
+}

+ 430 - 0
crates/cdk-ffi/src/types/quote.rs

@@ -0,0 +1,430 @@
+//! Quote-related FFI types
+
+use serde::{Deserialize, Serialize};
+
+use super::amount::{Amount, CurrencyUnit};
+use super::mint::MintUrl;
+use crate::error::FfiError;
+
+/// FFI-compatible MintQuote
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct MintQuote {
+    /// Quote ID
+    pub id: String,
+    /// Quote amount
+    pub amount: Option<Amount>,
+    /// Currency unit
+    pub unit: CurrencyUnit,
+    /// Payment request
+    pub request: String,
+    /// Quote state
+    pub state: QuoteState,
+    /// Expiry timestamp
+    pub expiry: u64,
+    /// Mint URL
+    pub mint_url: MintUrl,
+    /// Amount issued
+    pub amount_issued: Amount,
+    /// Amount paid
+    pub amount_paid: Amount,
+    /// Payment method
+    pub payment_method: PaymentMethod,
+    /// Secret key (optional, hex-encoded)
+    pub secret_key: Option<String>,
+}
+
+impl From<cdk::wallet::MintQuote> for MintQuote {
+    fn from(quote: cdk::wallet::MintQuote) -> Self {
+        Self {
+            id: quote.id.clone(),
+            amount: quote.amount.map(Into::into),
+            unit: quote.unit.clone().into(),
+            request: quote.request.clone(),
+            state: quote.state.into(),
+            expiry: quote.expiry,
+            mint_url: quote.mint_url.clone().into(),
+            amount_issued: quote.amount_issued.into(),
+            amount_paid: quote.amount_paid.into(),
+            payment_method: quote.payment_method.into(),
+            secret_key: quote.secret_key.map(|sk| sk.to_secret_hex()),
+        }
+    }
+}
+
+impl TryFrom<MintQuote> for cdk::wallet::MintQuote {
+    type Error = FfiError;
+
+    fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
+        let secret_key = quote
+            .secret_key
+            .map(|hex| cdk::nuts::SecretKey::from_hex(&hex))
+            .transpose()
+            .map_err(|e| FfiError::InvalidCryptographicKey { msg: e.to_string() })?;
+
+        Ok(Self {
+            id: quote.id,
+            amount: quote.amount.map(Into::into),
+            unit: quote.unit.into(),
+            request: quote.request,
+            state: quote.state.into(),
+            expiry: quote.expiry,
+            mint_url: quote.mint_url.try_into()?,
+            amount_issued: quote.amount_issued.into(),
+            amount_paid: quote.amount_paid.into(),
+            payment_method: quote.payment_method.into(),
+            secret_key,
+        })
+    }
+}
+
+impl MintQuote {
+    /// Get total amount (amount + fees)
+    pub fn total_amount(&self) -> Amount {
+        if let Some(amount) = self.amount {
+            Amount::new(amount.value + self.amount_paid.value - self.amount_issued.value)
+        } else {
+            Amount::zero()
+        }
+    }
+
+    /// Check if quote is expired
+    pub fn is_expired(&self, current_time: u64) -> bool {
+        current_time > self.expiry
+    }
+
+    /// Get amount that can be minted
+    pub fn amount_mintable(&self) -> Amount {
+        Amount::new(self.amount_paid.value - self.amount_issued.value)
+    }
+
+    /// Convert MintQuote to JSON string
+    pub fn to_json(&self) -> Result<String, FfiError> {
+        Ok(serde_json::to_string(self)?)
+    }
+}
+
+/// Decode MintQuote from JSON string
+#[uniffi::export]
+pub fn decode_mint_quote(json: String) -> Result<MintQuote, FfiError> {
+    let quote: cdk::wallet::MintQuote = serde_json::from_str(&json)?;
+    Ok(quote.into())
+}
+
+/// Encode MintQuote to JSON string
+#[uniffi::export]
+pub fn encode_mint_quote(quote: MintQuote) -> Result<String, FfiError> {
+    Ok(serde_json::to_string(&quote)?)
+}
+
+/// FFI-compatible MintQuoteBolt11Response
+#[derive(Debug, uniffi::Object)]
+pub struct MintQuoteBolt11Response {
+    /// Quote ID
+    pub quote: String,
+    /// Request string
+    pub request: String,
+    /// State of the quote
+    pub state: QuoteState,
+    /// Expiry timestamp (optional)
+    pub expiry: Option<u64>,
+    /// Amount (optional)
+    pub amount: Option<Amount>,
+    /// Unit (optional)
+    pub unit: Option<CurrencyUnit>,
+    /// Pubkey (optional)
+    pub pubkey: Option<String>,
+}
+
+impl From<cdk::nuts::MintQuoteBolt11Response<String>> for MintQuoteBolt11Response {
+    fn from(response: cdk::nuts::MintQuoteBolt11Response<String>) -> Self {
+        Self {
+            quote: response.quote,
+            request: response.request,
+            state: response.state.into(),
+            expiry: response.expiry,
+            amount: response.amount.map(Into::into),
+            unit: response.unit.map(Into::into),
+            pubkey: response.pubkey.map(|p| p.to_string()),
+        }
+    }
+}
+
+#[uniffi::export]
+impl MintQuoteBolt11Response {
+    /// Get quote ID
+    pub fn quote(&self) -> String {
+        self.quote.clone()
+    }
+
+    /// Get request string
+    pub fn request(&self) -> String {
+        self.request.clone()
+    }
+
+    /// Get state
+    pub fn state(&self) -> QuoteState {
+        self.state.clone()
+    }
+
+    /// Get expiry
+    pub fn expiry(&self) -> Option<u64> {
+        self.expiry
+    }
+
+    /// Get amount
+    pub fn amount(&self) -> Option<Amount> {
+        self.amount
+    }
+
+    /// Get unit
+    pub fn unit(&self) -> Option<CurrencyUnit> {
+        self.unit.clone()
+    }
+
+    /// Get pubkey
+    pub fn pubkey(&self) -> Option<String> {
+        self.pubkey.clone()
+    }
+}
+
+/// FFI-compatible MeltQuoteBolt11Response
+#[derive(Debug, uniffi::Object)]
+pub struct MeltQuoteBolt11Response {
+    /// Quote ID
+    pub quote: String,
+    /// Amount
+    pub amount: Amount,
+    /// Fee reserve
+    pub fee_reserve: Amount,
+    /// State of the quote
+    pub state: QuoteState,
+    /// Expiry timestamp
+    pub expiry: u64,
+    /// Payment preimage (optional)
+    pub payment_preimage: Option<String>,
+    /// Request string (optional)
+    pub request: Option<String>,
+    /// Unit (optional)
+    pub unit: Option<CurrencyUnit>,
+}
+
+impl From<cdk::nuts::MeltQuoteBolt11Response<String>> for MeltQuoteBolt11Response {
+    fn from(response: cdk::nuts::MeltQuoteBolt11Response<String>) -> Self {
+        Self {
+            quote: response.quote,
+            amount: response.amount.into(),
+            fee_reserve: response.fee_reserve.into(),
+            state: response.state.into(),
+            expiry: response.expiry,
+            payment_preimage: response.payment_preimage,
+            request: response.request,
+            unit: response.unit.map(Into::into),
+        }
+    }
+}
+
+#[uniffi::export]
+impl MeltQuoteBolt11Response {
+    /// Get quote ID
+    pub fn quote(&self) -> String {
+        self.quote.clone()
+    }
+
+    /// Get amount
+    pub fn amount(&self) -> Amount {
+        self.amount
+    }
+
+    /// Get fee reserve
+    pub fn fee_reserve(&self) -> Amount {
+        self.fee_reserve
+    }
+
+    /// Get state
+    pub fn state(&self) -> QuoteState {
+        self.state.clone()
+    }
+
+    /// Get expiry
+    pub fn expiry(&self) -> u64 {
+        self.expiry
+    }
+
+    /// Get payment preimage
+    pub fn payment_preimage(&self) -> Option<String> {
+        self.payment_preimage.clone()
+    }
+
+    /// Get request
+    pub fn request(&self) -> Option<String> {
+        self.request.clone()
+    }
+
+    /// Get unit
+    pub fn unit(&self) -> Option<CurrencyUnit> {
+        self.unit.clone()
+    }
+}
+
+/// FFI-compatible PaymentMethod
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
+pub enum PaymentMethod {
+    /// Bolt11 payment type
+    Bolt11,
+    /// Bolt12 payment type
+    Bolt12,
+    /// Custom payment type
+    Custom { method: String },
+}
+
+impl From<cdk::nuts::PaymentMethod> for PaymentMethod {
+    fn from(method: cdk::nuts::PaymentMethod) -> Self {
+        match method {
+            cdk::nuts::PaymentMethod::Bolt11 => Self::Bolt11,
+            cdk::nuts::PaymentMethod::Bolt12 => Self::Bolt12,
+            cdk::nuts::PaymentMethod::Custom(s) => Self::Custom { method: s },
+        }
+    }
+}
+
+impl From<PaymentMethod> for cdk::nuts::PaymentMethod {
+    fn from(method: PaymentMethod) -> Self {
+        match method {
+            PaymentMethod::Bolt11 => Self::Bolt11,
+            PaymentMethod::Bolt12 => Self::Bolt12,
+            PaymentMethod::Custom { method } => Self::Custom(method),
+        }
+    }
+}
+
+/// FFI-compatible MeltQuote
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct MeltQuote {
+    /// Quote ID
+    pub id: String,
+    /// Quote amount
+    pub amount: Amount,
+    /// Currency unit
+    pub unit: CurrencyUnit,
+    /// Payment request
+    pub request: String,
+    /// Fee reserve
+    pub fee_reserve: Amount,
+    /// Quote state
+    pub state: QuoteState,
+    /// Expiry timestamp
+    pub expiry: u64,
+    /// Payment preimage
+    pub payment_preimage: Option<String>,
+    /// Payment method
+    pub payment_method: PaymentMethod,
+}
+
+impl From<cdk::wallet::MeltQuote> for MeltQuote {
+    fn from(quote: cdk::wallet::MeltQuote) -> Self {
+        Self {
+            id: quote.id.clone(),
+            amount: quote.amount.into(),
+            unit: quote.unit.clone().into(),
+            request: quote.request.clone(),
+            fee_reserve: quote.fee_reserve.into(),
+            state: quote.state.into(),
+            expiry: quote.expiry,
+            payment_preimage: quote.payment_preimage.clone(),
+            payment_method: quote.payment_method.into(),
+        }
+    }
+}
+
+impl TryFrom<MeltQuote> for cdk::wallet::MeltQuote {
+    type Error = FfiError;
+
+    fn try_from(quote: MeltQuote) -> Result<Self, Self::Error> {
+        Ok(Self {
+            id: quote.id,
+            amount: quote.amount.into(),
+            unit: quote.unit.into(),
+            request: quote.request,
+            fee_reserve: quote.fee_reserve.into(),
+            state: quote.state.into(),
+            expiry: quote.expiry,
+            payment_preimage: quote.payment_preimage,
+            payment_method: quote.payment_method.into(),
+        })
+    }
+}
+
+impl MeltQuote {
+    /// Convert MeltQuote to JSON string
+    pub fn to_json(&self) -> Result<String, FfiError> {
+        Ok(serde_json::to_string(self)?)
+    }
+}
+
+/// Decode MeltQuote from JSON string
+#[uniffi::export]
+pub fn decode_melt_quote(json: String) -> Result<MeltQuote, FfiError> {
+    let quote: cdk::wallet::MeltQuote = serde_json::from_str(&json)?;
+    Ok(quote.into())
+}
+
+/// Encode MeltQuote to JSON string
+#[uniffi::export]
+pub fn encode_melt_quote(quote: MeltQuote) -> Result<String, FfiError> {
+    Ok(serde_json::to_string(&quote)?)
+}
+
+/// FFI-compatible QuoteState
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
+pub enum QuoteState {
+    Unpaid,
+    Paid,
+    Pending,
+    Issued,
+}
+
+impl From<cdk::nuts::nut05::QuoteState> for QuoteState {
+    fn from(state: cdk::nuts::nut05::QuoteState) -> Self {
+        match state {
+            cdk::nuts::nut05::QuoteState::Unpaid => QuoteState::Unpaid,
+            cdk::nuts::nut05::QuoteState::Paid => QuoteState::Paid,
+            cdk::nuts::nut05::QuoteState::Pending => QuoteState::Pending,
+            cdk::nuts::nut05::QuoteState::Unknown => QuoteState::Unpaid,
+            cdk::nuts::nut05::QuoteState::Failed => QuoteState::Unpaid,
+        }
+    }
+}
+
+impl From<QuoteState> for cdk::nuts::nut05::QuoteState {
+    fn from(state: QuoteState) -> Self {
+        match state {
+            QuoteState::Unpaid => cdk::nuts::nut05::QuoteState::Unpaid,
+            QuoteState::Paid => cdk::nuts::nut05::QuoteState::Paid,
+            QuoteState::Pending => cdk::nuts::nut05::QuoteState::Pending,
+            QuoteState::Issued => cdk::nuts::nut05::QuoteState::Paid, // Map issued to paid for melt quotes
+        }
+    }
+}
+
+impl From<cdk::nuts::MintQuoteState> for QuoteState {
+    fn from(state: cdk::nuts::MintQuoteState) -> Self {
+        match state {
+            cdk::nuts::MintQuoteState::Unpaid => QuoteState::Unpaid,
+            cdk::nuts::MintQuoteState::Paid => QuoteState::Paid,
+            cdk::nuts::MintQuoteState::Issued => QuoteState::Issued,
+        }
+    }
+}
+
+impl From<QuoteState> for cdk::nuts::MintQuoteState {
+    fn from(state: QuoteState) -> Self {
+        match state {
+            QuoteState::Unpaid => cdk::nuts::MintQuoteState::Unpaid,
+            QuoteState::Paid => cdk::nuts::MintQuoteState::Paid,
+            QuoteState::Issued => cdk::nuts::MintQuoteState::Issued,
+            QuoteState::Pending => cdk::nuts::MintQuoteState::Paid, // Map pending to paid
+        }
+    }
+}
+
+// Note: MeltQuoteState is the same as nut05::QuoteState, so we don't need a separate impl

+ 175 - 0
crates/cdk-ffi/src/types/subscription.rs

@@ -0,0 +1,175 @@
+//! Subscription-related FFI types
+use std::sync::Arc;
+
+use cdk::event::MintEvent;
+use serde::{Deserialize, Serialize};
+
+use super::proof::ProofStateUpdate;
+use super::quote::{MeltQuoteBolt11Response, MintQuoteBolt11Response};
+use crate::error::FfiError;
+
+/// FFI-compatible SubscriptionKind
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
+pub enum SubscriptionKind {
+    /// Bolt 11 Melt Quote
+    Bolt11MeltQuote,
+    /// Bolt 11 Mint Quote
+    Bolt11MintQuote,
+    /// Bolt 12 Mint Quote
+    Bolt12MintQuote,
+    /// Proof State
+    ProofState,
+}
+
+impl From<SubscriptionKind> for cdk::nuts::nut17::Kind {
+    fn from(kind: SubscriptionKind) -> Self {
+        match kind {
+            SubscriptionKind::Bolt11MeltQuote => cdk::nuts::nut17::Kind::Bolt11MeltQuote,
+            SubscriptionKind::Bolt11MintQuote => cdk::nuts::nut17::Kind::Bolt11MintQuote,
+            SubscriptionKind::Bolt12MintQuote => cdk::nuts::nut17::Kind::Bolt12MintQuote,
+            SubscriptionKind::ProofState => cdk::nuts::nut17::Kind::ProofState,
+        }
+    }
+}
+
+impl From<cdk::nuts::nut17::Kind> for SubscriptionKind {
+    fn from(kind: cdk::nuts::nut17::Kind) -> Self {
+        match kind {
+            cdk::nuts::nut17::Kind::Bolt11MeltQuote => SubscriptionKind::Bolt11MeltQuote,
+            cdk::nuts::nut17::Kind::Bolt11MintQuote => SubscriptionKind::Bolt11MintQuote,
+            cdk::nuts::nut17::Kind::Bolt12MintQuote => SubscriptionKind::Bolt12MintQuote,
+            cdk::nuts::nut17::Kind::ProofState => SubscriptionKind::ProofState,
+        }
+    }
+}
+
+/// FFI-compatible SubscribeParams
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct SubscribeParams {
+    /// Subscription kind
+    pub kind: SubscriptionKind,
+    /// Filters
+    pub filters: Vec<String>,
+    /// Subscription ID (optional, will be generated if not provided)
+    pub id: Option<String>,
+}
+
+impl From<SubscribeParams> for cdk::nuts::nut17::Params<Arc<String>> {
+    fn from(params: SubscribeParams) -> Self {
+        let sub_id = params.id.unwrap_or_else(|| {
+            // Generate a random ID
+            uuid::Uuid::new_v4().to_string()
+        });
+
+        cdk::nuts::nut17::Params {
+            kind: params.kind.into(),
+            filters: params.filters,
+            id: Arc::new(sub_id),
+        }
+    }
+}
+
+impl SubscribeParams {
+    /// Convert SubscribeParams to JSON string
+    pub fn to_json(&self) -> Result<String, FfiError> {
+        Ok(serde_json::to_string(self)?)
+    }
+}
+
+/// Decode SubscribeParams from JSON string
+#[uniffi::export]
+pub fn decode_subscribe_params(json: String) -> Result<SubscribeParams, FfiError> {
+    Ok(serde_json::from_str(&json)?)
+}
+
+/// Encode SubscribeParams to JSON string
+#[uniffi::export]
+pub fn encode_subscribe_params(params: SubscribeParams) -> Result<String, FfiError> {
+    Ok(serde_json::to_string(&params)?)
+}
+
+/// FFI-compatible ActiveSubscription
+#[derive(uniffi::Object)]
+pub struct ActiveSubscription {
+    inner: std::sync::Arc<tokio::sync::Mutex<cdk::wallet::subscription::ActiveSubscription>>,
+    pub sub_id: String,
+}
+
+impl ActiveSubscription {
+    pub(crate) fn new(
+        inner: cdk::wallet::subscription::ActiveSubscription,
+        sub_id: String,
+    ) -> Self {
+        Self {
+            inner: std::sync::Arc::new(tokio::sync::Mutex::new(inner)),
+            sub_id,
+        }
+    }
+}
+
+#[uniffi::export(async_runtime = "tokio")]
+impl ActiveSubscription {
+    /// Get the subscription ID
+    pub fn id(&self) -> String {
+        self.sub_id.clone()
+    }
+
+    /// Receive the next notification
+    pub async fn recv(&self) -> Result<NotificationPayload, FfiError> {
+        let mut guard = self.inner.lock().await;
+        guard
+            .recv()
+            .await
+            .ok_or(FfiError::Generic {
+                msg: "Subscription closed".to_string(),
+            })
+            .map(Into::into)
+    }
+
+    /// Try to receive a notification without blocking
+    pub async fn try_recv(&self) -> Result<Option<NotificationPayload>, FfiError> {
+        let mut guard = self.inner.lock().await;
+        Ok(guard.try_recv().map(Into::into))
+    }
+}
+
+/// FFI-compatible NotificationPayload
+#[derive(Debug, Clone, uniffi::Enum)]
+pub enum NotificationPayload {
+    /// Proof state update
+    ProofState { proof_states: Vec<ProofStateUpdate> },
+    /// Mint quote update
+    MintQuoteUpdate {
+        quote: std::sync::Arc<MintQuoteBolt11Response>,
+    },
+    /// Melt quote update
+    MeltQuoteUpdate {
+        quote: std::sync::Arc<MeltQuoteBolt11Response>,
+    },
+}
+
+impl From<MintEvent<String>> for NotificationPayload {
+    fn from(payload: MintEvent<String>) -> Self {
+        match payload.into() {
+            cdk::nuts::NotificationPayload::ProofState(states) => NotificationPayload::ProofState {
+                proof_states: vec![states.into()],
+            },
+            cdk::nuts::NotificationPayload::MintQuoteBolt11Response(quote_resp) => {
+                NotificationPayload::MintQuoteUpdate {
+                    quote: std::sync::Arc::new(quote_resp.into()),
+                }
+            }
+            cdk::nuts::NotificationPayload::MeltQuoteBolt11Response(quote_resp) => {
+                NotificationPayload::MeltQuoteUpdate {
+                    quote: std::sync::Arc::new(quote_resp.into()),
+                }
+            }
+            _ => {
+                // For now, handle other notification types as empty ProofState
+                NotificationPayload::ProofState {
+                    proof_states: vec![],
+                }
+            }
+        }
+    }
+}

+ 255 - 0
crates/cdk-ffi/src/types/transaction.rs

@@ -0,0 +1,255 @@
+//! Transaction-related FFI types
+
+use std::collections::HashMap;
+
+use serde::{Deserialize, Serialize};
+
+use super::amount::{Amount, CurrencyUnit};
+use super::keys::PublicKey;
+use super::mint::MintUrl;
+use super::proof::Proofs;
+use crate::error::FfiError;
+
+/// FFI-compatible Transaction
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct Transaction {
+    /// Transaction ID
+    pub id: TransactionId,
+    /// Mint URL
+    pub mint_url: MintUrl,
+    /// Transaction direction
+    pub direction: TransactionDirection,
+    /// Amount
+    pub amount: Amount,
+    /// Fee
+    pub fee: Amount,
+    /// Currency Unit
+    pub unit: CurrencyUnit,
+    /// Proof Ys (Y values from proofs)
+    pub ys: Vec<PublicKey>,
+    /// Unix timestamp
+    pub timestamp: u64,
+    /// Memo
+    pub memo: Option<String>,
+    /// User-defined metadata
+    pub metadata: HashMap<String, String>,
+    /// Quote ID if this is a mint or melt transaction
+    pub quote_id: Option<String>,
+    /// Payment request (e.g., BOLT11 invoice, BOLT12 offer)
+    pub payment_request: Option<String>,
+    /// Payment proof (e.g., preimage for Lightning melt transactions)
+    pub payment_proof: Option<String>,
+}
+
+impl From<cdk::wallet::types::Transaction> for Transaction {
+    fn from(tx: cdk::wallet::types::Transaction) -> Self {
+        Self {
+            id: tx.id().into(),
+            mint_url: tx.mint_url.into(),
+            direction: tx.direction.into(),
+            amount: tx.amount.into(),
+            fee: tx.fee.into(),
+            unit: tx.unit.into(),
+            ys: tx.ys.into_iter().map(Into::into).collect(),
+            timestamp: tx.timestamp,
+            memo: tx.memo,
+            metadata: tx.metadata,
+            quote_id: tx.quote_id,
+            payment_request: tx.payment_request,
+            payment_proof: tx.payment_proof,
+        }
+    }
+}
+
+/// Convert FFI Transaction to CDK Transaction
+impl TryFrom<Transaction> for cdk::wallet::types::Transaction {
+    type Error = FfiError;
+
+    fn try_from(tx: Transaction) -> Result<Self, Self::Error> {
+        let cdk_ys: Result<Vec<cdk::nuts::PublicKey>, _> =
+            tx.ys.into_iter().map(|pk| pk.try_into()).collect();
+        let cdk_ys = cdk_ys?;
+
+        Ok(Self {
+            mint_url: tx.mint_url.try_into()?,
+            direction: tx.direction.into(),
+            amount: tx.amount.into(),
+            fee: tx.fee.into(),
+            unit: tx.unit.into(),
+            ys: cdk_ys,
+            timestamp: tx.timestamp,
+            memo: tx.memo,
+            metadata: tx.metadata,
+            quote_id: tx.quote_id,
+            payment_request: tx.payment_request,
+            payment_proof: tx.payment_proof,
+        })
+    }
+}
+
+impl Transaction {
+    /// Convert Transaction to JSON string
+    pub fn to_json(&self) -> Result<String, FfiError> {
+        Ok(serde_json::to_string(self)?)
+    }
+}
+
+/// Decode Transaction from JSON string
+#[uniffi::export]
+pub fn decode_transaction(json: String) -> Result<Transaction, FfiError> {
+    Ok(serde_json::from_str(&json)?)
+}
+
+/// Encode Transaction to JSON string
+#[uniffi::export]
+pub fn encode_transaction(transaction: Transaction) -> Result<String, FfiError> {
+    Ok(serde_json::to_string(&transaction)?)
+}
+
+/// FFI-compatible TransactionDirection
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
+pub enum TransactionDirection {
+    /// Incoming transaction (i.e., receive or mint)
+    Incoming,
+    /// Outgoing transaction (i.e., send or melt)
+    Outgoing,
+}
+
+impl From<cdk::wallet::types::TransactionDirection> for TransactionDirection {
+    fn from(direction: cdk::wallet::types::TransactionDirection) -> Self {
+        match direction {
+            cdk::wallet::types::TransactionDirection::Incoming => TransactionDirection::Incoming,
+            cdk::wallet::types::TransactionDirection::Outgoing => TransactionDirection::Outgoing,
+        }
+    }
+}
+
+impl From<TransactionDirection> for cdk::wallet::types::TransactionDirection {
+    fn from(direction: TransactionDirection) -> Self {
+        match direction {
+            TransactionDirection::Incoming => cdk::wallet::types::TransactionDirection::Incoming,
+            TransactionDirection::Outgoing => cdk::wallet::types::TransactionDirection::Outgoing,
+        }
+    }
+}
+
+/// FFI-compatible TransactionId
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+#[serde(transparent)]
+pub struct TransactionId {
+    /// Hex-encoded transaction ID (64 characters)
+    pub hex: String,
+}
+
+impl TransactionId {
+    /// Create a new TransactionId from hex string
+    pub fn from_hex(hex: String) -> Result<Self, FfiError> {
+        // Validate hex string length (should be 64 characters for 32 bytes)
+        if hex.len() != 64 {
+            return Err(FfiError::InvalidHex {
+                msg: "Transaction ID hex must be exactly 64 characters (32 bytes)".to_string(),
+            });
+        }
+
+        // Validate hex format
+        if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
+            return Err(FfiError::InvalidHex {
+                msg: "Transaction ID hex contains invalid characters".to_string(),
+            });
+        }
+
+        Ok(Self { hex })
+    }
+
+    /// Create from proofs
+    pub fn from_proofs(proofs: &Proofs) -> Result<Self, FfiError> {
+        let cdk_proofs: Vec<cdk::nuts::Proof> = proofs.iter().map(|p| p.inner.clone()).collect();
+        let id = cdk::wallet::types::TransactionId::from_proofs(cdk_proofs)?;
+        Ok(Self {
+            hex: id.to_string(),
+        })
+    }
+}
+
+impl From<cdk::wallet::types::TransactionId> for TransactionId {
+    fn from(id: cdk::wallet::types::TransactionId) -> Self {
+        Self {
+            hex: id.to_string(),
+        }
+    }
+}
+
+impl TryFrom<TransactionId> for cdk::wallet::types::TransactionId {
+    type Error = FfiError;
+
+    fn try_from(id: TransactionId) -> Result<Self, Self::Error> {
+        cdk::wallet::types::TransactionId::from_hex(&id.hex)
+            .map_err(|e| FfiError::InvalidHex { msg: e.to_string() })
+    }
+}
+
+/// FFI-compatible AuthProof
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct AuthProof {
+    /// Keyset ID
+    pub keyset_id: String,
+    /// Secret message
+    pub secret: String,
+    /// Unblinded signature (C)
+    pub c: String,
+    /// Y value (hash_to_curve of secret)
+    pub y: String,
+}
+
+impl From<cdk::nuts::AuthProof> for AuthProof {
+    fn from(auth_proof: cdk::nuts::AuthProof) -> Self {
+        Self {
+            keyset_id: auth_proof.keyset_id.to_string(),
+            secret: auth_proof.secret.to_string(),
+            c: auth_proof.c.to_string(),
+            y: auth_proof
+                .y()
+                .map(|y| y.to_string())
+                .unwrap_or_else(|_| "".to_string()),
+        }
+    }
+}
+
+impl TryFrom<AuthProof> for cdk::nuts::AuthProof {
+    type Error = FfiError;
+
+    fn try_from(auth_proof: AuthProof) -> Result<Self, Self::Error> {
+        use std::str::FromStr;
+        Ok(Self {
+            keyset_id: cdk::nuts::Id::from_str(&auth_proof.keyset_id)
+                .map_err(|e| FfiError::Serialization { msg: e.to_string() })?,
+            secret: {
+                use std::str::FromStr;
+                cdk::secret::Secret::from_str(&auth_proof.secret)
+                    .map_err(|e| FfiError::Serialization { msg: e.to_string() })?
+            },
+            c: cdk::nuts::PublicKey::from_str(&auth_proof.c)
+                .map_err(|e| FfiError::InvalidCryptographicKey { msg: e.to_string() })?,
+            dleq: None, // FFI doesn't expose DLEQ proofs for simplicity
+        })
+    }
+}
+
+impl AuthProof {
+    /// Convert AuthProof to JSON string
+    pub fn to_json(&self) -> Result<String, FfiError> {
+        Ok(serde_json::to_string(self)?)
+    }
+}
+
+/// Decode AuthProof from JSON string
+#[uniffi::export]
+pub fn decode_auth_proof(json: String) -> Result<AuthProof, FfiError> {
+    Ok(serde_json::from_str(&json)?)
+}
+
+/// Encode AuthProof to JSON string
+#[uniffi::export]
+pub fn encode_auth_proof(proof: AuthProof) -> Result<String, FfiError> {
+    Ok(serde_json::to_string(&proof)?)
+}

+ 471 - 0
crates/cdk-ffi/src/types/wallet.rs

@@ -0,0 +1,471 @@
+//! Wallet-related FFI types
+
+use std::collections::HashMap;
+use std::sync::Mutex;
+
+use serde::{Deserialize, Serialize};
+
+use super::amount::{Amount, SplitTarget};
+use super::proof::{Proofs, SpendingConditions};
+use crate::error::FfiError;
+use crate::token::Token;
+
+/// FFI-compatible SendMemo
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct SendMemo {
+    /// Memo text
+    pub memo: String,
+    /// Include memo in token
+    pub include_memo: bool,
+}
+
+impl From<SendMemo> for cdk::wallet::SendMemo {
+    fn from(memo: SendMemo) -> Self {
+        cdk::wallet::SendMemo {
+            memo: memo.memo,
+            include_memo: memo.include_memo,
+        }
+    }
+}
+
+impl From<cdk::wallet::SendMemo> for SendMemo {
+    fn from(memo: cdk::wallet::SendMemo) -> Self {
+        Self {
+            memo: memo.memo,
+            include_memo: memo.include_memo,
+        }
+    }
+}
+
+impl SendMemo {
+    /// Convert SendMemo to JSON string
+    pub fn to_json(&self) -> Result<String, FfiError> {
+        Ok(serde_json::to_string(self)?)
+    }
+}
+
+/// Decode SendMemo from JSON string
+#[uniffi::export]
+pub fn decode_send_memo(json: String) -> Result<SendMemo, FfiError> {
+    Ok(serde_json::from_str(&json)?)
+}
+
+/// Encode SendMemo to JSON string
+#[uniffi::export]
+pub fn encode_send_memo(memo: SendMemo) -> Result<String, FfiError> {
+    Ok(serde_json::to_string(&memo)?)
+}
+
+/// FFI-compatible SendKind
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)]
+pub enum SendKind {
+    /// Allow online swap before send if wallet does not have exact amount
+    OnlineExact,
+    /// Prefer offline send if difference is less than tolerance
+    OnlineTolerance { tolerance: Amount },
+    /// Wallet cannot do an online swap and selected proof must be exactly send amount
+    OfflineExact,
+    /// Wallet must remain offline but can over pay if below tolerance
+    OfflineTolerance { tolerance: Amount },
+}
+
+impl From<SendKind> for cdk::wallet::SendKind {
+    fn from(kind: SendKind) -> Self {
+        match kind {
+            SendKind::OnlineExact => cdk::wallet::SendKind::OnlineExact,
+            SendKind::OnlineTolerance { tolerance } => {
+                cdk::wallet::SendKind::OnlineTolerance(tolerance.into())
+            }
+            SendKind::OfflineExact => cdk::wallet::SendKind::OfflineExact,
+            SendKind::OfflineTolerance { tolerance } => {
+                cdk::wallet::SendKind::OfflineTolerance(tolerance.into())
+            }
+        }
+    }
+}
+
+impl From<cdk::wallet::SendKind> for SendKind {
+    fn from(kind: cdk::wallet::SendKind) -> Self {
+        match kind {
+            cdk::wallet::SendKind::OnlineExact => SendKind::OnlineExact,
+            cdk::wallet::SendKind::OnlineTolerance(tolerance) => SendKind::OnlineTolerance {
+                tolerance: tolerance.into(),
+            },
+            cdk::wallet::SendKind::OfflineExact => SendKind::OfflineExact,
+            cdk::wallet::SendKind::OfflineTolerance(tolerance) => SendKind::OfflineTolerance {
+                tolerance: tolerance.into(),
+            },
+        }
+    }
+}
+
+/// FFI-compatible Send options
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct SendOptions {
+    /// Memo
+    pub memo: Option<SendMemo>,
+    /// Spending conditions
+    pub conditions: Option<SpendingConditions>,
+    /// Amount split target
+    pub amount_split_target: SplitTarget,
+    /// Send kind
+    pub send_kind: SendKind,
+    /// Include fee
+    pub include_fee: bool,
+    /// Maximum number of proofs to include in the token
+    pub max_proofs: Option<u32>,
+    /// Metadata
+    pub metadata: HashMap<String, String>,
+}
+
+impl Default for SendOptions {
+    fn default() -> Self {
+        Self {
+            memo: None,
+            conditions: None,
+            amount_split_target: SplitTarget::None,
+            send_kind: SendKind::OnlineExact,
+            include_fee: false,
+            max_proofs: None,
+            metadata: HashMap::new(),
+        }
+    }
+}
+
+impl From<SendOptions> for cdk::wallet::SendOptions {
+    fn from(opts: SendOptions) -> Self {
+        cdk::wallet::SendOptions {
+            memo: opts.memo.map(Into::into),
+            conditions: opts.conditions.and_then(|c| c.try_into().ok()),
+            amount_split_target: opts.amount_split_target.into(),
+            send_kind: opts.send_kind.into(),
+            include_fee: opts.include_fee,
+            max_proofs: opts.max_proofs.map(|p| p as usize),
+            metadata: opts.metadata,
+        }
+    }
+}
+
+impl From<cdk::wallet::SendOptions> for SendOptions {
+    fn from(opts: cdk::wallet::SendOptions) -> Self {
+        Self {
+            memo: opts.memo.map(Into::into),
+            conditions: opts.conditions.map(Into::into),
+            amount_split_target: opts.amount_split_target.into(),
+            send_kind: opts.send_kind.into(),
+            include_fee: opts.include_fee,
+            max_proofs: opts.max_proofs.map(|p| p as u32),
+            metadata: opts.metadata,
+        }
+    }
+}
+
+impl SendOptions {
+    /// Convert SendOptions to JSON string
+    pub fn to_json(&self) -> Result<String, FfiError> {
+        Ok(serde_json::to_string(self)?)
+    }
+}
+
+/// Decode SendOptions from JSON string
+#[uniffi::export]
+pub fn decode_send_options(json: String) -> Result<SendOptions, FfiError> {
+    Ok(serde_json::from_str(&json)?)
+}
+
+/// Encode SendOptions to JSON string
+#[uniffi::export]
+pub fn encode_send_options(options: SendOptions) -> Result<String, FfiError> {
+    Ok(serde_json::to_string(&options)?)
+}
+
+/// FFI-compatible SecretKey
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+#[serde(transparent)]
+pub struct SecretKey {
+    /// Hex-encoded secret key (64 characters)
+    pub hex: String,
+}
+
+impl SecretKey {
+    /// Create a new SecretKey from hex string
+    pub fn from_hex(hex: String) -> Result<Self, FfiError> {
+        // Validate hex string length (should be 64 characters for 32 bytes)
+        if hex.len() != 64 {
+            return Err(FfiError::InvalidHex {
+                msg: "Secret key hex must be exactly 64 characters (32 bytes)".to_string(),
+            });
+        }
+
+        // Validate hex format
+        if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
+            return Err(FfiError::InvalidHex {
+                msg: "Secret key hex contains invalid characters".to_string(),
+            });
+        }
+
+        Ok(Self { hex })
+    }
+
+    /// Generate a random secret key
+    pub fn random() -> Self {
+        use cdk::nuts::SecretKey as CdkSecretKey;
+        let secret_key = CdkSecretKey::generate();
+        Self {
+            hex: secret_key.to_secret_hex(),
+        }
+    }
+}
+
+impl From<SecretKey> for cdk::nuts::SecretKey {
+    fn from(key: SecretKey) -> Self {
+        // This will panic if hex is invalid, but we validate in from_hex()
+        cdk::nuts::SecretKey::from_hex(&key.hex).expect("Invalid secret key hex")
+    }
+}
+
+impl From<cdk::nuts::SecretKey> for SecretKey {
+    fn from(key: cdk::nuts::SecretKey) -> Self {
+        Self {
+            hex: key.to_secret_hex(),
+        }
+    }
+}
+
+/// FFI-compatible Receive options
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct ReceiveOptions {
+    /// Amount split target
+    pub amount_split_target: SplitTarget,
+    /// P2PK signing keys
+    pub p2pk_signing_keys: Vec<SecretKey>,
+    /// Preimages for HTLC conditions
+    pub preimages: Vec<String>,
+    /// Metadata
+    pub metadata: HashMap<String, String>,
+}
+
+impl Default for ReceiveOptions {
+    fn default() -> Self {
+        Self {
+            amount_split_target: SplitTarget::None,
+            p2pk_signing_keys: Vec::new(),
+            preimages: Vec::new(),
+            metadata: HashMap::new(),
+        }
+    }
+}
+
+impl From<ReceiveOptions> for cdk::wallet::ReceiveOptions {
+    fn from(opts: ReceiveOptions) -> Self {
+        cdk::wallet::ReceiveOptions {
+            amount_split_target: opts.amount_split_target.into(),
+            p2pk_signing_keys: opts.p2pk_signing_keys.into_iter().map(Into::into).collect(),
+            preimages: opts.preimages,
+            metadata: opts.metadata,
+        }
+    }
+}
+
+impl From<cdk::wallet::ReceiveOptions> for ReceiveOptions {
+    fn from(opts: cdk::wallet::ReceiveOptions) -> Self {
+        Self {
+            amount_split_target: opts.amount_split_target.into(),
+            p2pk_signing_keys: opts.p2pk_signing_keys.into_iter().map(Into::into).collect(),
+            preimages: opts.preimages,
+            metadata: opts.metadata,
+        }
+    }
+}
+
+impl ReceiveOptions {
+    /// Convert ReceiveOptions to JSON string
+    pub fn to_json(&self) -> Result<String, FfiError> {
+        Ok(serde_json::to_string(self)?)
+    }
+}
+
+/// Decode ReceiveOptions from JSON string
+#[uniffi::export]
+pub fn decode_receive_options(json: String) -> Result<ReceiveOptions, FfiError> {
+    Ok(serde_json::from_str(&json)?)
+}
+
+/// Encode ReceiveOptions to JSON string
+#[uniffi::export]
+pub fn encode_receive_options(options: ReceiveOptions) -> Result<String, FfiError> {
+    Ok(serde_json::to_string(&options)?)
+}
+
+/// FFI-compatible PreparedSend
+#[derive(Debug, uniffi::Object)]
+pub struct PreparedSend {
+    inner: Mutex<Option<cdk::wallet::PreparedSend>>,
+    id: String,
+    amount: Amount,
+    proofs: Proofs,
+}
+
+impl From<cdk::wallet::PreparedSend> for PreparedSend {
+    fn from(prepared: cdk::wallet::PreparedSend) -> Self {
+        let id = format!("{:?}", prepared); // Use debug format as ID
+        let amount = prepared.amount().into();
+        let proofs = prepared
+            .proofs()
+            .iter()
+            .cloned()
+            .map(|p| std::sync::Arc::new(p.into()))
+            .collect();
+        Self {
+            inner: Mutex::new(Some(prepared)),
+            id,
+            amount,
+            proofs,
+        }
+    }
+}
+
+#[uniffi::export(async_runtime = "tokio")]
+impl PreparedSend {
+    /// Get the prepared send ID
+    pub fn id(&self) -> String {
+        self.id.clone()
+    }
+
+    /// Get the amount to send
+    pub fn amount(&self) -> Amount {
+        self.amount
+    }
+
+    /// Get the proofs that will be used
+    pub fn proofs(&self) -> Proofs {
+        self.proofs.clone()
+    }
+
+    /// Get the total fee for this send operation
+    pub fn fee(&self) -> Amount {
+        if let Ok(guard) = self.inner.lock() {
+            if let Some(ref inner) = *guard {
+                inner.fee().into()
+            } else {
+                Amount::new(0)
+            }
+        } else {
+            Amount::new(0)
+        }
+    }
+
+    /// Confirm the prepared send and create a token
+    pub async fn confirm(
+        self: std::sync::Arc<Self>,
+        memo: Option<String>,
+    ) -> Result<Token, FfiError> {
+        let inner = {
+            if let Ok(mut guard) = self.inner.lock() {
+                guard.take()
+            } else {
+                return Err(FfiError::Generic {
+                    msg: "Failed to acquire lock on PreparedSend".to_string(),
+                });
+            }
+        };
+
+        if let Some(inner) = inner {
+            let send_memo = memo.map(|m| cdk::wallet::SendMemo::for_token(&m));
+            let token = inner.confirm(send_memo).await?;
+            Ok(token.into())
+        } else {
+            Err(FfiError::Generic {
+                msg: "PreparedSend has already been consumed or cancelled".to_string(),
+            })
+        }
+    }
+
+    /// Cancel the prepared send operation
+    pub async fn cancel(self: std::sync::Arc<Self>) -> Result<(), FfiError> {
+        let inner = {
+            if let Ok(mut guard) = self.inner.lock() {
+                guard.take()
+            } else {
+                return Err(FfiError::Generic {
+                    msg: "Failed to acquire lock on PreparedSend".to_string(),
+                });
+            }
+        };
+
+        if let Some(inner) = inner {
+            inner.cancel().await?;
+            Ok(())
+        } else {
+            Err(FfiError::Generic {
+                msg: "PreparedSend has already been consumed or cancelled".to_string(),
+            })
+        }
+    }
+}
+
+/// FFI-compatible Melted result
+#[derive(Debug, Clone, uniffi::Record)]
+pub struct Melted {
+    pub state: super::quote::QuoteState,
+    pub preimage: Option<String>,
+    pub change: Option<Proofs>,
+    pub amount: Amount,
+    pub fee_paid: Amount,
+}
+
+// MeltQuoteState is just an alias for nut05::QuoteState, so we don't need a separate implementation
+
+impl From<cdk::types::Melted> for Melted {
+    fn from(melted: cdk::types::Melted) -> Self {
+        Self {
+            state: melted.state.into(),
+            preimage: melted.preimage,
+            change: melted.change.map(|proofs| {
+                proofs
+                    .into_iter()
+                    .map(|p| std::sync::Arc::new(p.into()))
+                    .collect()
+            }),
+            amount: melted.amount.into(),
+            fee_paid: melted.fee_paid.into(),
+        }
+    }
+}
+
+/// FFI-compatible MeltOptions
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)]
+pub enum MeltOptions {
+    /// MPP (Multi-Part Payments) options
+    Mpp { amount: Amount },
+    /// Amountless options
+    Amountless { amount_msat: Amount },
+}
+
+impl From<MeltOptions> for cdk::nuts::MeltOptions {
+    fn from(opts: MeltOptions) -> Self {
+        match opts {
+            MeltOptions::Mpp { amount } => {
+                let cdk_amount: cdk::Amount = amount.into();
+                cdk::nuts::MeltOptions::new_mpp(cdk_amount)
+            }
+            MeltOptions::Amountless { amount_msat } => {
+                let cdk_amount: cdk::Amount = amount_msat.into();
+                cdk::nuts::MeltOptions::new_amountless(cdk_amount)
+            }
+        }
+    }
+}
+
+impl From<cdk::nuts::MeltOptions> for MeltOptions {
+    fn from(opts: cdk::nuts::MeltOptions) -> Self {
+        match opts {
+            cdk::nuts::MeltOptions::Mpp { mpp } => MeltOptions::Mpp {
+                amount: mpp.amount.into(),
+            },
+            cdk::nuts::MeltOptions::Amountless { amountless } => MeltOptions::Amountless {
+                amount_msat: amountless.amount_msat.into(),
+            },
+        }
+    }
+}

+ 9 - 5
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
@@ -348,7 +349,7 @@ impl Wallet {
         &self,
         params: SubscribeParams,
     ) -> Result<std::sync::Arc<ActiveSubscription>, FfiError> {
-        let cdk_params: cdk::nuts::nut17::Params<cdk::pub_sub::SubId> = params.clone().into();
+        let cdk_params: cdk::nuts::nut17::Params<Arc<String>> = params.clone().into();
         let sub_id = cdk_params.id.to_string();
         let active_sub = self.inner.subscribe(cdk_params).await;
         Ok(std::sync::Arc::new(ActiveSubscription::new(
@@ -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,6 +8,8 @@ cdylib_name = "cdk_ffi"
 [bindings.swift]
 module_name = "CashuDevKit"
 cdylib_name = "cdk_ffi"
+generate_codable_conformance = true
+generate_immutable_records = true
 
 [bindings.go]
 go_mod = "github.com/cashubtc/cdk-go"

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

+ 25 - 4
crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs

@@ -9,6 +9,7 @@
 //! whether to use real Lightning Network payments (regtest mode) or simulated payments.
 
 use core::panic;
+use std::collections::HashMap;
 use std::env;
 use std::fmt::Debug;
 use std::path::PathBuf;
@@ -161,9 +162,23 @@ async fn test_happy_mint_melt_round_trip() {
 
     assert_eq!(response_json, expected_json);
 
-    let melt_response = wallet.melt(&melt.id).await.unwrap();
+    let mut metadata = HashMap::new();
+    metadata.insert("test".to_string(), "value".to_string());
+
+    let melt_response = wallet
+        .melt_with_metadata(&melt.id, metadata.clone())
+        .await
+        .unwrap();
     assert!(melt_response.preimage.is_some());
-    assert!(melt_response.state == MeltQuoteState::Paid);
+    assert_eq!(melt_response.state, MeltQuoteState::Paid);
+
+    let txs = wallet.list_transactions(None).await.unwrap();
+    let tx = txs
+        .into_iter()
+        .find(|tx| tx.quote_id == Some(melt.id.clone()))
+        .unwrap();
+    assert_eq!(tx.amount, melt.amount);
+    assert_eq!(tx.metadata, metadata);
 
     let (sub_id, payload) = get_notification(&mut reader, Duration::from_millis(15000)).await;
     // first message is the current state
@@ -376,9 +391,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);
 

+ 127 - 37
crates/cdk-integration-tests/tests/integration_tests_pure.rs

@@ -13,6 +13,7 @@ use std::assert_eq;
 use std::collections::{HashMap, HashSet};
 use std::hash::RandomState;
 use std::str::FromStr;
+use std::sync::Arc;
 use std::time::Duration;
 
 use cashu::amount::SplitTarget;
@@ -24,7 +25,7 @@ use cashu::{
 };
 use cdk::mint::Mint;
 use cdk::nuts::nut00::ProofsMethods;
-use cdk::subscription::{IndexableParams, Params};
+use cdk::subscription::Params;
 use cdk::wallet::types::{TransactionDirection, TransactionId};
 use cdk::wallet::{ReceiveOptions, SendMemo, SendOptions};
 use cdk::Amount;
@@ -243,11 +244,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 +263,7 @@ async fn test_mint_double_spend() {
         keyset_id,
         proofs.total_amount().unwrap(),
         &SplitTarget::default(),
+        &fee_and_amounts,
     )
     .unwrap();
 
@@ -300,14 +304,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 +340,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 +374,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 +396,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 +440,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 +465,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());
 
@@ -445,15 +486,11 @@ pub async fn test_p2pk_swap() {
 
     let mut listener = mint_bob
         .pubsub_manager()
-        .try_subscribe::<IndexableParams>(
-            Params {
-                kind: cdk::nuts::nut17::Kind::ProofState,
-                filters: public_keys_to_listen.clone(),
-                id: "test".into(),
-            }
-            .into(),
-        )
-        .await
+        .subscribe(Params {
+            kind: cdk::nuts::nut17::Kind::ProofState,
+            filters: public_keys_to_listen.clone(),
+            id: Arc::new("test".into()),
+        })
         .expect("valid subscription");
 
     match mint_bob.process_swap_request(swap_request).await {
@@ -480,9 +517,8 @@ pub async fn test_p2pk_swap() {
     sleep(Duration::from_secs(1)).await;
 
     let mut msgs = HashMap::new();
-    while let Ok((sub_id, msg)) = listener.try_recv() {
-        assert_eq!(sub_id, "test".into());
-        match msg {
+    while let Some(msg) = listener.try_recv() {
+        match msg.into_inner() {
             NotificationPayload::ProofState(ProofState { y, state, .. }) => {
                 msgs.entry(y.to_string())
                     .or_insert_with(Vec::new)
@@ -504,7 +540,7 @@ pub async fn test_p2pk_swap() {
         );
     }
 
-    assert!(listener.try_recv().is_err(), "no other event is happening");
+    assert!(listener.try_recv().is_none(), "no other event is happening");
     assert!(msgs.is_empty(), "Only expected key events are received");
 }
 
@@ -536,8 +572,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 +596,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 +651,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 +677,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 +693,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 +715,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 +795,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

+ 10 - 4
crates/cdk-integration-tests/tests/regtest.rs

@@ -165,7 +165,7 @@ async fn test_websocket_connection() {
         .expect("timeout waiting for unpaid notification")
         .expect("No paid notification received");
 
-    match msg {
+    match msg.into_inner() {
         NotificationPayload::MintQuoteBolt11Response(response) => {
             assert_eq!(response.quote.to_string(), mint_quote.id);
             assert_eq!(response.state, MintQuoteState::Unpaid);
@@ -185,7 +185,7 @@ async fn test_websocket_connection() {
         .expect("timeout waiting for paid notification")
         .expect("No paid notification received");
 
-    match msg {
+    match msg.into_inner() {
         NotificationPayload::MintQuoteBolt11Response(response) => {
             assert_eq!(response.quote.to_string(), mint_quote.id);
             assert_eq!(response.state, MintQuoteState::Paid);
@@ -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"]
 

+ 1 - 1
crates/cdk-mintd/README.md

@@ -251,7 +251,7 @@ For complete configuration options, see the [example configuration file](./examp
 ## Documentation
 
 - **[Configuration Examples](./example.config.toml)** - Complete configuration reference
-- **[PostgreSQL Setup Guide](../../POSTGRES.md)** - Database setup instructions
+- **[PostgreSQL Setup Guide](../../docker-compose.postgres.yaml)** - Database setup with Docker Compose
 - **[Development Guide](../../DEVELOPMENT.md)** - Contributing and development setup
 
 ## License

+ 5 - 3
crates/cdk-mintd/example.config.toml

@@ -14,8 +14,7 @@ melt_ttl = 120
 
 
 [info.logging]
-# Where to output logs: "stdout", "file", or "both" (default: "both")
-# Note: "stdout" actually outputs to stderr (standard error stream)
+# Where to output logs: "stderr" (standard error stream), "file", or "both" (default: "both")
 # output = "both"
 # Log level for console output (default: "info")
 # console_level = "info"  
@@ -33,10 +32,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-redb/src/error.rs

@@ -43,6 +43,9 @@ pub enum Error {
     /// CDK Error
     #[error(transparent)]
     CDK(#[from] cdk_common::error::Error),
+    /// IO Error
+    #[error(transparent)]
+    Io(#[from] std::io::Error),
     /// NUT00 Error
     #[error(transparent)]
     CDKNUT00(#[from] cdk_common::nuts::nut00::Error),

+ 35 - 1
crates/cdk-redb/src/wallet/mod.rs

@@ -58,6 +58,16 @@ impl WalletRedbDatabase {
     /// Create new [`WalletRedbDatabase`]
     pub fn new(path: &Path) -> Result<Self, Error> {
         {
+            // Check if parent directory exists before attempting to create database
+            if let Some(parent) = path.parent() {
+                if !parent.exists() {
+                    return Err(Error::Io(std::io::Error::new(
+                        std::io::ErrorKind::NotFound,
+                        format!("Parent directory does not exist: {:?}", parent),
+                    )));
+                }
+            }
+
             let db = Arc::new(Database::create(path)?);
 
             let db_version: Option<String>;
@@ -156,6 +166,16 @@ impl WalletRedbDatabase {
             drop(db);
         }
 
+        // Check parent directory again for final database creation
+        if let Some(parent) = path.parent() {
+            if !parent.exists() {
+                return Err(Error::Io(std::io::Error::new(
+                    std::io::ErrorKind::NotFound,
+                    format!("Parent directory does not exist: {:?}", parent),
+                )));
+            }
+        }
+
         let mut db = Database::create(path)?;
 
         db.upgrade()?;
@@ -721,6 +741,18 @@ impl WalletDatabase for WalletRedbDatabase {
         Ok(proofs)
     }
 
+    async fn get_balance(
+        &self,
+        mint_url: Option<MintUrl>,
+        unit: Option<CurrencyUnit>,
+        state: Option<Vec<State>>,
+    ) -> Result<u64, database::Error> {
+        // For redb, we still need to fetch all proofs and sum them
+        // since redb doesn't have SQL aggregation
+        let proofs = self.get_proofs(mint_url, unit, state, None).await?;
+        Ok(proofs.iter().map(|p| u64::from(p.proof.amount)).sum())
+    }
+
     async fn update_proofs_state(
         &self,
         ys: Vec<PublicKey>,
@@ -795,6 +827,8 @@ impl WalletDatabase for WalletRedbDatabase {
 
     #[instrument(skip(self))]
     async fn add_transaction(&self, transaction: Transaction) -> Result<(), Self::Err> {
+        let id = transaction.id();
+
         let write_txn = self.db.begin_write().map_err(Error::from)?;
 
         {
@@ -803,7 +837,7 @@ impl WalletDatabase for WalletRedbDatabase {
                 .map_err(Error::from)?;
             table
                 .insert(
-                    transaction.id().as_slice(),
+                    id.as_slice(),
                     serde_json::to_string(&transaction)
                         .map_err(Error::from)?
                         .as_str(),

+ 10 - 8
crates/cdk-signatory/src/proto/convert.rs

@@ -43,6 +43,14 @@ impl TryInto<crate::signatory::SignatoryKeySet> for KeySet {
     type Error = cdk_common::Error;
 
     fn try_into(self) -> Result<crate::signatory::SignatoryKeySet, Self::Error> {
+        let keys = self
+            .keys
+            .ok_or(cdk_common::Error::Custom(INTERNAL_ERROR.to_owned()))?
+            .keys
+            .into_iter()
+            .map(|(amount, pk)| PublicKey::from_slice(&pk).map(|pk| (amount.into(), pk)))
+            .collect::<Result<BTreeMap<Amount, _>, _>>()?;
+
         Ok(crate::signatory::SignatoryKeySet {
             id: Id::from_bytes(&self.id)?,
             unit: self
@@ -52,14 +60,8 @@ impl TryInto<crate::signatory::SignatoryKeySet> for KeySet {
                 .map_err(|_| cdk_common::Error::Custom("Invalid currency unit".to_owned()))?,
             active: self.active,
             input_fee_ppk: self.input_fee_ppk,
-            keys: cdk_common::Keys::new(
-                self.keys
-                    .ok_or(cdk_common::Error::Custom(INTERNAL_ERROR.to_owned()))?
-                    .keys
-                    .into_iter()
-                    .map(|(amount, pk)| PublicKey::from_slice(&pk).map(|pk| (amount.into(), pk)))
-                    .collect::<Result<BTreeMap<Amount, _>, _>>()?,
-            ),
+            amounts: keys.keys().map(|x| x.to_u64()).collect::<Vec<_>>(),
+            keys: cdk_common::Keys::new(keys),
             final_expiry: self.final_expiry,
         })
     }

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

+ 3 - 0
crates/cdk-sql-common/src/wallet/migrations/postgres/20251005120000_add_payment_info_to_transactions.sql

@@ -0,0 +1,3 @@
+-- Add payment_request and payment_proof to transactions table
+ALTER TABLE transactions ADD COLUMN payment_request TEXT;
+ALTER TABLE transactions ADD COLUMN payment_proof TEXT;

+ 3 - 0
crates/cdk-sql-common/src/wallet/migrations/sqlite/20251005120000_add_payment_info_to_transactions.sql

@@ -0,0 +1,3 @@
+-- Add payment_request and payment_proof to transactions table
+ALTER TABLE transactions ADD COLUMN payment_request TEXT;
+ALTER TABLE transactions ADD COLUMN payment_proof TEXT;

+ 87 - 9
crates/cdk-sql-common/src/wallet/mod.rs

@@ -836,6 +836,70 @@ ON CONFLICT(id) DO UPDATE SET
         .collect::<Vec<_>>())
     }
 
+    async fn get_balance(
+        &self,
+        mint_url: Option<MintUrl>,
+        unit: Option<CurrencyUnit>,
+        states: Option<Vec<State>>,
+    ) -> Result<u64, Self::Err> {
+        let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
+
+        let mut query_str = "SELECT COALESCE(SUM(amount), 0) as total FROM proof".to_string();
+        let mut where_clauses = Vec::new();
+        let states = states
+            .unwrap_or_default()
+            .into_iter()
+            .map(|x| x.to_string())
+            .collect::<Vec<_>>();
+
+        if mint_url.is_some() {
+            where_clauses.push("mint_url = :mint_url");
+        }
+        if unit.is_some() {
+            where_clauses.push("unit = :unit");
+        }
+        if !states.is_empty() {
+            where_clauses.push("state IN (:states)");
+        }
+
+        if !where_clauses.is_empty() {
+            query_str.push_str(" WHERE ");
+            query_str.push_str(&where_clauses.join(" AND "));
+        }
+
+        let mut q = query(&query_str)?;
+
+        if let Some(ref mint_url) = mint_url {
+            q = q.bind("mint_url", mint_url.to_string());
+        }
+        if let Some(ref unit) = unit {
+            q = q.bind("unit", unit.to_string());
+        }
+
+        if !states.is_empty() {
+            q = q.bind_vec("states", states);
+        }
+
+        let balance = q
+            .pluck(&*conn)
+            .await?
+            .map(|n| {
+                // SQLite SUM returns INTEGER which we need to convert to u64
+                match n {
+                    crate::stmt::Column::Integer(i) => Ok(i as u64),
+                    crate::stmt::Column::Real(f) => Ok(f as u64),
+                    _ => Err(Error::Database(Box::new(std::io::Error::new(
+                        std::io::ErrorKind::InvalidData,
+                        "Invalid balance type",
+                    )))),
+                }
+            })
+            .transpose()?
+            .unwrap_or(0);
+
+        Ok(balance)
+    }
+
     async fn update_proofs_state(&self, ys: Vec<PublicKey>, state: State) -> Result<(), Self::Err> {
         let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
         query("UPDATE proof SET state = :state WHERE y IN (:ys)")?
@@ -890,7 +954,6 @@ ON CONFLICT(id) DO UPDATE SET
 
     #[instrument(skip(self))]
     async fn add_transaction(&self, transaction: Transaction) -> Result<(), Self::Err> {
-        let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
         let mint_url = transaction.mint_url.to_string();
         let direction = transaction.direction.to_string();
         let unit = transaction.unit.to_string();
@@ -902,27 +965,32 @@ ON CONFLICT(id) DO UPDATE SET
             .flat_map(|y| y.to_bytes().to_vec())
             .collect::<Vec<_>>();
 
+        let id = transaction.id();
+
+        let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
+
         query(
             r#"
 INSERT INTO transactions
-(id, mint_url, direction, unit, amount, fee, ys, timestamp, memo, metadata, quote_id)
+(id, mint_url, direction, unit, amount, fee, ys, timestamp, memo, metadata, quote_id, payment_request, payment_proof)
 VALUES
-(:id, :mint_url, :direction, :unit, :amount, :fee, :ys, :timestamp, :memo, :metadata, :quote_id)
+(:id, :mint_url, :direction, :unit, :amount, :fee, :ys, :timestamp, :memo, :metadata, :quote_id, :payment_request, :payment_proof)
 ON CONFLICT(id) DO UPDATE SET
     mint_url = excluded.mint_url,
     direction = excluded.direction,
     unit = excluded.unit,
     amount = excluded.amount,
     fee = excluded.fee,
-    ys = excluded.ys,
     timestamp = excluded.timestamp,
     memo = excluded.memo,
     metadata = excluded.metadata,
-    quote_id = excluded.quote_id
+    quote_id = excluded.quote_id,
+    payment_request = excluded.payment_request,
+    payment_proof = excluded.payment_proof
 ;
         "#,
         )?
-        .bind("id", transaction.id().as_slice().to_vec())
+        .bind("id", id.as_slice().to_vec())
         .bind("mint_url", mint_url)
         .bind("direction", direction)
         .bind("unit", unit)
@@ -936,6 +1004,8 @@ ON CONFLICT(id) DO UPDATE SET
             serde_json::to_string(&transaction.metadata).map_err(Error::from)?,
         )
         .bind("quote_id", transaction.quote_id)
+        .bind("payment_request", transaction.payment_request)
+        .bind("payment_proof", transaction.payment_proof)
         .execute(&*conn)
         .await?;
 
@@ -960,7 +1030,9 @@ ON CONFLICT(id) DO UPDATE SET
                 timestamp,
                 memo,
                 metadata,
-                quote_id
+                quote_id,
+                payment_request,
+                payment_proof
             FROM
                 transactions
             WHERE
@@ -995,7 +1067,9 @@ ON CONFLICT(id) DO UPDATE SET
                 timestamp,
                 memo,
                 metadata,
-                quote_id
+                quote_id,
+                payment_request,
+                payment_proof
             FROM
                 transactions
             "#,
@@ -1233,7 +1307,9 @@ fn sql_row_to_transaction(row: Vec<Column>) -> Result<Transaction, Error> {
             timestamp,
             memo,
             metadata,
-            quote_id
+            quote_id,
+            payment_request,
+            payment_proof
         ) = row
     );
 
@@ -1257,5 +1333,7 @@ fn sql_row_to_transaction(row: Vec<Column>) -> Result<Transaction, Error> {
         })
         .unwrap_or_default(),
         quote_id: column_as_nullable_string!(quote_id),
+        payment_request: column_as_nullable_string!(payment_request),
+        payment_proof: column_as_nullable_string!(payment_proof),
     })
 }

+ 9 - 0
crates/cdk-sqlite/src/common.rs

@@ -47,6 +47,15 @@ impl DatabasePool for SqliteConnectionManager {
         _timeout: Duration,
     ) -> Result<Self::Connection, pool::Error<Self::Error>> {
         let conn = if let Some(path) = config.path.as_ref() {
+            // Check if parent directory exists before attempting to open database
+            let path_buf = PathBuf::from(path);
+            if let Some(parent) = path_buf.parent() {
+                if !parent.exists() {
+                    return Err(pool::Error::Resource(rusqlite::Error::InvalidPath(
+                        path_buf.clone(),
+                    )));
+                }
+            }
             Connection::open(path)?
         } else {
             Connection::open_in_memory()?

+ 25 - 12
crates/cdk/Cargo.toml

@@ -21,9 +21,21 @@ bip353 = ["dep:hickory-resolver"]
 swagger = ["mint", "dep:utoipa", "cdk-common/swagger"]
 bench = []
 http_subscription = []
+tor = [
+    "wallet",
+    "dep:arti-client",
+    "dep:arti-hyper",
+    "dep:hyper",
+    "dep:http",
+    "dep:rustls",
+    "dep:tor-rtcompat",
+    "dep:tls-api",
+    "dep:tls-api-native-tls",
+]
 prometheus = ["dep:cdk-prometheus"]
 
 [dependencies]
+arc-swap = "1.7.1"
 cdk-common.workspace = true
 cbor-diag.workspace = true
 async-trait.workspace = true
@@ -39,22 +51,15 @@ serde_json.workspace = true
 serde_with.workspace = true
 tracing.workspace = true
 thiserror.workspace = true
+
 futures = { workspace = true, optional = true, features = ["alloc"] }
 url.workspace = true
 utoipa = { workspace = true, optional = true }
 uuid.workspace = true
 jsonwebtoken = { workspace = true, optional = true }
-nostr-sdk = { optional = true, version = "0.43.0", default-features = false, features = [
-    "nip04",
-    "nip44",
-    "nip59"
-]}
+nostr-sdk = { workspace = true, optional = true }
 cdk-prometheus = {workspace = true, optional = true}
 web-time.workspace = true
-# -Z minimal-versions
-sync_wrapper = "0.1.2"
-bech32 = "0.9.1"
-arc-swap = "1.7.1"
 zeroize = "1"
 tokio-util.workspace = true
 
@@ -73,16 +78,24 @@ tokio-tungstenite = { workspace = true, features = [
     "rustls-tls-native-roots",
     "connect"
 ] }
+# Tor dependencies (optional; enabled by feature "tor")
+hyper = { version = "0.14", optional = true, features = ["client", "http1", "http2"] }
+http = { version = "0.2", optional = true }
+arti-client = { version = "0.19.0", optional = true, default-features = false, features = ["tokio", "rustls"] }
+arti-hyper = { version = "0.19.0", optional = true }
 rustls = { workspace = true, optional = true }
+tor-rtcompat = { version = "0.19.0", optional = true, features = ["tokio", "rustls"] }
+tls-api = { version = "0.9", optional = true }
+tls-api-native-tls = { version = "0.9", optional = true }
 
 [target.'cfg(target_arch = "wasm32")'.dependencies]
 tokio = { workspace = true, features = ["rt", "macros", "sync", "time"] }
 cdk-signatory = { workspace = true, default-features = false }
 getrandom = { version = "0.2", features = ["js"] }
 ring = { version = "0.17.14", features = ["wasm32_unknown_unknown_js"] }
+rustls = { workspace = true, optional = true }
+
 uuid = { workspace = true, features = ["js"] }
-wasm-bindgen = "0.2"
-wasm-bindgen-futures = "0.4"
 gloo-timers = { version = "0.3", features = ["futures"] }
 
 [[example]]
@@ -130,7 +143,7 @@ rand.workspace = true
 cdk-sqlite.workspace = true
 bip39.workspace = true
 tracing-subscriber.workspace = true
-criterion = "0.6.0"
+criterion.workspace = true
 reqwest = { workspace = true }
 anyhow.workspace = true
 ureq = { version = "3.1.0", features = ["json"] }

+ 127 - 0
crates/cdk/src/event.rs

@@ -0,0 +1,127 @@
+//! Mint event types
+use std::fmt::Debug;
+use std::hash::Hash;
+use std::ops::Deref;
+
+use cdk_common::nut17::NotificationId;
+use cdk_common::pub_sub::Event;
+use cdk_common::{
+    MeltQuoteBolt11Response, MintQuoteBolt11Response, MintQuoteBolt12Response, NotificationPayload,
+    ProofState,
+};
+use serde::de::DeserializeOwned;
+use serde::{Deserialize, Serialize};
+
+/// Simple wrapper over `NotificationPayload<QuoteId>` which is a foreign type
+#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(bound = "T: Serialize + DeserializeOwned")]
+pub struct MintEvent<T>(NotificationPayload<T>)
+where
+    T: Clone + Eq + PartialEq;
+
+impl<T> From<MintEvent<T>> for NotificationPayload<T>
+where
+    T: Clone + Eq + PartialEq,
+{
+    fn from(value: MintEvent<T>) -> Self {
+        value.0
+    }
+}
+
+impl<T> Deref for MintEvent<T>
+where
+    T: Clone + Eq + PartialEq,
+{
+    type Target = NotificationPayload<T>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl<T> From<ProofState> for MintEvent<T>
+where
+    T: Clone + Eq + PartialEq,
+{
+    fn from(value: ProofState) -> Self {
+        Self(NotificationPayload::ProofState(value))
+    }
+}
+
+impl<T> MintEvent<T>
+where
+    T: Clone + Eq + PartialEq,
+{
+    /// New instance
+    pub fn new(t: NotificationPayload<T>) -> Self {
+        Self(t)
+    }
+
+    /// Get inner
+    pub fn inner(&self) -> &NotificationPayload<T> {
+        &self.0
+    }
+
+    /// Into inner
+    pub fn into_inner(self) -> NotificationPayload<T> {
+        self.0
+    }
+}
+
+impl<T> From<NotificationPayload<T>> for MintEvent<T>
+where
+    T: Clone + Eq + PartialEq,
+{
+    fn from(value: NotificationPayload<T>) -> Self {
+        Self(value)
+    }
+}
+
+impl<T> From<MintQuoteBolt11Response<T>> for MintEvent<T>
+where
+    T: Clone + Eq + PartialEq,
+{
+    fn from(value: MintQuoteBolt11Response<T>) -> Self {
+        Self(NotificationPayload::MintQuoteBolt11Response(value))
+    }
+}
+
+impl<T> From<MeltQuoteBolt11Response<T>> for MintEvent<T>
+where
+    T: Clone + Eq + PartialEq,
+{
+    fn from(value: MeltQuoteBolt11Response<T>) -> Self {
+        Self(NotificationPayload::MeltQuoteBolt11Response(value))
+    }
+}
+
+impl<T> From<MintQuoteBolt12Response<T>> for MintEvent<T>
+where
+    T: Clone + Eq + PartialEq,
+{
+    fn from(value: MintQuoteBolt12Response<T>) -> Self {
+        Self(NotificationPayload::MintQuoteBolt12Response(value))
+    }
+}
+
+impl<T> Event for MintEvent<T>
+where
+    T: Clone + Serialize + DeserializeOwned + Debug + Ord + Hash + Send + Sync + Eq + PartialEq,
+{
+    type Topic = NotificationId<T>;
+
+    fn get_topics(&self) -> Vec<Self::Topic> {
+        vec![match &self.0 {
+            NotificationPayload::MeltQuoteBolt11Response(r) => {
+                NotificationId::MeltQuoteBolt11(r.quote.to_owned())
+            }
+            NotificationPayload::MintQuoteBolt11Response(r) => {
+                NotificationId::MintQuoteBolt11(r.quote.to_owned())
+            }
+            NotificationPayload::MintQuoteBolt12Response(r) => {
+                NotificationId::MintQuoteBolt12(r.quote.to_owned())
+            }
+            NotificationPayload::ProofState(p) => NotificationId::ProofState(p.y.to_owned()),
+        }]
+    }
+}

+ 13 - 8
crates/cdk/src/lib.rs

@@ -3,6 +3,10 @@
 #![warn(missing_docs)]
 #![warn(rustdoc::bare_urls)]
 
+// Disallow enabling `tor` feature on wasm32 with a clear error.
+#[cfg(all(target_arch = "wasm32", feature = "tor"))]
+compile_error!("The 'tor' feature is not supported on wasm32 targets (browser). Disable the 'tor' feature or use a non-wasm32 target.");
+
 pub mod cdk_database {
     //! CDK Database
     pub use cdk_common::database::Error;
@@ -28,11 +32,9 @@ mod bip353;
 #[cfg(all(any(feature = "wallet", feature = "mint"), feature = "auth"))]
 mod oidc_client;
 
-#[cfg(all(any(feature = "wallet", feature = "mint"), feature = "auth"))]
-pub use oidc_client::OidcClient;
-
-pub mod pub_sub;
-
+#[cfg(feature = "mint")]
+#[doc(hidden)]
+pub use cdk_common::payment as cdk_payment;
 /// Re-export amount type
 #[doc(hidden)]
 pub use cdk_common::{
@@ -40,10 +42,11 @@ pub use cdk_common::{
     error::{self, Error},
     lightning_invoice, mint_url, nuts, secret, util, ws, Amount, Bolt11Invoice,
 };
-#[cfg(feature = "mint")]
-#[doc(hidden)]
-pub use cdk_common::{payment as cdk_payment, subscription};
+#[cfg(all(any(feature = "wallet", feature = "mint"), feature = "auth"))]
+pub use oidc_client::OidcClient;
 
+#[cfg(any(feature = "wallet", feature = "mint"))]
+pub mod event;
 pub mod fees;
 
 #[doc(hidden)]
@@ -65,6 +68,8 @@ pub use self::wallet::HttpClient;
 #[doc(hidden)]
 pub type Result<T, E = Box<dyn std::error::Error>> = std::result::Result<T, E>;
 
+/// Re-export subscription
+pub use cdk_common::subscription;
 /// Re-export futures::Stream
 #[cfg(any(feature = "wallet", feature = "mint"))]
 pub use futures::{Stream, StreamExt};

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

+ 2 - 35
crates/cdk/src/mint/issue/mod.rs

@@ -322,12 +322,12 @@ impl Mint {
                 PaymentMethod::Bolt11 => {
                     let res: MintQuoteBolt11Response<QuoteId> = quote.clone().into();
                     self.pubsub_manager
-                        .broadcast(NotificationPayload::MintQuoteBolt11Response(res));
+                        .publish(NotificationPayload::MintQuoteBolt11Response(res));
                 }
                 PaymentMethod::Bolt12 => {
                     let res: MintQuoteBolt12Response<QuoteId> = quote.clone().try_into()?;
                     self.pubsub_manager
-                        .broadcast(NotificationPayload::MintQuoteBolt12Response(res));
+                        .publish(NotificationPayload::MintQuoteBolt12Response(res));
                 }
                 PaymentMethod::Custom(_) => {}
             }
@@ -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

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů