Răsfoiți Sursa

Merge branch 'main' into go-ffi

# Conflicts:
#	crates/cdk-ffi/uniffi.toml
asmo 5 luni în urmă
părinte
comite
9b5c76716b
100 a modificat fișierele cu 8824 adăugiri și 4256 ștergeri
  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

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff